diff --git a/gradle.properties b/gradle.properties index a75b6af..d05da32 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ org.gradle.jvmargs=-Xmx1024M -quarkusPlatformVersion=3.14.2 -quarkusPluginVersion=3.14.2 +quarkusPlatformVersion=3.14.4 +quarkusPluginVersion=3.14.4 sentryVersion=7.14.0 twelveMonkeysVersion=3.11.0 spotlessPluginVersion=6.25.0 diff --git a/src/main/java/app/fyreplace/api/data/Color.java b/src/main/java/app/fyreplace/api/data/Color.java new file mode 100644 index 0000000..6708dd3 --- /dev/null +++ b/src/main/java/app/fyreplace/api/data/Color.java @@ -0,0 +1,8 @@ +package app.fyreplace.api.data; + +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +public record Color( + @Schema(required = true, minimum = "0", maximum = "255", format = "uint8") int r, + @Schema(required = true, minimum = "0", maximum = "255", format = "uint8") int g, + @Schema(required = true, minimum = "0", maximum = "255", format = "uint8") int b) {} diff --git a/src/main/java/app/fyreplace/api/data/TokenCreation.java b/src/main/java/app/fyreplace/api/data/TokenCreation.java index e9f7a8d..bf35719 100644 --- a/src/main/java/app/fyreplace/api/data/TokenCreation.java +++ b/src/main/java/app/fyreplace/api/data/TokenCreation.java @@ -6,4 +6,4 @@ public record TokenCreation( @NotBlank @Length(max = Email.EMAIL_MAX_LENGTH) @Schema(maxLength = Email.EMAIL_MAX_LENGTH) String identifier, - @NotBlank String secret) {} + @NotBlank @Schema(format = "password") String secret) {} diff --git a/src/main/java/app/fyreplace/api/data/User.java b/src/main/java/app/fyreplace/api/data/User.java index 07ec1f4..8a31c6c 100644 --- a/src/main/java/app/fyreplace/api/data/User.java +++ b/src/main/java/app/fyreplace/api/data/User.java @@ -14,7 +14,7 @@ import jakarta.persistence.Table; import jakarta.ws.rs.NotAuthorizedException; import jakarta.ws.rs.core.SecurityContext; -import java.awt.Color; +import java.security.MessageDigest; import java.time.Duration; import java.time.Instant; import java.util.Arrays; @@ -22,7 +22,7 @@ import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; -import java.util.zip.CRC32; +import lombok.SneakyThrows; import org.eclipse.microprofile.openapi.annotations.media.Schema; import org.hibernate.annotations.OnDelete; import org.hibernate.annotations.OnDeleteAction; @@ -154,16 +154,17 @@ public boolean getBlocked() { return Block.count("source = ?1 and target = ?2", currentUser, this) > 0; } - @Schema(required = true, pattern = "^#[A-F0-9]{6}$") - public String getTint() { - final var crc = new CRC32(); - crc.update(username.getBytes()); - final var hue = (float) (crc.getValue() / Math.pow(2, 32)); + @SneakyThrows + @Schema(required = true) + public Color getTint() { + final var md5 = MessageDigest.getInstance("MD5"); + final var digest = md5.digest(username.getBytes()); + final var hue = bytesToFloat(digest); final var h = hue * 6; - final var variance = (h - (float) Math.floor(h)) * 0.25f; - final var brightness = (int) h % 2 == 0 ? 1f - variance : 0.75f + variance; - final var color = Color.HSBtoRGB(hue, 0.5f, brightness); - return "#%02X%02X%02X".formatted((color >> 16) & 0xFF, (color >> 8) & 0xFF, color & 0xFF); + final var variance = Math.abs(h - (float) Math.round(h)) * 0.15f; + final var brightness = Math.round(h) % 2 == 0 ? 0.75f - variance : 0.6f + variance; + final var color = java.awt.Color.HSBtoRGB(hue, 0.5f, brightness); + return new Color((color >> 2 * Byte.SIZE) & 0xFF, (color >> Byte.SIZE) & 0xFF, color & 0xFF); } @Override @@ -261,6 +262,17 @@ public static User getFromSecurityContext(final SecurityContext context, @Nullab return user; } + private static float bytesToFloat(byte[] bytes) { + final var bytesToUse = Math.min(bytes.length, Long.BYTES); + var result = 0L; + + for (var i = 0; i < bytesToUse; i++) { + result = (result << Byte.SIZE) | (bytes[i] & 0xFF); + } + + return (float) Math.abs((double) result / Long.MAX_VALUE); + } + public enum Rank { CITIZEN, MODERATOR, @@ -277,5 +289,5 @@ public record Profile( @Schema(required = true) UUID id, @Schema(required = true) String username, @Schema(required = true) String avatar, - @Schema(required = true) String tint) {} + @Schema(required = true) Color tint) {} } diff --git a/src/main/java/app/fyreplace/api/data/dev/DataSeeder.java b/src/main/java/app/fyreplace/api/data/dev/DataSeeder.java index f23be18..04f3a32 100644 --- a/src/main/java/app/fyreplace/api/data/dev/DataSeeder.java +++ b/src/main/java/app/fyreplace/api/data/dev/DataSeeder.java @@ -1,5 +1,6 @@ package app.fyreplace.api.data.dev; +import static java.util.Objects.requireNonNull; import static java.util.stream.IntStream.range; import app.fyreplace.api.data.Block; @@ -19,6 +20,7 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.event.Observes; import jakarta.transaction.Transactional; +import java.io.IOException; import org.eclipse.microprofile.config.inject.ConfigProperty; @SuppressWarnings({"UnusedReturnValue", "unused"}) @@ -44,8 +46,8 @@ public void onShutdown(@Observes final ShutdownEvent event) { @Transactional public void insertData() { - range(0, 20).forEach(i -> createUser("user_" + i, true)); - range(0, 10).forEach(i -> createUser("user_inactive_" + i, false)); + range(0, 20).forEach(i -> createUser("user_" + i, true, i % 2 == 0)); + range(0, 10).forEach(i -> createUser("user_inactive_" + i, false, false)); final var user = User.findByUsername("user_0"); range(0, 20).forEach(i -> createPost(user, "Post " + i, true, false)); range(0, 20).forEach(i -> createPost(user, "Draft " + i, false, false)); @@ -74,12 +76,24 @@ public void deleteData() { } @Transactional(Transactional.TxType.REQUIRES_NEW) - public User createUser(final String username, final boolean active) { + public User createUser(final String username, final boolean active, final boolean hasAvatar) { final var user = new User(); user.username = username; user.active = active; user.persist(); + if (hasAvatar) { + try (final var stream = getClass().getResourceAsStream("/META-INF/resources/images/logo-maskable.png")) { + final var storedFile = new StoredFile( + "avatars", user.username, requireNonNull(stream).readAllBytes()); + storedFile.persist(); + user.avatar = storedFile; + user.persist(); + } catch (final IOException e) { + throw new RuntimeException(e); + } + } + final var email = new Email(); email.user = user; email.email = username + "@example.org"; diff --git a/src/main/java/app/fyreplace/api/emails/EmailBase.java b/src/main/java/app/fyreplace/api/emails/EmailBase.java index 0fb27a7..69f9e38 100644 --- a/src/main/java/app/fyreplace/api/emails/EmailBase.java +++ b/src/main/java/app/fyreplace/api/emails/EmailBase.java @@ -9,7 +9,9 @@ import io.quarkus.mailer.Mailer; import io.quarkus.qute.TemplateInstance; import jakarta.inject.Inject; +import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.UriBuilder; +import jakarta.ws.rs.core.UriInfo; import java.net.URI; import java.time.Instant; import java.time.ZoneId; @@ -24,9 +26,6 @@ public abstract class EmailBase extends Mail { @ConfigProperty(name = "app.name") String appName; - @ConfigProperty(name = "app.url") - URI appUrl; - @ConfigProperty(name = "app.front.url") URI appFrontUrl; @@ -45,6 +44,9 @@ public abstract class EmailBase extends Mail { @Inject LocaleService localeService; + @Context + UriInfo uriInfo; + private boolean customDeepLinks; private String randomCodeClearText; @@ -85,8 +87,9 @@ protected String getRandomCode() { protected String getLink() { return UriBuilder.fromUri(appFrontUrl.toString()) .scheme(customDeepLinks ? appFrontCustomScheme : appFrontUrl.getScheme()) + .path(email.user.active ? "/login" : "/register") .queryParam("action", action()) - .fragment(email.email + ':' + getRandomCode()) + .fragment(getRandomCode()) .build() .toString(); } @@ -96,9 +99,11 @@ protected ResourceBundle getResourceBundle() { } protected TemplateCommonData getTemplateCommonData() { - return new TemplateCommonData(getResourceBundle(), appName, appUrl, appWebsiteUrl, getRandomCode(), getLink()); + return new TemplateCommonData( + getResourceBundle(), appName, uriInfo.getBaseUri(), appWebsiteUrl, getRandomCode(), getLink()); } + @SuppressWarnings("unused") public static final class TemplateCommonData { public final ResourceBundle res; public final String appName; @@ -117,5 +122,9 @@ private TemplateCommonData( this.code = code; this.link = link; } + + public URI makeAppUrl(String path) { + return UriBuilder.fromUri(appUrl).path(path).build(); + } } } diff --git a/src/main/java/app/fyreplace/api/endpoints/DevEndpoint.java b/src/main/java/app/fyreplace/api/endpoints/DevEndpoint.java index ecb3184..50f751f 100644 --- a/src/main/java/app/fyreplace/api/endpoints/DevEndpoint.java +++ b/src/main/java/app/fyreplace/api/endpoints/DevEndpoint.java @@ -1,10 +1,8 @@ package app.fyreplace.api.endpoints; -import app.fyreplace.api.cache.DuplicateRequestKeyGenerator; import app.fyreplace.api.data.User; import app.fyreplace.api.services.JwtService; import io.quarkus.arc.properties.IfBuildProperty; -import io.quarkus.cache.CacheResult; import io.quarkus.elytron.security.common.BcryptUtil; import jakarta.inject.Inject; import jakarta.ws.rs.GET; @@ -20,7 +18,6 @@ public final class DevEndpoint { @GET @Path("users/{username}/token") - @CacheResult(cacheName = "requests", keyGenerator = DuplicateRequestKeyGenerator.class) public String getUserToken(@PathParam("username") final String username) { final var user = User.findByUsername(username); diff --git a/src/main/java/app/fyreplace/api/endpoints/StoredFilesEndpoint.java b/src/main/java/app/fyreplace/api/endpoints/StoredFilesEndpoint.java index ad200ea..f719ec9 100644 --- a/src/main/java/app/fyreplace/api/endpoints/StoredFilesEndpoint.java +++ b/src/main/java/app/fyreplace/api/endpoints/StoredFilesEndpoint.java @@ -8,33 +8,33 @@ import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.RedirectionException; +import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response.Status; +import jakarta.ws.rs.core.UriInfo; import java.io.IOException; -import java.net.URI; import java.net.URISyntaxException; -import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.openapi.annotations.Operation; @Path("stored-files") public final class StoredFilesEndpoint { - @ConfigProperty(name = "app.url") - URI appUrl; - @Inject StorageService storageService; @Inject MimeTypeService mimeTypeService; + @Context + UriInfo uriInfo; + @GET @Path("{path:.*}") @Operation(hidden = true) public Response getStoredFile(@PathParam("path") final String path) throws URISyntaxException { - final var requestUri = storageService.getUri(path); + final var pathUri = storageService.getUri(path); - if (!appUrl.getHost().equals(requestUri.getHost())) { - throw new RedirectionException(Status.SEE_OTHER, requestUri); + if (!uriInfo.getBaseUri().getHost().equals(pathUri.getHost())) { + throw new RedirectionException(Status.SEE_OTHER, pathUri); } try { diff --git a/src/main/java/app/fyreplace/api/endpoints/TokensEndpoint.java b/src/main/java/app/fyreplace/api/endpoints/TokensEndpoint.java index eceb603..8a1faf6 100644 --- a/src/main/java/app/fyreplace/api/endpoints/TokensEndpoint.java +++ b/src/main/java/app/fyreplace/api/endpoints/TokensEndpoint.java @@ -51,7 +51,10 @@ public final class TokensEndpoint { @APIResponse( responseCode = "201", description = "Created", - content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(implementation = String.class))) + content = + @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema(implementation = String.class, format = "password"))) @APIResponse(responseCode = "400", description = "Bad Request") @APIResponse(responseCode = "404", description = "Not Found") @CacheResult(cacheName = "requests", keyGenerator = DuplicateRequestKeyGenerator.class) @@ -85,7 +88,13 @@ public Response createToken(@NotNull @Valid final TokenCreation input) { @GET @Path("new") @Authenticated - @APIResponse(responseCode = "200", description = "OK") + @APIResponse( + responseCode = "200", + description = "OK", + content = + @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema(implementation = String.class, format = "password"))) public String getNewToken() { return jwtService.makeJwt(User.getFromSecurityContext(context)); } diff --git a/src/main/java/app/fyreplace/api/services/MimeTypeService.java b/src/main/java/app/fyreplace/api/services/MimeTypeService.java index c727b7d..31ef633 100644 --- a/src/main/java/app/fyreplace/api/services/MimeTypeService.java +++ b/src/main/java/app/fyreplace/api/services/MimeTypeService.java @@ -17,11 +17,11 @@ public String getMimeType(final byte[] data) throws IOException { while (readers.hasNext()) { final var reader = readers.next(); - final var format = reader.getFormatName().toLowerCase(); + final var format = reader.getFormatName().toUpperCase(); try { return Arrays.stream(KnownFileType.values()) - .filter(m -> m.name.equals(format)) + .filter(m -> m.name().equals(format)) .findFirst() .orElseThrow() .mime; @@ -39,7 +39,8 @@ public String getExtension(final byte[] data) { .filter(m -> m.mime.equals(mime)) .findFirst() .orElseThrow() - .name(); + .name() + .toLowerCase(); } catch (final IOException | NoSuchElementException e) { return "unknown"; } diff --git a/src/main/java/app/fyreplace/api/services/mimetype/KnownFileType.java b/src/main/java/app/fyreplace/api/services/mimetype/KnownFileType.java index 0e4fa14..29522ef 100644 --- a/src/main/java/app/fyreplace/api/services/mimetype/KnownFileType.java +++ b/src/main/java/app/fyreplace/api/services/mimetype/KnownFileType.java @@ -1,15 +1,13 @@ package app.fyreplace.api.services.mimetype; public enum KnownFileType { - JPEG("jpeg", "image/jpeg"), - PNG("png", "image/png"), - WEBP("webp", "image/webp"); + JPEG("image/jpeg"), + PNG("image/png"), + WEBP("image/webp"); - public final String name; public final String mime; - KnownFileType(String name, String mime) { - this.name = name; + KnownFileType(String mime) { this.mime = mime; } } diff --git a/src/main/java/app/fyreplace/api/services/storage/LocalStorageServiceBase.java b/src/main/java/app/fyreplace/api/services/storage/LocalStorageServiceBase.java index c24148c..cfd2817 100644 --- a/src/main/java/app/fyreplace/api/services/storage/LocalStorageServiceBase.java +++ b/src/main/java/app/fyreplace/api/services/storage/LocalStorageServiceBase.java @@ -3,17 +3,17 @@ import app.fyreplace.api.endpoints.StoredFilesEndpoint; import app.fyreplace.api.services.StorageService; import jakarta.ws.rs.Path; -import jakarta.ws.rs.core.UriBuilder; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.UriInfo; import java.net.URI; -import org.eclipse.microprofile.config.inject.ConfigProperty; public abstract class LocalStorageServiceBase implements StorageService { - @ConfigProperty(name = "app.url") - URI appUrl; + @Context + UriInfo uriInfo; @Override public URI getUri(final String path) { final var pathBase = StoredFilesEndpoint.class.getAnnotation(Path.class).value(); - return UriBuilder.fromUri(appUrl).path(pathBase).path(path).build(); + return uriInfo.getBaseUriBuilder().path(pathBase).path(path).build(); } } diff --git a/src/main/resources/templates/_logo.mjml b/src/main/resources/templates/_logo.mjml index 0cb5194..cb1a135 100644 --- a/src/main/resources/templates/_logo.mjml +++ b/src/main/resources/templates/_logo.mjml @@ -1,2 +1,2 @@ - + diff --git a/src/main/resources/templates/_schema.mjml b/src/main/resources/templates/_schema.mjml index ad354ca..4f44710 100644 --- a/src/main/resources/templates/_schema.mjml +++ b/src/main/resources/templates/_schema.mjml @@ -8,7 +8,7 @@ "@context": "https://schema.org", "@type": "Organization", "url": "{d.websiteUrl}", - "logo": "{d.appUrl}/images/logo-maskable.png", + "logo": "{d.makeAppUrl('/images/logo-maskable.png')}", "name": "{d.appName}" }, "potentialAction": { diff --git a/src/test/java/app/fyreplace/api/testing/UserTestsBase.java b/src/test/java/app/fyreplace/api/testing/UserTestsBase.java index a530ae2..9cfa11c 100644 --- a/src/test/java/app/fyreplace/api/testing/UserTestsBase.java +++ b/src/test/java/app/fyreplace/api/testing/UserTestsBase.java @@ -11,8 +11,8 @@ public class UserTestsBase extends TransactionalTestsBase { @Override public void beforeEach() { super.beforeEach(); - range(0, getActiveUserCount()).forEach(i -> dataSeeder.createUser("user_" + i, true)); - range(0, getInactiveUserCount()).forEach(i -> dataSeeder.createUser("user_inactive_" + i, false)); + range(0, getActiveUserCount()).forEach(i -> dataSeeder.createUser("user_" + i, true, false)); + range(0, getInactiveUserCount()).forEach(i -> dataSeeder.createUser("user_inactive_" + i, false, false)); } public int getActiveUserCount() {