diff --git a/build.gradle b/build.gradle index ca3dd7c..075d540 100644 --- a/build.gradle +++ b/build.gradle @@ -18,6 +18,7 @@ group = "app.fyreplace" version = gitVersion() repositories { + maven { url "https://www.javaxt.com/maven/" } mavenCentral() mavenLocal() } @@ -47,6 +48,7 @@ dependencies { implementation("io.quarkus:quarkus-smallrye-openapi") implementation("io.quarkiverse.amazonservices:quarkus-amazon-s3") implementation("io.sentry:sentry-jul") + implementation("javaxt:javaxt-core:${javaXtVersion}") implementation("software.amazon.awssdk:url-connection-client") implementation("software.amazon.awssdk.crt:aws-crt") implementation("com.twelvemonkeys.imageio:imageio-webp:${twelveMonkeysVersion}") diff --git a/gradle.properties b/gradle.properties index d05da32..a6b93f8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,8 @@ org.gradle.jvmargs=-Xmx1024M -quarkusPlatformVersion=3.14.4 -quarkusPluginVersion=3.14.4 -sentryVersion=7.14.0 -twelveMonkeysVersion=3.11.0 +quarkusPlatformVersion=3.16.1 +quarkusPluginVersion=3.16.1 +sentryVersion=7.16.0 +javaXtVersion=2.1.9 +twelveMonkeysVersion=3.12.0 spotlessPluginVersion=6.25.0 -lombokPluginVersion=8.10 +lombokPluginVersion=8.10.2 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9355b41..df97d72 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/quarkus-sentry/deployment/build.gradle b/quarkus-sentry/deployment/build.gradle index 95a4393..d373d2e 100644 --- a/quarkus-sentry/deployment/build.gradle +++ b/quarkus-sentry/deployment/build.gradle @@ -9,6 +9,6 @@ dependencies { implementation("io.quarkus:quarkus-arc-deployment") implementation("io.quarkus:quarkus-core-deployment") implementation("io.quarkus:quarkus-opentelemetry-deployment") - implementation("io.quarkus:quarkus-resteasy-reactive-deployment") + implementation("io.quarkus:quarkus-rest-deployment") implementation(project(":quarkus-sentry:runtime")) } diff --git a/quarkus-sentry/runtime/build.gradle b/quarkus-sentry/runtime/build.gradle index 7ebd194..3a2f68a 100644 --- a/quarkus-sentry/runtime/build.gradle +++ b/quarkus-sentry/runtime/build.gradle @@ -9,7 +9,7 @@ repositories { dependencies { implementation("io.quarkus:quarkus-arc") implementation("io.quarkus:quarkus-core") - implementation("io.quarkus:quarkus-resteasy-reactive") + implementation("io.quarkus:quarkus-rest") implementation("io.sentry:sentry-jul") implementation("io.opentelemetry.instrumentation:opentelemetry-jdbc") } diff --git a/quarkus-sentry/runtime/src/main/java/app/fyreplace/api/sentry/config/SentryConfig.java b/quarkus-sentry/runtime/src/main/java/app/fyreplace/api/sentry/config/SentryConfig.java index 5a3c764..135ffee 100644 --- a/quarkus-sentry/runtime/src/main/java/app/fyreplace/api/sentry/config/SentryConfig.java +++ b/quarkus-sentry/runtime/src/main/java/app/fyreplace/api/sentry/config/SentryConfig.java @@ -1,28 +1,27 @@ package app.fyreplace.api.sentry.config; -import io.quarkus.runtime.annotations.ConfigItem; import io.quarkus.runtime.annotations.ConfigPhase; import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; import java.util.Optional; -@SuppressWarnings("OptionalUsedAsFieldOrParameterType") -@ConfigRoot(phase = ConfigPhase.RUN_TIME, name = "sentry") -public final class SentryConfig { +@ConfigMapping(prefix = "quarkus.sentry") +@ConfigRoot(phase = ConfigPhase.RUN_TIME) +public interface SentryConfig { /** * Sentry Data Source Name. */ - @ConfigItem - public Optional dsn = Optional.empty(); + Optional dsn(); /** * Environment the events are tagged with. */ - @ConfigItem - public Optional environment = Optional.empty(); + Optional environment(); /** * Percentage of performance events sent to Sentry. */ - @ConfigItem(defaultValue = "0.0") - public Optional tracesSampleRate = Optional.empty(); + @WithDefault("0.0") + Optional tracesSampleRate(); } diff --git a/quarkus-sentry/runtime/src/main/java/app/fyreplace/api/sentry/recorders/SentryRecorder.java b/quarkus-sentry/runtime/src/main/java/app/fyreplace/api/sentry/recorders/SentryRecorder.java index 24e7f72..a6b8488 100644 --- a/quarkus-sentry/runtime/src/main/java/app/fyreplace/api/sentry/recorders/SentryRecorder.java +++ b/quarkus-sentry/runtime/src/main/java/app/fyreplace/api/sentry/recorders/SentryRecorder.java @@ -17,7 +17,7 @@ @Recorder public class SentryRecorder { public RuntimeValue> create(final SentryConfig sentryConfig) { - if (sentryConfig.dsn.isEmpty()) { + if (sentryConfig.dsn().isEmpty()) { return new RuntimeValue<>(Optional.empty()); } @@ -27,9 +27,9 @@ public RuntimeValue> create(final SentryConfig sentryConfig) { final var options = new AtomicReference(); Sentry.init(it -> { - sentryConfig.dsn.ifPresent(it::setDsn); - sentryConfig.environment.ifPresent(it::setEnvironment); - sentryConfig.tracesSampleRate.ifPresent(it::setTracesSampleRate); + sentryConfig.dsn().ifPresent(it::setDsn); + sentryConfig.environment().ifPresent(it::setEnvironment); + sentryConfig.tracesSampleRate().ifPresent(it::setTracesSampleRate); it.setRelease(appName + '@' + appVersion); it.addInAppInclude("app.fyreplace.api"); it.setInstrumenter(Instrumenter.OTEL); diff --git a/src/main/java/app/fyreplace/api/data/CommentCreation.java b/src/main/java/app/fyreplace/api/data/CommentCreation.java index b73818e..e5c6fc2 100644 --- a/src/main/java/app/fyreplace/api/data/CommentCreation.java +++ b/src/main/java/app/fyreplace/api/data/CommentCreation.java @@ -1,8 +1,8 @@ package app.fyreplace.api.data; +import app.fyreplace.api.data.validators.Length; import jakarta.validation.constraints.NotBlank; import org.eclipse.microprofile.openapi.annotations.media.Schema; -import org.hibernate.validator.constraints.Length; public record CommentCreation( @Length(min = 1, max = Comment.TEXT_MAX_LENGTH) diff --git a/src/main/java/app/fyreplace/api/data/EmailActivation.java b/src/main/java/app/fyreplace/api/data/EmailActivation.java index bcaef9f..ff8db42 100644 --- a/src/main/java/app/fyreplace/api/data/EmailActivation.java +++ b/src/main/java/app/fyreplace/api/data/EmailActivation.java @@ -1,8 +1,8 @@ package app.fyreplace.api.data; +import app.fyreplace.api.data.validators.Length; import jakarta.validation.constraints.NotBlank; import org.eclipse.microprofile.openapi.annotations.media.Schema; -import org.hibernate.validator.constraints.Length; public record EmailActivation( @NotBlank diff --git a/src/main/java/app/fyreplace/api/data/EmailCreation.java b/src/main/java/app/fyreplace/api/data/EmailCreation.java index 9deb4f6..7bc2288 100644 --- a/src/main/java/app/fyreplace/api/data/EmailCreation.java +++ b/src/main/java/app/fyreplace/api/data/EmailCreation.java @@ -1,8 +1,8 @@ package app.fyreplace.api.data; +import app.fyreplace.api.data.validators.Length; import jakarta.validation.constraints.NotBlank; import org.eclipse.microprofile.openapi.annotations.media.Schema; -import org.hibernate.validator.constraints.Length; public record EmailCreation( @NotBlank diff --git a/src/main/java/app/fyreplace/api/data/NewTokenCreation.java b/src/main/java/app/fyreplace/api/data/NewTokenCreation.java index 0037d42..5643dea 100644 --- a/src/main/java/app/fyreplace/api/data/NewTokenCreation.java +++ b/src/main/java/app/fyreplace/api/data/NewTokenCreation.java @@ -1,8 +1,8 @@ package app.fyreplace.api.data; +import app.fyreplace.api.data.validators.Length; import jakarta.validation.constraints.NotBlank; import org.eclipse.microprofile.openapi.annotations.media.Schema; -import org.hibernate.validator.constraints.Length; public record NewTokenCreation( @NotBlank @Length(max = Email.EMAIL_MAX_LENGTH) @Schema(maxLength = Email.EMAIL_MAX_LENGTH) diff --git a/src/main/java/app/fyreplace/api/data/StoredFile.java b/src/main/java/app/fyreplace/api/data/StoredFile.java index c261379..c79ee9c 100644 --- a/src/main/java/app/fyreplace/api/data/StoredFile.java +++ b/src/main/java/app/fyreplace/api/data/StoredFile.java @@ -1,6 +1,6 @@ package app.fyreplace.api.data; -import app.fyreplace.api.services.MimeTypeService; +import app.fyreplace.api.services.ImageService; import app.fyreplace.api.services.StorageService; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.JsonSerializer; @@ -10,13 +10,14 @@ import jakarta.annotation.Nullable; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.PostPersist; -import jakarta.persistence.PreRemove; +import jakarta.persistence.PostRemove; +import jakarta.persistence.PrePersist; import jakarta.persistence.Table; import jakarta.persistence.Transient; +import jakarta.ws.rs.core.UriBuilder; import java.io.IOException; import java.net.URISyntaxException; -import java.nio.file.Paths; +import java.util.UUID; import org.eclipse.microprofile.openapi.annotations.media.Schema; @Entity @@ -26,7 +27,7 @@ public class StoredFile extends EntityBase { private StorageService storageService; @Transient - private MimeTypeService mimeTypeService; + private ImageService imageService; @Column(unique = true, nullable = false) @Schema(required = true) @@ -42,9 +43,12 @@ public StoredFile() { initServices(); } - public StoredFile(final String directory, final String name, @Nullable final byte[] data) { + public StoredFile(final String directory, @Nullable final byte[] data) { initServices(); - this.path = Paths.get(directory, name) + "." + mimeTypeService.getExtension(data); + this.path = UriBuilder.fromPath(directory) + .path(UUID.randomUUID() + "." + imageService.getExtension(data)) + .build() + .getPath(); this.data = data; } @@ -58,32 +62,26 @@ public String toString() { } } - public void store(final byte[] data) throws IOException { - if (data != null) { - storageService.store(path, data); - } - } - @SuppressWarnings("unused") - @PostPersist - final void postPersist() throws IOException { + @PrePersist + final void prePersist() throws IOException { if (data != null) { - store(data); + storageService.store(path, data); data = null; } } @SuppressWarnings("unused") - @PreRemove - final void preDestroy() throws IOException { + @PostRemove + final void postRemove() throws IOException { storageService.remove(path); } private void initServices() { try (final var storage = Arc.container().instance(StorageService.class); - final var mimeType = Arc.container().instance(MimeTypeService.class)) { + final var mimeType = Arc.container().instance(ImageService.class)) { storageService = storage.get(); - mimeTypeService = mimeType.get(); + imageService = mimeType.get(); } } diff --git a/src/main/java/app/fyreplace/api/data/TokenCreation.java b/src/main/java/app/fyreplace/api/data/TokenCreation.java index bf35719..5daeea6 100644 --- a/src/main/java/app/fyreplace/api/data/TokenCreation.java +++ b/src/main/java/app/fyreplace/api/data/TokenCreation.java @@ -1,8 +1,8 @@ package app.fyreplace.api.data; +import app.fyreplace.api.data.validators.Length; import jakarta.validation.constraints.NotBlank; import org.eclipse.microprofile.openapi.annotations.media.Schema; -import org.hibernate.validator.constraints.Length; public record TokenCreation( @NotBlank @Length(max = Email.EMAIL_MAX_LENGTH) @Schema(maxLength = Email.EMAIL_MAX_LENGTH) String identifier, diff --git a/src/main/java/app/fyreplace/api/data/User.java b/src/main/java/app/fyreplace/api/data/User.java index 8a31c6c..b2367d4 100644 --- a/src/main/java/app/fyreplace/api/data/User.java +++ b/src/main/java/app/fyreplace/api/data/User.java @@ -40,6 +40,8 @@ public class User extends UserDependentEntityBase implements Reportable { "authors", "fyreplace", "fyreplaces", + "guest", + "guests", "management", "managements", "manager", diff --git a/src/main/java/app/fyreplace/api/data/UserCreation.java b/src/main/java/app/fyreplace/api/data/UserCreation.java index 777d462..01dad78 100644 --- a/src/main/java/app/fyreplace/api/data/UserCreation.java +++ b/src/main/java/app/fyreplace/api/data/UserCreation.java @@ -1,9 +1,9 @@ package app.fyreplace.api.data; +import app.fyreplace.api.data.validators.Length; import app.fyreplace.api.data.validators.Regex; import jakarta.validation.constraints.NotBlank; import org.eclipse.microprofile.openapi.annotations.media.Schema; -import org.hibernate.validator.constraints.Length; public record UserCreation( @NotBlank 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 04f3a32..bc435bb 100644 --- a/src/main/java/app/fyreplace/api/data/dev/DataSeeder.java +++ b/src/main/java/app/fyreplace/api/data/dev/DataSeeder.java @@ -15,6 +15,7 @@ import app.fyreplace.api.data.Subscription; import app.fyreplace.api.data.User; import app.fyreplace.api.data.Vote; +import io.quarkus.elytron.security.common.BcryptUtil; import io.quarkus.runtime.ShutdownEvent; import io.quarkus.runtime.StartupEvent; import jakarta.enterprise.context.ApplicationScoped; @@ -46,8 +47,9 @@ public void onShutdown(@Observes final ShutdownEvent event) { @Transactional public void insertData() { - range(0, 20).forEach(i -> createUser("user_" + i, true, i % 2 == 0)); - range(0, 10).forEach(i -> createUser("user_inactive_" + i, false, false)); + createUser("demo", true, false, true); + range(0, 20).forEach(i -> createUser("user_" + i, true, i % 2 == 0, false)); + range(0, 10).forEach(i -> createUser("user_inactive_" + i, false, 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)); @@ -76,7 +78,8 @@ public void deleteData() { } @Transactional(Transactional.TxType.REQUIRES_NEW) - public User createUser(final String username, final boolean active, final boolean hasAvatar) { + public User createUser( + final String username, final boolean active, final boolean hasAvatar, final boolean usesPassword) { final var user = new User(); user.username = username; user.active = active; @@ -84,8 +87,8 @@ public User createUser(final String username, final boolean active, final boolea 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()); + final var storedFile = + new StoredFile("avatars", requireNonNull(stream).readAllBytes()); storedFile.persist(); user.avatar = storedFile; user.persist(); @@ -94,14 +97,21 @@ public User createUser(final String username, final boolean active, final boolea } } - final var email = new Email(); - email.user = user; - email.email = username + "@example.org"; - email.verified = active; - email.persist(); + if (usesPassword) { + final var password = new Password(); + password.user = user; + password.password = BcryptUtil.bcryptHash(username + "_password"); + password.persist(); + } else { + final var email = new Email(); + email.user = user; + email.email = username + "@example.org"; + email.verified = active; + email.persist(); + user.mainEmail = email; + user.persist(); + } - user.mainEmail = email; - user.persist(); return user; } diff --git a/src/main/java/app/fyreplace/api/data/validators/Length.java b/src/main/java/app/fyreplace/api/data/validators/Length.java new file mode 100644 index 0000000..da21295 --- /dev/null +++ b/src/main/java/app/fyreplace/api/data/validators/Length.java @@ -0,0 +1,26 @@ +package app.fyreplace.api.data.validators; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +@SuppressWarnings("unused") +@Retention(RUNTIME) +@Target({FIELD, PARAMETER}) +@Constraint(validatedBy = LengthValidator.class) +public @interface Length { + String message() default "does not meet length requirements"; + + Class[] groups() default {}; + + Class[] payload() default {}; + + int min() default 0; + + int max() default Integer.MAX_VALUE; +} diff --git a/src/main/java/app/fyreplace/api/data/validators/LengthValidator.java b/src/main/java/app/fyreplace/api/data/validators/LengthValidator.java new file mode 100644 index 0000000..399cfa7 --- /dev/null +++ b/src/main/java/app/fyreplace/api/data/validators/LengthValidator.java @@ -0,0 +1,21 @@ +package app.fyreplace.api.data.validators; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public final class LengthValidator implements ConstraintValidator { + private int min; + private int max; + + @Override + public void initialize(final Length constraintAnnotation) { + min = constraintAnnotation.min(); + max = constraintAnnotation.max(); + } + + @Override + public boolean isValid(final String value, final ConstraintValidatorContext context) { + final var count = value.codePointCount(0, value.length()); + return count >= min && count <= max; + } +} diff --git a/src/main/java/app/fyreplace/api/endpoints/ChaptersEndpoint.java b/src/main/java/app/fyreplace/api/endpoints/ChaptersEndpoint.java index 7800313..0a78d77 100644 --- a/src/main/java/app/fyreplace/api/endpoints/ChaptersEndpoint.java +++ b/src/main/java/app/fyreplace/api/endpoints/ChaptersEndpoint.java @@ -6,9 +6,11 @@ import app.fyreplace.api.data.Post; import app.fyreplace.api.data.StoredFile; import app.fyreplace.api.data.User; +import app.fyreplace.api.data.validators.Length; import app.fyreplace.api.exceptions.ExplainedFailure; import app.fyreplace.api.exceptions.ForbiddenException; -import app.fyreplace.api.services.MimeTypeService; +import app.fyreplace.api.services.ImageService; +import app.fyreplace.api.services.SanitizationService; import io.quarkus.cache.CacheResult; import io.quarkus.panache.common.Sort; import io.quarkus.security.Authenticated; @@ -36,7 +38,6 @@ import org.eclipse.microprofile.openapi.annotations.media.Schema; import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; -import org.hibernate.validator.constraints.Length; @Path("posts/{id}/chapters") public final class ChaptersEndpoint { @@ -44,7 +45,10 @@ public final class ChaptersEndpoint { int postsMaxChapterCount; @Inject - MimeTypeService mimeTypeService; + ImageService imageService; + + @Inject + SanitizationService sanitizationService; @Context SecurityContext context; @@ -153,9 +157,9 @@ public String setChapterText( try { final var chapter = getChapter(post, position); - chapter.text = input; + chapter.text = sanitizationService.sanitize(input); chapter.persist(); - return input; + return chapter.text; } catch (final IndexOutOfBoundsException e) { throw new NotFoundException(); } @@ -175,7 +179,7 @@ public String setChapterImage( @PathParam("position") @PositiveOrZero final int position, final byte[] input) throws IOException { - mimeTypeService.validate(input); + imageService.validate(input); final var user = User.getFromSecurityContext(context); final var post = Post.findById(id); Post.validateAccess(post, user, false, true); @@ -183,16 +187,17 @@ public String setChapterImage( try { final var chapter = getChapter(post, position); final var image = ImageIO.read(new ByteArrayInputStream(input)); + final var oldImage = chapter.image; chapter.width = image.getWidth(); chapter.height = image.getHeight(); + chapter.image = new StoredFile("chapters", imageService.shrink(input)); + chapter.image.persist(); + chapter.persist(); - if (chapter.image == null) { - chapter.image = new StoredFile("chapters", chapter.id.toString(), input); - } else { - chapter.image.store(input); + if (oldImage != null) { + oldImage.delete(); } - chapter.persist(); return chapter.image.toString(); } catch (final IndexOutOfBoundsException e) { throw new NotFoundException(); diff --git a/src/main/java/app/fyreplace/api/endpoints/CommentsEndpoint.java b/src/main/java/app/fyreplace/api/endpoints/CommentsEndpoint.java index e337a36..179c681 100644 --- a/src/main/java/app/fyreplace/api/endpoints/CommentsEndpoint.java +++ b/src/main/java/app/fyreplace/api/endpoints/CommentsEndpoint.java @@ -23,6 +23,7 @@ import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; @@ -197,6 +198,7 @@ public Response acknowledgeComment( @GET @Path("count") @Authenticated + @Produces(MediaType.APPLICATION_JSON) @APIResponse(responseCode = "200", description = "OK") @APIResponse(responseCode = "400", description = "Bad Request") @APIResponse(responseCode = "404", description = "Not Found") diff --git a/src/main/java/app/fyreplace/api/endpoints/EmailsEndpoint.java b/src/main/java/app/fyreplace/api/endpoints/EmailsEndpoint.java index 0537f09..5090f25 100644 --- a/src/main/java/app/fyreplace/api/endpoints/EmailsEndpoint.java +++ b/src/main/java/app/fyreplace/api/endpoints/EmailsEndpoint.java @@ -26,6 +26,7 @@ import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; @@ -147,6 +148,7 @@ public Response setMainEmail(@PathParam("id") final UUID id) { @GET @Path("count") @Authenticated + @Produces(MediaType.APPLICATION_JSON) @APIResponse(responseCode = "200", description = "OK") public long countEmails() { return Email.count("user", User.getFromSecurityContext(context)); diff --git a/src/main/java/app/fyreplace/api/endpoints/PostsEndpoint.java b/src/main/java/app/fyreplace/api/endpoints/PostsEndpoint.java index 62b60f5..faafe7d 100644 --- a/src/main/java/app/fyreplace/api/endpoints/PostsEndpoint.java +++ b/src/main/java/app/fyreplace/api/endpoints/PostsEndpoint.java @@ -27,6 +27,7 @@ import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; @@ -239,6 +240,7 @@ public Response votePost(@PathParam("id") final UUID id, @NotNull @Valid final V @GET @Path("count") @Authenticated + @Produces(MediaType.APPLICATION_JSON) @APIResponse(responseCode = "200", description = "OK") @APIResponse(responseCode = "400", description = "Bad Request") public long countPosts(@QueryParam("type") @NotNull final PostListingType type) { diff --git a/src/main/java/app/fyreplace/api/endpoints/StoredFilesEndpoint.java b/src/main/java/app/fyreplace/api/endpoints/StoredFilesEndpoint.java index f719ec9..d836961 100644 --- a/src/main/java/app/fyreplace/api/endpoints/StoredFilesEndpoint.java +++ b/src/main/java/app/fyreplace/api/endpoints/StoredFilesEndpoint.java @@ -1,6 +1,6 @@ package app.fyreplace.api.endpoints; -import app.fyreplace.api.services.MimeTypeService; +import app.fyreplace.api.services.ImageService; import app.fyreplace.api.services.StorageService; import jakarta.inject.Inject; import jakarta.ws.rs.GET; @@ -22,7 +22,7 @@ public final class StoredFilesEndpoint { StorageService storageService; @Inject - MimeTypeService mimeTypeService; + ImageService imageService; @Context UriInfo uriInfo; @@ -40,7 +40,7 @@ public Response getStoredFile(@PathParam("path") final String path) throws URISy try { final byte[] data; data = storageService.fetch(path); - return Response.ok(data).type(mimeTypeService.getMimeType(data)).build(); + return Response.ok(data).type(imageService.getMimeType(data)).build(); } catch (final IOException e) { throw new NotFoundException(); } diff --git a/src/main/java/app/fyreplace/api/endpoints/TokensEndpoint.java b/src/main/java/app/fyreplace/api/endpoints/TokensEndpoint.java index 8a1faf6..3c35b8e 100644 --- a/src/main/java/app/fyreplace/api/endpoints/TokensEndpoint.java +++ b/src/main/java/app/fyreplace/api/endpoints/TokensEndpoint.java @@ -60,8 +60,10 @@ public final class TokensEndpoint { @CacheResult(cacheName = "requests", keyGenerator = DuplicateRequestKeyGenerator.class) public Response createToken(@NotNull @Valid final TokenCreation input) { final var email = getEmail(input.identifier()); - final var password = Password.find("user", email.user).firstResult(); + final var password = + Password.find("user.username", input.identifier()).firstResult(); final RandomCode randomCode; + final User user; try (final var stream = RandomCode.stream("email", email)) { randomCode = stream.filter(rc -> BcryptUtil.matches(input.secret(), rc.code)) @@ -71,18 +73,16 @@ public Response createToken(@NotNull @Valid final TokenCreation input) { if (randomCode != null) { randomCode.validateEmail(); + user = email.user; } else if (password != null && BcryptUtil.matches(input.secret(), password.password)) { - email.verified = true; - email.persist(); + user = password.user; } else { throw new BadRequestException(); } - email.user.active = true; - email.user.persist(); - return Response.status(Status.CREATED) - .entity(jwtService.makeJwt(email.user)) - .build(); + user.active = true; + user.persist(); + return Response.status(Status.CREATED).entity(jwtService.makeJwt(user)).build(); } @GET @@ -118,7 +118,7 @@ public Response createNewToken( @NotNull @Valid final NewTokenCreation input, @QueryParam("customDeepLinks") final boolean customDeepLinks) { final var email = getEmail(input.identifier()); - final var hasPassword = Password.count("user", email.user) > 0; + final var hasPassword = Password.count("user.username", input.identifier()) > 0; if (hasPassword) { throw new ForbiddenException("user_has_password"); diff --git a/src/main/java/app/fyreplace/api/endpoints/UsersEndpoint.java b/src/main/java/app/fyreplace/api/endpoints/UsersEndpoint.java index 252ceaf..5994fcc 100644 --- a/src/main/java/app/fyreplace/api/endpoints/UsersEndpoint.java +++ b/src/main/java/app/fyreplace/api/endpoints/UsersEndpoint.java @@ -8,12 +8,14 @@ import app.fyreplace.api.data.StoredFile; import app.fyreplace.api.data.User; import app.fyreplace.api.data.UserCreation; +import app.fyreplace.api.data.validators.Length; import app.fyreplace.api.emails.UserActivationEmail; import app.fyreplace.api.exceptions.ConflictException; import app.fyreplace.api.exceptions.ExplainedFailure; import app.fyreplace.api.exceptions.ForbiddenException; import app.fyreplace.api.exceptions.GoneException; -import app.fyreplace.api.services.MimeTypeService; +import app.fyreplace.api.services.ImageService; +import app.fyreplace.api.services.SanitizationService; import io.quarkus.cache.CacheResult; import io.quarkus.hibernate.validator.runtime.jaxrs.ViolationReport; import io.quarkus.panache.common.Sort; @@ -33,13 +35,13 @@ import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response.Status; import jakarta.ws.rs.core.SecurityContext; -import java.io.IOException; import java.time.Duration; import java.time.Instant; import java.util.UUID; @@ -48,7 +50,6 @@ import org.eclipse.microprofile.openapi.annotations.media.Schema; import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; -import org.hibernate.validator.constraints.Length; @Path("users") public final class UsersEndpoint { @@ -56,7 +57,10 @@ public final class UsersEndpoint { int pagingSize; @Inject - MimeTypeService mimeTypeService; + ImageService imageService; + + @Inject + SanitizationService sanitizationService; @Inject UserActivationEmail userActivationEmail; @@ -231,9 +235,9 @@ public User getCurrentUser() { @APIResponse(responseCode = "400", description = "Bad Request") public String setCurrentUserBio(@NotNull @Length(max = User.BIO_MAX_LENGTH) final String input) { final var user = User.getFromSecurityContext(context, LockModeType.PESSIMISTIC_READ); - user.bio = input; + user.bio = sanitizationService.sanitize(input); user.persist(); - return input; + return user.bio; } @PUT @@ -244,17 +248,18 @@ public String setCurrentUserBio(@NotNull @Length(max = User.BIO_MAX_LENGTH) fina @APIResponse(responseCode = "200", description = "OK") @APIResponse(responseCode = "413", description = "Payload Too Large") @APIResponse(responseCode = "415", description = "Unsupported Media Type") - public String setCurrentUserAvatar(final byte[] input) throws IOException { - mimeTypeService.validate(input); + public String setCurrentUserAvatar(final byte[] input) { + imageService.validate(input); final var user = User.getFromSecurityContext(context, LockModeType.PESSIMISTIC_WRITE); + final var oldAvatar = user.avatar; + user.avatar = new StoredFile("avatars", imageService.shrink(input)); + user.avatar.persist(); + user.persist(); - if (user.avatar == null) { - user.avatar = new StoredFile("avatars", user.username, input); - } else { - user.avatar.store(input); + if (oldAvatar != null) { + oldAvatar.delete(); } - user.persist(); return user.avatar.toString(); } @@ -300,6 +305,7 @@ public Iterable listBlockedUsers(@QueryParam("page") @PositiveOrZe @GET @Path("blocked/count") @Authenticated + @Produces(MediaType.APPLICATION_JSON) @APIResponse(responseCode = "200", description = "OK") public long countBlockedUsers() { return Block.count("source", User.getFromSecurityContext(context)); diff --git a/src/main/java/app/fyreplace/api/exceptions/RequestEntityTooLargeException.java b/src/main/java/app/fyreplace/api/exceptions/RequestEntityTooLargeException.java new file mode 100644 index 0000000..6b870aa --- /dev/null +++ b/src/main/java/app/fyreplace/api/exceptions/RequestEntityTooLargeException.java @@ -0,0 +1,10 @@ +package app.fyreplace.api.exceptions; + +import jakarta.ws.rs.ClientErrorException; +import jakarta.ws.rs.core.Response; + +public class RequestEntityTooLargeException extends ClientErrorException { + public RequestEntityTooLargeException() { + super(Response.Status.REQUEST_ENTITY_TOO_LARGE); + } +} diff --git a/src/main/java/app/fyreplace/api/services/ImageService.java b/src/main/java/app/fyreplace/api/services/ImageService.java new file mode 100644 index 0000000..09c45ee --- /dev/null +++ b/src/main/java/app/fyreplace/api/services/ImageService.java @@ -0,0 +1,105 @@ +package app.fyreplace.api.services; + +import app.fyreplace.api.exceptions.RequestEntityTooLargeException; +import app.fyreplace.api.exceptions.UnsupportedMediaTypeException; +import app.fyreplace.api.services.mimetype.KnownFileType; +import io.quarkus.runtime.configuration.MemorySize; +import jakarta.enterprise.context.ApplicationScoped; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.Arrays; +import java.util.NoSuchElementException; +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javaxt.io.Image; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +@ApplicationScoped +public final class ImageService { + @ConfigProperty(name = "app.storage.limits.max-size") + MemorySize fileMaxSize; + + public String getMimeType(final byte[] data) throws IOException { + final var reader = getFirstValidReader(data); + final var format = reader.getFormatName().toUpperCase(); + + try { + return Arrays.stream(KnownFileType.values()) + .filter(m -> m.name().equals(format)) + .findFirst() + .orElseThrow() + .mime; + } catch (final NoSuchElementException e) { + throw new IOException(); + } + } + + public String getExtension(final byte[] data) { + try { + final var mime = getMimeType(data); + return Arrays.stream(KnownFileType.values()) + .filter(m -> m.mime.equals(mime)) + .findFirst() + .orElseThrow() + .name() + .toLowerCase(); + } catch (final IOException | NoSuchElementException e) { + return "unknown"; + } + } + + public void validate(final byte[] data) throws UnsupportedMediaTypeException { + try { + final var mimeType = getMimeType(data); + + if (Arrays.stream(KnownFileType.values()).noneMatch(m -> m.mime.equals(mimeType))) { + throw new UnsupportedMediaTypeException(); + } + } catch (final IOException e) { + throw new UnsupportedMediaTypeException(); + } + } + + public byte[] shrink(final byte[] data) { + return shrink(data, 1.5); + } + + private byte[] shrink(final byte[] data, final double scaleFactorIncrease) { + final var maxSize = fileMaxSize.asLongValue(); + + if (data.length <= maxSize) { + return data; + } + + final var inputImage = new Image(data); + final var scaleFactor = Math.sqrt((double) data.length / maxSize) * scaleFactorIncrease; + inputImage.resize((int) (inputImage.getWidth() / scaleFactor), (int) (inputImage.getHeight() / scaleFactor)); + inputImage.rotate(); + final var output = inputImage.getByteArray(); + + if (output.length <= maxSize) { + return output; + } else if (scaleFactorIncrease >= 3) { + throw new RequestEntityTooLargeException(); + } else { + return shrink(data, scaleFactorIncrease + 0.5); + } + } + + private ImageReader getFirstValidReader(final byte[] data) throws IOException { + try (final var input = ImageIO.createImageInputStream(new ByteArrayInputStream(data))) { + final var readers = ImageIO.getImageReaders(input); + + while (readers.hasNext()) { + final var reader = readers.next(); + final var format = reader.getFormatName().toUpperCase(); + + if (Arrays.stream(KnownFileType.values()).anyMatch(m -> m.name().equals(format))) { + return reader; + } + } + + throw new IOException(); + } + } +} diff --git a/src/main/java/app/fyreplace/api/services/MimeTypeService.java b/src/main/java/app/fyreplace/api/services/MimeTypeService.java deleted file mode 100644 index 31ef633..0000000 --- a/src/main/java/app/fyreplace/api/services/MimeTypeService.java +++ /dev/null @@ -1,60 +0,0 @@ -package app.fyreplace.api.services; - -import app.fyreplace.api.exceptions.UnsupportedMediaTypeException; -import app.fyreplace.api.services.mimetype.KnownFileType; -import jakarta.enterprise.context.ApplicationScoped; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.util.Arrays; -import java.util.NoSuchElementException; -import javax.imageio.ImageIO; - -@ApplicationScoped -public final class MimeTypeService { - public String getMimeType(final byte[] data) throws IOException { - final var input = ImageIO.createImageInputStream(new ByteArrayInputStream(data)); - final var readers = ImageIO.getImageReaders(input); - - while (readers.hasNext()) { - final var reader = readers.next(); - final var format = reader.getFormatName().toUpperCase(); - - try { - return Arrays.stream(KnownFileType.values()) - .filter(m -> m.name().equals(format)) - .findFirst() - .orElseThrow() - .mime; - } catch (final NoSuchElementException ignored) { - } - } - - throw new IOException(); - } - - public String getExtension(final byte[] data) { - try { - final var mime = getMimeType(data); - return Arrays.stream(KnownFileType.values()) - .filter(m -> m.mime.equals(mime)) - .findFirst() - .orElseThrow() - .name() - .toLowerCase(); - } catch (final IOException | NoSuchElementException e) { - return "unknown"; - } - } - - public void validate(final byte[] data) throws UnsupportedMediaTypeException { - try { - final var mimeType = getMimeType(data); - - if (Arrays.stream(KnownFileType.values()).noneMatch(m -> m.mime.equals(mimeType))) { - throw new UnsupportedMediaTypeException(); - } - } catch (final IOException e) { - throw new UnsupportedMediaTypeException(); - } - } -} diff --git a/src/main/java/app/fyreplace/api/services/SanitizationService.java b/src/main/java/app/fyreplace/api/services/SanitizationService.java new file mode 100644 index 0000000..793d6c7 --- /dev/null +++ b/src/main/java/app/fyreplace/api/services/SanitizationService.java @@ -0,0 +1,10 @@ +package app.fyreplace.api.services; + +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public final class SanitizationService { + public String sanitize(final String input) { + return input.replace('\r', '\n').replaceAll("\n\n+", "\n\n").trim(); + } +} diff --git a/src/main/java/app/fyreplace/api/services/storage/s3/S3StorageService.java b/src/main/java/app/fyreplace/api/services/storage/s3/S3StorageService.java index 726a52b..78fdbb1 100644 --- a/src/main/java/app/fyreplace/api/services/storage/s3/S3StorageService.java +++ b/src/main/java/app/fyreplace/api/services/storage/s3/S3StorageService.java @@ -1,6 +1,6 @@ package app.fyreplace.api.services.storage.s3; -import app.fyreplace.api.services.MimeTypeService; +import app.fyreplace.api.services.ImageService; import app.fyreplace.api.services.StorageService; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -33,7 +33,7 @@ public final class S3StorageService implements StorageService { ObjectMapper objectMapper; @Inject - MimeTypeService mimeTypeService; + ImageService imageService; public void onStartup(@Observes final StartupEvent event) { client.putBucketPolicy(b -> { @@ -61,7 +61,7 @@ public byte[] fetch(final String path) throws IOException { @Override public void store(final String path, final byte[] data) throws IOException { - final var mime = mimeTypeService.getMimeType(data); + final var mime = imageService.getMimeType(data); client.putObject(b -> b.bucket(config.bucket()).key(path).contentType(mime), RequestBody.fromBytes(data)); } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index b806a6f..eb4b5cb 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -18,7 +18,11 @@ quarkus: policy: "deny" cors: true limits: - max-body-size: "1M" + max-body-size: "10M" + rest: + jackson: + optimization: + enable-reflection-free-serializers: true otel: security-events: enabled: true @@ -61,6 +65,8 @@ app: s3: bucket: "" custom-endpoint: "" + limits: + max-size: "1M" posts: max-chapter-count: 10 starting-life: 4 diff --git a/src/test/java/app/fyreplace/api/testing/TransactionalTestsBase.java b/src/test/java/app/fyreplace/api/testing/TransactionalTestsBase.java index ad3d16e..bf4e39a 100644 --- a/src/test/java/app/fyreplace/api/testing/TransactionalTestsBase.java +++ b/src/test/java/app/fyreplace/api/testing/TransactionalTestsBase.java @@ -14,7 +14,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; -@WithTestResource(value = DatabaseTestResource.class, restrictToAnnotatedClass = false) +@WithTestResource(value = DatabaseTestResource.class) public abstract class TransactionalTestsBase { @Inject public DataSeeder dataSeeder; diff --git a/src/test/java/app/fyreplace/api/testing/UserTestsBase.java b/src/test/java/app/fyreplace/api/testing/UserTestsBase.java index 9cfa11c..bc3072a 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, false)); - range(0, getInactiveUserCount()).forEach(i -> dataSeeder.createUser("user_inactive_" + i, false, false)); + range(0, getActiveUserCount()).forEach(i -> dataSeeder.createUser("user_" + i, true, false, false)); + range(0, getInactiveUserCount()).forEach(i -> dataSeeder.createUser("user_inactive_" + i, false, false, false)); } public int getActiveUserCount() { diff --git a/src/test/java/app/fyreplace/api/testing/endpoints/tokens/CreateNewTokenTests.java b/src/test/java/app/fyreplace/api/testing/endpoints/tokens/CreateNewTokenTests.java index 7ba0cba..84f4fe4 100644 --- a/src/test/java/app/fyreplace/api/testing/endpoints/tokens/CreateNewTokenTests.java +++ b/src/test/java/app/fyreplace/api/testing/endpoints/tokens/CreateNewTokenTests.java @@ -5,6 +5,7 @@ import static java.util.Objects.requireNonNull; import static org.junit.jupiter.api.Assertions.assertEquals; +import app.fyreplace.api.data.Email; import app.fyreplace.api.data.NewTokenCreation; import app.fyreplace.api.data.Password; import app.fyreplace.api.data.User; @@ -51,9 +52,10 @@ public void createNewTokenWhileUserHasPassword() { password.user = user; password.password = BcryptUtil.bcryptHash("password"); password.persist(); + Email.delete("user", user); }); given().contentType(ContentType.JSON) - .body(new NewTokenCreation(user.mainEmail.email)) + .body(new NewTokenCreation(user.username)) .post("new") .then() .statusCode(403); diff --git a/src/test/java/app/fyreplace/api/testing/endpoints/tokens/CreateTokenTests.java b/src/test/java/app/fyreplace/api/testing/endpoints/tokens/CreateTokenTests.java index fe2fbeb..afd1a78 100644 --- a/src/test/java/app/fyreplace/api/testing/endpoints/tokens/CreateTokenTests.java +++ b/src/test/java/app/fyreplace/api/testing/endpoints/tokens/CreateTokenTests.java @@ -100,15 +100,13 @@ public void createTokenWithPassword() { final var randomCodeCount = RandomCode.count(); assertFalse(password.user.mainEmail.verified); given().contentType(ContentType.JSON) - .body(new TokenCreation(password.user.mainEmail.email, "password")) + .body(new TokenCreation(password.user.username, "password")) .post() .then() .statusCode(201) .contentType(ContentType.TEXT) .body(isA(String.class)); assertEquals(randomCodeCount, RandomCode.count()); - final var email = Email.find("id", password.user.mainEmail.id).firstResult(); - assertTrue(email.verified); } @Test @@ -165,6 +163,7 @@ public void beforeEach() { password.user = User.findByUsername("user_inactive_1"); password.password = BcryptUtil.bcryptHash("password"); password.persist(); + Email.delete("user", password.user); } private RandomCode makeRandomCode(final String username, final String code) {