init backend, added admin page in front

Signed-off-by: Nicolas Froger <nicolas@kektus.xyz>
This commit is contained in:
Nicolas Froger 2024-07-25 03:00:51 +02:00
commit ddc6c64f0f
No known key found for this signature in database
89 changed files with 5083 additions and 9 deletions

View file

@ -0,0 +1,23 @@
package fr.kektus.summer2024;
import io.smallrye.common.constraint.NotNull;
import jakarta.ws.rs.InternalServerErrorException;
import jakarta.ws.rs.WebApplicationException;
public enum Validators {
;
public static void assertNotNull(final Exception exception) {
if (exception == null) {
throw new InternalServerErrorException("Null exception was passed");
}
}
public static void assertNotNull(final Object object, final @NotNull WebApplicationException exception) {
assertNotNull(exception);
if (object == null) {
throw exception;
}
}
}

View file

@ -0,0 +1,7 @@
package fr.kektus.summer2024.converters;
public interface Converter<TYPE_LEFT, TYPE_RIGHT> {
public TYPE_LEFT toLeft(TYPE_RIGHT right);
public TYPE_RIGHT toRight(TYPE_LEFT left);
}

View file

@ -0,0 +1,25 @@
package fr.kektus.summer2024.converters;
import fr.kektus.summer2024.data.model.Post;
import fr.kektus.summer2024.domain.service.AssetService;
import fr.kektus.summer2024.presentation.rest.PostApi;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class PublicPostConverter {
@Inject AssetService assetService;
public PostApi.PostDto toPostDto(Post post) {
return new PostApi.PostDto().withId(post.id)
.withDate(post.date)
.withDescription(post.description)
.withAssets(post.assets.stream()
.map(assetService::getPresignedUrlForAsset)
.toList())
.withLocation(new PostApi.PostDto.Location().withCity(post.city)
.withCountry(post.country)
.withLat(post.latitude)
.withLon(post.longitude));
}
}

View file

@ -0,0 +1,22 @@
package fr.kektus.summer2024.data.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.With;
@Entity
@AllArgsConstructor @NoArgsConstructor @With
@Getter @Setter
public class Asset {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) public Long id;
public String filename;
@ManyToOne @JoinColumn(name = "post_id") public Post post;
}

View file

@ -0,0 +1,31 @@
package fr.kektus.summer2024.data.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.With;
import org.hibernate.Length;
import java.time.ZonedDateTime;
import java.util.List;
@Entity
@NoArgsConstructor @AllArgsConstructor
@With @Getter @Setter
public class Post {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) public Long id;
public ZonedDateTime date;
@Column(length = Length.LONG) public String description;
public Float latitude;
public Float longitude;
@Column(length = 64) public String city;
@Column(length = 64) public String country;
@OneToMany(mappedBy = "post") public List<Asset> assets;
}

View file

@ -0,0 +1,9 @@
package fr.kektus.summer2024.data.repository;
import fr.kektus.summer2024.data.model.Asset;
import io.quarkus.hibernate.orm.panache.PanacheRepository;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class AssetRepository implements PanacheRepository<Asset> {
}

View file

@ -0,0 +1,9 @@
package fr.kektus.summer2024.data.repository;
import fr.kektus.summer2024.data.model.Post;
import io.quarkus.hibernate.orm.panache.PanacheRepository;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class PostRepository implements PanacheRepository<Post> {
}

View file

@ -0,0 +1,11 @@
package fr.kektus.summer2024.domain.entity;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import lombok.With;
@NoArgsConstructor @AllArgsConstructor @With
public class AssetEntity {
public Long id;
public String presignedUrl;
}

View file

@ -0,0 +1,23 @@
package fr.kektus.summer2024.domain.entity;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.With;
import java.time.ZonedDateTime;
import java.util.List;
@NoArgsConstructor @AllArgsConstructor
@With @Getter @Setter
public class PostEntity {
public Long id;
public ZonedDateTime date;
public String description;
public Float latitude;
public Float longitude;
public String city;
public String country;
public List<Long> assets;
}

View file

@ -0,0 +1,24 @@
package fr.kektus.summer2024.domain.entity;
import fr.kektus.summer2024.data.model.Asset;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.With;
import java.time.ZonedDateTime;
import java.util.List;
@NoArgsConstructor @AllArgsConstructor
@With @Getter @Setter
public class PostNestedEntity {
public Long id;
public ZonedDateTime date;
public String description;
public Float latitude;
public Float longitude;
public String city;
public String country;
public List<AssetEntity> assets;
}

View file

@ -0,0 +1,14 @@
package fr.kektus.summer2024.domain.entity;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import lombok.With;
import java.util.Map;
@NoArgsConstructor @AllArgsConstructor @With
public class PresignedAsset {
public Long id;
public String filename;
public Map<String, String> formData;
}

View file

@ -0,0 +1,64 @@
package fr.kektus.summer2024.domain.service;
import fr.kektus.summer2024.data.model.Asset;
import fr.kektus.summer2024.data.repository.AssetRepository;
import fr.kektus.summer2024.domain.entity.PresignedAsset;
import io.minio.GetPresignedObjectUrlArgs;
import io.minio.MinioClient;
import io.minio.PostPolicy;
import io.minio.errors.MinioException;
import io.minio.http.Method;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.InternalServerErrorException;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.wildfly.common.Assert;
import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.ZonedDateTime;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@ApplicationScoped
public class AssetService {
@Inject AssetRepository assetRepository;
@Inject MinioClient minioClient;
@ConfigProperty(name = "kektus.assets.bucket") String bucketName;
@Transactional
public PresignedAsset createAsset(String filename) {
String actualFilename = UUID.randomUUID() + "_" + filename;
PostPolicy policy = new PostPolicy(bucketName, ZonedDateTime.now().plusMinutes(60));
policy.addEqualsCondition("key", actualFilename);
policy.addContentLengthRangeCondition(64 * 1024, 200 * 1024 * 1024);
try {
Map<String, String> formData = minioClient.getPresignedPostFormData(policy);
Asset asset = new Asset().withFilename(actualFilename);
assetRepository.persist(asset);
return new PresignedAsset().withId(asset.id).withFilename(actualFilename).withFormData(formData);
} catch (MinioException | InvalidKeyException | IOException | NoSuchAlgorithmException e) {
throw new InternalServerErrorException(e);
}
}
public String getPresignedUrlForAsset(Asset asset) {
Assert.assertNotNull(asset);
try {
return minioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(bucketName)
.object(asset.filename)
.expiry(10, TimeUnit.MINUTES)
.build());
} catch (MinioException | InvalidKeyException | IOException | NoSuchAlgorithmException e) {
throw new InternalServerErrorException(e);
}
}
}

View file

@ -0,0 +1,24 @@
package fr.kektus.summer2024.domain.service;
import fr.kektus.summer2024.Validators;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.NotAuthorizedException;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import org.wildfly.common.Assert;
@ApplicationScoped
public class AuthService {
@Inject Logger logger;
@ConfigProperty(name = "kektus.admin.token") String adminToken;
public boolean isAdminTokenValid(final String adminToken) {
logger.info("Checking admin token");
Validators.assertNotNull(adminToken, new NotAuthorizedException("no admin token provided"));
return adminToken.equals(this.adminToken);
}
}

View file

@ -0,0 +1,71 @@
package fr.kektus.summer2024.domain.service;
import fr.kektus.summer2024.data.model.Asset;
import fr.kektus.summer2024.data.model.Post;
import fr.kektus.summer2024.data.repository.AssetRepository;
import fr.kektus.summer2024.data.repository.PostRepository;
import fr.kektus.summer2024.domain.entity.AssetEntity;
import fr.kektus.summer2024.domain.entity.PostEntity;
import fr.kektus.summer2024.domain.entity.PostNestedEntity;
import io.quarkus.panache.common.Sort;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import org.wildfly.common.Assert;
import java.time.ZonedDateTime;
import java.util.List;
@ApplicationScoped
public class PostService {
@Inject PostRepository postRepository;
@Inject AssetRepository assetRepository;
@Inject AssetService assetService;
@Transactional
public List<PostNestedEntity> getAllPosts() {
return postRepository.findAll().stream().map(post -> new PostNestedEntity()
.withId(post.id)
.withDescription(post.description)
.withDate(post.date)
.withLatitude(post.latitude)
.withLongitude(post.longitude)
.withCity(post.city)
.withCountry(post.country)
.withAssets(post.assets.stream().map(asset -> new AssetEntity()
.withId(asset.id)
.withPresignedUrl(assetService.getPresignedUrlForAsset(asset))
).toList())).toList();
}
public List<Post> getAllPostsOrdered() {
return postRepository.findAll(Sort.descending("id")).stream().toList();
}
@Transactional
public void createPost(PostEntity post) {
Assert.assertNotNull(post);
Assert.assertNotNull(post.description);
Assert.assertNotNull(post.latitude);
Assert.assertNotNull(post.longitude);
Assert.assertNotNull(post.city);
Assert.assertNotNull(post.country);
Assert.assertNotNull(post.assets);
Assert.assertFalse(post.assets.isEmpty());
List<Asset> postAssets = post.assets.stream().map(assetRepository::findById).toList();
Post model = new Post().withDescription(post.description)
.withDate(ZonedDateTime.now())
.withLatitude(post.latitude)
.withLongitude(post.longitude)
.withCity(post.city)
.withCountry(post.country)
.withAssets(postAssets);
postRepository.persist(model);
postAssets.forEach(asset -> asset.setPost(model));
postAssets.forEach(asset -> assetRepository.persist(asset));
post.id = model.id;
post.date = model.date;
}
}

View file

@ -0,0 +1,55 @@
package fr.kektus.summer2024.presentation.rest;
import fr.kektus.summer2024.domain.service.AssetService;
import fr.kektus.summer2024.domain.service.AuthService;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.NotAuthorizedException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import lombok.With;
import org.jboss.resteasy.reactive.RestHeader;
import org.wildfly.common.Assert;
import java.util.Map;
@Path("/admin/assets")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class AdminAssetApi {
@Inject AssetService assetService;
@Inject AuthService authService;
@POST
public CreateAssets.Response createAsset(@RestHeader("X-admin-token") final String adminToken, CreateAssets.Request request) {
if (!authService.isAdminTokenValid(adminToken))
throw new NotAuthorizedException("provided admin token is invalid");
Assert.assertNotNull(request);
Assert.assertNotNull(request.filename);
final var asset = assetService.createAsset(request.filename);
return new CreateAssets.Response()
.withId(asset.id)
.withFilename(asset.filename)
.withFormData(asset.formData);
}
public static class CreateAssets {
@NoArgsConstructor @AllArgsConstructor @With
public static class Request {
public String filename;
}
@NoArgsConstructor @AllArgsConstructor @With
public static class Response {
public Long id;
public String filename;
public Map<String, String> formData;
}
}
}

View file

@ -0,0 +1,36 @@
package fr.kektus.summer2024.presentation.rest;
import fr.kektus.summer2024.domain.entity.PostEntity;
import fr.kektus.summer2024.domain.service.AuthService;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotAuthorizedException;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import lombok.With;
import org.jboss.resteasy.reactive.RestHeader;
@Path("/admin/auth")
@Produces(MediaType.APPLICATION_JSON)
public class AdminAuthApi {
@Inject AuthService authService;
@GET @Path("/check")
public CheckAuth.Response checkAuth(@RestHeader("X-admin-token") final String adminToken) {
if (!authService.isAdminTokenValid(adminToken))
throw new NotAuthorizedException("provided admin token is invalid");
return new CheckAuth.Response().withStatus("ok");
}
public static class CheckAuth {
@AllArgsConstructor @NoArgsConstructor @With
public static class Response {
public String status;
}
}
}

View file

@ -0,0 +1,65 @@
package fr.kektus.summer2024.presentation.rest;
import fr.kektus.summer2024.domain.entity.PostEntity;
import fr.kektus.summer2024.domain.entity.PostNestedEntity;
import fr.kektus.summer2024.domain.service.AuthService;
import fr.kektus.summer2024.domain.service.PostService;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotAuthorizedException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import lombok.With;
import org.jboss.resteasy.reactive.RestHeader;
import java.util.List;
@Path("/admin/posts")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class AdminPostApi {
@Inject PostService postService;
@Inject AuthService authService;
@POST
public PostEntity createPost(@RestHeader("X-admin-token") final String adminToken, CreatePost.Request request) {
if (!authService.isAdminTokenValid(adminToken))
throw new NotAuthorizedException("provided admin token is invalid");
PostEntity post = new PostEntity().withDescription(request.description)
.withLatitude(request.latitude)
.withLongitude(request.longitude)
.withCity(request.city)
.withCountry(request.country)
.withAssets(request.assets);
postService.createPost(post);
return post;
}
@GET
public List<PostNestedEntity> getPosts(@RestHeader("X-admin-token") final String adminToken) {
if (!authService.isAdminTokenValid(adminToken))
throw new NotAuthorizedException("provided admin token is invalid");
return postService.getAllPosts();
}
public static class CreatePost {
@NoArgsConstructor @AllArgsConstructor @With
public static class Request {
public String description;
public Float latitude;
public Float longitude;
public String city;
public String country;
public List<Long> assets;
}
}
}

View file

@ -0,0 +1,50 @@
package fr.kektus.summer2024.presentation.rest;
import fr.kektus.summer2024.converters.PublicPostConverter;
import fr.kektus.summer2024.domain.service.PostService;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.With;
import java.time.ZonedDateTime;
import java.util.List;
@Path("/posts")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class PostApi {
@Inject PostService postService;
@Inject PublicPostConverter postConverter;
@NoArgsConstructor @AllArgsConstructor @With
@Getter @Setter
public static class PostDto {
@NoArgsConstructor @AllArgsConstructor @With
@Getter @Setter
public static class Location {
public Float lat;
public Float lon;
public String city;
public String country;
}
public Long id;
public ZonedDateTime date;
public String description;
public Location location;
public List<String> assets;
}
@GET
public List<PostDto> list() {
return postService.getAllPostsOrdered().stream().map(postConverter::toPostDto).toList();
}
}