diff --git a/.github/workflows/JavaMavenCI.yml b/.github/workflows/JavaMavenCI.yml new file mode 100644 index 0000000..690e6e9 --- /dev/null +++ b/.github/workflows/JavaMavenCI.yml @@ -0,0 +1,114 @@ +name: Java CI with Maven + +on: + pull_request: + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: maven + - name: Compile with maven + run: mvn clean compile + + - name: Update dependency graph + uses: advanced-security/maven-dependency-submission-action@571e99aab1055c2e71a1e2309b9691de18d6b7d6 + + checkstyle: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: maven + - name: Compile with maven + run: mvn checkstyle:check + + testing: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: maven + - name: Compile maven project + run: mvn clean compile + - name: Test maven project + run: mvn test + - name: Add coverage to PR + id: jacoco + uses: madrapps/jacoco-report@v1.6.1 + with: + paths: | + ${{ github.workspace }}/**/target/site/jacoco/*.xml + token: ${{ secrets.TEST_SECRET }} + min-coverage-overall: 50 + min-coverage-changed-files: 50 + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: jacoco-report + path: ${{ github.workspace }}/**/target/site/jacoco/ + + - name: Fail PR if overall coverage is less than 50% + if: ${{ steps.jacoco.outputs.coverage-overall < 50.0 }} + uses: actions/github-script@v6 + with: + script: | + core.setFailed('Overall coverage is less than 50%!') + + docker: + runs-on: ubuntu-latest + steps: + - + name: Checkout + uses: actions/checkout@v4 + - + name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: maven + - + name: Package + run: mvn clean package + - + name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - + name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + - + name: Build and push + uses: docker/build-push-action@v5 + with: + context: ./api/. + push: true + tags: asavershin/api:latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ed0d6b --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000..cb28b0e Binary files /dev/null and b/.mvn/wrapper/maven-wrapper.jar differ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..5f0536e --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.5/apache-maven-3.9.5-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar diff --git a/README.md b/README.md index d14d000..8b17dda 100644 --- a/README.md +++ b/README.md @@ -1 +1,7 @@ -# Images \ No newline at end of file +# Start api + +./mvnw clean package +В папке api: docker compose up -d + + + diff --git a/api/.env b/api/.env new file mode 100644 index 0000000..c2d0dce --- /dev/null +++ b/api/.env @@ -0,0 +1,36 @@ +SERVER_PORT=8081 + +# DATABASE +HOST=db-api +PORT_DB=5432 +POSTGRES_SCHEMA=public +POSTGRES_DB=postgres +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +LOG_SQL=true + +# REDIS +REDIS_HOST=redis-api +REDIS_PORT=6379 +REDIS_PASSWORD=cGFzc3dvcmxk +REDIS_CACHE_TIME=86400000 + +# MINIO +MINIO_BUCKET=files +MINIO_URL=http://minio-api:9000 +MINIO_ROOT_USER=minioadmin +MINIO_ROOT_PASSWORD=minioadmin +MINIO_CONSOLE_PORT=9090 +MINIO_PORT=9000 + +# MONGO +MONGO_INITDB_ROOT_USERNAME=admin +MONGO_INITDB_ROOT_PASSWORD=admin +MONGO_DB=mongo +MONGO_PORT=27017 +MONGO_HOST=mongodb-note + +# AUTH +ACCESS_EXPIRATION=86400000 +REFRESH_EXPIRATION=604800000 +JWT_SECRET=404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970 \ No newline at end of file diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..79aace5 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,7 @@ +FROM openjdk:21 + +WORKDIR /app + +COPY target/*.jar app.jar + +CMD ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..9ebc64d --- /dev/null +++ b/api/README.md @@ -0,0 +1,5 @@ +# Info +.env файл со всем окружением/портами + + + diff --git a/api/docker-compose.yml b/api/docker-compose.yml new file mode 100644 index 0000000..aaf3a8f --- /dev/null +++ b/api/docker-compose.yml @@ -0,0 +1,52 @@ +version: '3.8' + +services: + + backend-api: + container_name: backend-api + build: + context: . + dockerfile: Dockerfile + ports: + - "8081:8081" + depends_on: + - db-api + - redis-api + env_file: + - .env + db-api: + image: postgres:15.1-alpine + container_name: db-api + env_file: + - .env + ports: + - "5440:5432" + volumes: + - db-api-data:/var/lib/postgresql/data/ + + redis-api: + image: redis:7.2-rc-alpine + restart: always + container_name: redis-api + ports: + - '6379:6379' + command: redis-server --save 20 1 --loglevel debug --requirepass ${REDIS_PASSWORD} + volumes: + - redis-api-data:/data + + minio-api: + image: minio/minio:latest + container_name: minio-api + env_file: + - .env + command: server ~/minio --console-address :9090 + ports: + - '9090:9090' + - '9000:9000' + volumes: + - minio-api-data:/minio + +volumes: + db-api-data: + redis-api-data: + minio-api-data: \ No newline at end of file diff --git a/api/pom.xml b/api/pom.xml new file mode 100644 index 0000000..8fbee14 --- /dev/null +++ b/api/pom.xml @@ -0,0 +1,260 @@ + + + 4.0.0 + + com.github.asavershin.images + images + 0.0.1-SNAPSHOT + + + api + + + 21 + 21 + UTF-8 + 3.19.3 + 2.1.0 + 0.11.5 + 8.5.7 + + + 1.19.1 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.starter.version} + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-security + + + + + + + io.minio + minio + ${minio.version} + + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + + + org.springframework.boot + spring-boot-starter-jooq + + + org.jooq + jooq + ${jooq.version} + + + org.jooq + jooq-meta + ${jooq.version} + + + org.jooq + jooq-codegen + ${jooq.version} + + + org.jooq + jooq-meta-extensions + ${jooq.version} + + + + + org.liquibase + liquibase-core + + + + org.postgresql + postgresql + + + + + + ch.qos.logback + logback-classic + ${logback.version} + + + + + io.jsonwebtoken + jjwt-api + ${jsonwebtoken.version} + + + + io.jsonwebtoken + jjwt-impl + ${jsonwebtoken.version} + runtime + + + + io.jsonwebtoken + jjwt-jackson + ${jsonwebtoken.version} + runtime + + + + + + + + + + org.testcontainers + postgresql + ${containers.version} + test + + + + org.testcontainers + minio + ${containers.version} + test + + + + org.testcontainers + testcontainers + ${containers.version} + test + + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + org.jooq + jooq-codegen-maven + ${jooq.version} + + + generate-jooq-sources + generate-sources + + generate + + + + + org.jooq.meta.extensions.ddl.DDLDatabase + + + scripts + src/main/resources/db/changelog/migrations/*create*.sql + + + sort + semantic + + + unqualifiedSchema + none + + + defaultNameCase + lower + + + + + asavershin.generated.package + target/generated-sources/jooq + + + + + + + + + org.jacoco + jacoco-maven-plugin + 0.8.10 + + + + prepare-agent + + + + report + test + + report + + + + + + + com/github/asavershin/api/domain/** + com/github/asavershin/api/application/in/** + com/github/asavershin/api/infrastructure/out/** + + + + + + + \ No newline at end of file diff --git a/api/src/main/java/com/github/asavershin/api/ApiMain.java b/api/src/main/java/com/github/asavershin/api/ApiMain.java new file mode 100644 index 0000000..0ed4b22 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/ApiMain.java @@ -0,0 +1,22 @@ +package com.github.asavershin.api; + + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + + +@SpringBootApplication +public class ApiMain { + /** + * The main method that starts the Spring Boot application. + * + * @param args the command-line arguments + */ + public static void main(final String[] args) { + SpringApplication.run(ApiMain.class, args); + } + + private void foo() { + throw new UnsupportedOperationException(); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/application/in/services/image/ImageService.java b/api/src/main/java/com/github/asavershin/api/application/in/services/image/ImageService.java new file mode 100644 index 0000000..d2c4f5d --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/application/in/services/image/ImageService.java @@ -0,0 +1,33 @@ +package com.github.asavershin.api.application.in.services.image; + +import com.github.asavershin.api.domain.image.ImageId; +import com.github.asavershin.api.domain.user.UserId; +import org.springframework.web.multipart.MultipartFile; + +public interface ImageService { + /** + * Stores an image for a given user. + * + * @param userId the user who owns the image + * @param multipartFile the image file to be stored + * @return the unique identifier of the stored image + */ + ImageId storeImage(UserId userId, MultipartFile multipartFile); + /** + * Deletes an image by its unique identifier. Validate that + * image belongs to user. + * + * @param userId the user who owns the image + * @param imageId the unique identifier of the image to be deleted + */ + void deleteImageByImageId(UserId userId, ImageId imageId); + /** + * Downloads an image by its unique identifier. Validate that + * image belongs to user. + * + * @param imageId the unique identifier of the image to be downloaded + * @param userId the user who owns the image + * @return the byte array representation of the image + */ + byte[] downloadImage(ImageId imageId, UserId userId); +} diff --git a/api/src/main/java/com/github/asavershin/api/application/in/services/image/impl/ImageServiceImpl.java b/api/src/main/java/com/github/asavershin/api/application/in/services/image/impl/ImageServiceImpl.java new file mode 100644 index 0000000..eb2fc9a --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/application/in/services/image/impl/ImageServiceImpl.java @@ -0,0 +1,108 @@ +package com.github.asavershin.api.application.in.services.image.impl; + +import com.github.asavershin.api.application.in.services.image.ImageService; +import com.github.asavershin.api.application.out.MinioService; +import com.github.asavershin.api.domain.image.DeleteImageOfUser; +import com.github.asavershin.api.domain.image.GetImageOfUser; +import com.github.asavershin.api.domain.image.Image; +import com.github.asavershin.api.domain.image.ImageId; +import com.github.asavershin.api.domain.image.ImageNameWithExtension; +import com.github.asavershin.api.domain.image.MetaData; +import com.github.asavershin.api.domain.image.StoreImageOfUser; +import com.github.asavershin.api.domain.user.UserId; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.Serializable; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ImageServiceImpl implements ImageService, Serializable { + /** + * The MinioService is used to interact with the Minio storage service. + */ + private final MinioService minioService; + + /** + * The GetImageOfUser service is used to retrieve an image + * by its ID and user ID. + */ + private final GetImageOfUser getImageOfUser; + + /** + * The DeleteImageOfUser service is used to delete an image + * by its ID and user ID. + */ + private final DeleteImageOfUser deleteImageOfUser; + + /** + * The StoreImageOfUser service is used to store an image + * with its metadata and associate it with a user. + */ + private final StoreImageOfUser storeImageOfUser; + + /** + * Method not marked as final to allow Spring + * to create a proxy first for transactional purposes. + * @param userId the user who owns the image + * @param multipartFile the image file to be stored + * @return ID of new stored image + */ + @Override + @Transactional + public ImageId storeImage(final UserId userId, + final MultipartFile multipartFile) { + var metaInfo = new MetaData( + ImageNameWithExtension + .fromOriginalFileName( + multipartFile.getOriginalFilename() + ), + multipartFile.getSize() + ); + var imageId = ImageId.nextIdentity(); + storeImageOfUser.storeImageOfUser( + new Image( + imageId, + metaInfo, + userId + ) + ); + minioService.saveFile(multipartFile, imageId.value().toString()); + return imageId; + } + + /** + * Method not marked as final to allow Spring + * * to create a proxy first for transactional purposes. + * @param userId the user who owns the image + * @param imageId the unique identifier of the image to be deleted + */ + @Override + @Transactional + public void deleteImageByImageId(final UserId userId, + final ImageId imageId) { + deleteImageOfUser.removeImageOfUser(imageId, userId); + minioService.deleteFiles(List.of(imageId.value().toString())); + } + + /** + * Method not marked as final to allow Spring make CGLIB proxy. + * @param imageId the unique identifier of the image to be downloaded + * @param userId the user who owns the image + * @return bytes of the image + */ + @Override + public byte[] downloadImage(final ImageId imageId, + final UserId userId) { + return minioService.getFile( + getImageOfUser.getImageOfUser( + userId, + imageId + ) + .imageId().value().toString() + ); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/application/in/services/image/impl/package-info.java b/api/src/main/java/com/github/asavershin/api/application/in/services/image/impl/package-info.java new file mode 100644 index 0000000..98dee3f --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/application/in/services/image/impl/package-info.java @@ -0,0 +1,5 @@ +/** + * This package contains implementation application services for images. + * @author asavershin + */ +package com.github.asavershin.api.application.in.services.image.impl; diff --git a/api/src/main/java/com/github/asavershin/api/application/in/services/image/package-info.java b/api/src/main/java/com/github/asavershin/api/application/in/services/image/package-info.java new file mode 100644 index 0000000..9480e7d --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/application/in/services/image/package-info.java @@ -0,0 +1,5 @@ +/** + * This package contains application services for images. + * @author asavershin + */ +package com.github.asavershin.api.application.in.services.image; diff --git a/api/src/main/java/com/github/asavershin/api/application/in/services/user/ApplicationCredentials.java b/api/src/main/java/com/github/asavershin/api/application/in/services/user/ApplicationCredentials.java new file mode 100644 index 0000000..8ab9109 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/application/in/services/user/ApplicationCredentials.java @@ -0,0 +1,29 @@ +package com.github.asavershin.api.application.in.services.user; + +import lombok.Getter; + +@Getter +public class ApplicationCredentials { + /** + * The access token for user. + */ + private final String accessToken; + + /** + * The refresh token for user. + */ + private final String refreshToken; + + /** + * Constructs an instance of {@link ApplicationCredentials} + * with the provided access token and refresh token. + * + * @param aAccessToken The access token for user. + * @param aRefreshToken The refresh token for user. + */ + public ApplicationCredentials(final String aAccessToken, + final String aRefreshToken) { + this.accessToken = aAccessToken; + this.refreshToken = aRefreshToken; + } +} diff --git a/api/src/main/java/com/github/asavershin/api/application/in/services/user/GetNewCredentials.java b/api/src/main/java/com/github/asavershin/api/application/in/services/user/GetNewCredentials.java new file mode 100644 index 0000000..cf788e9 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/application/in/services/user/GetNewCredentials.java @@ -0,0 +1,14 @@ +package com.github.asavershin.api.application.in.services.user; + +import com.github.asavershin.api.domain.user.Credentials; + +@FunctionalInterface +public interface GetNewCredentials { + /** + * Generates new credentials for the user based on the provided credentials. + * + * @param credentials The current credentials of the user. + * @return The new credentials for the user. + */ + ApplicationCredentials get(Credentials credentials); +} diff --git a/api/src/main/java/com/github/asavershin/api/application/in/services/user/GetNewCredentialsUsingRefreshToken.java b/api/src/main/java/com/github/asavershin/api/application/in/services/user/GetNewCredentialsUsingRefreshToken.java new file mode 100644 index 0000000..1cdda6b --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/application/in/services/user/GetNewCredentialsUsingRefreshToken.java @@ -0,0 +1,14 @@ +package com.github.asavershin.api.application.in.services.user; + +import com.github.asavershin.api.domain.user.Credentials; + +@FunctionalInterface +public interface GetNewCredentialsUsingRefreshToken { + /** + * Generates new credentials using the provided refresh token. + * + * @param credentials The credentials that contain the refresh token. + * @return The new credentials generated using the refresh token. + */ + ApplicationCredentials get(Credentials credentials); +} diff --git a/api/src/main/java/com/github/asavershin/api/application/in/services/user/JwtService.java b/api/src/main/java/com/github/asavershin/api/application/in/services/user/JwtService.java new file mode 100644 index 0000000..ee436d2 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/application/in/services/user/JwtService.java @@ -0,0 +1,30 @@ +package com.github.asavershin.api.application.in.services.user; + +import com.github.asavershin.api.domain.user.Credentials; + +public interface JwtService { + + /** + * Generates an access token for the provided credentials. + * + * @param credentials The credentials used to generate the access token. + * @return A string representing the generated access token. + */ + String generateAccessToken(Credentials credentials); + + /** + * Generates a refresh token for the provided credentials. + * + * @param credentials The credentials used to generate the refresh token. + * @return A string representing the generated refresh token. + */ + String generateRefreshToken(Credentials credentials); + + /** + * Extracts the subject from the provided JWT. + * + * @param jwt The JWT from which the subject should be extracted. + * @return A string representing the extracted subject. + */ + String extractSub(String jwt); +} diff --git a/api/src/main/java/com/github/asavershin/api/application/in/services/user/impl/GetNewCredentialsImpl.java b/api/src/main/java/com/github/asavershin/api/application/in/services/user/impl/GetNewCredentialsImpl.java new file mode 100644 index 0000000..71b306f --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/application/in/services/user/impl/GetNewCredentialsImpl.java @@ -0,0 +1,52 @@ +package com.github.asavershin.api.application.in.services.user.impl; + +import com.github.asavershin.api.application.in.services.user.ApplicationCredentials; +import com.github.asavershin.api.application.in.services.user.GetNewCredentials; +import com.github.asavershin.api.application.out.TokenRepository; +import com.github.asavershin.api.application.in.services.user.JwtService; +import com.github.asavershin.api.config.properties.JwtProperties; +import com.github.asavershin.api.domain.user.Credentials; +import com.github.asavershin.api.domain.user.TryToLogin; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class GetNewCredentialsImpl implements GetNewCredentials { + /** + * Dependency for get,save,delete tokens in redis. + */ + private final TokenRepository tokenRepository; + /** + * Domain service that allow user try to log in. + */ + private final TryToLogin tryToLogin; + /** + * Application service that allow to do some manipulations with JWT tokens. + */ + private final JwtService jwtService; + /** + * Property that contains secret and access, refresh expirations. + */ + private final JwtProperties jwtProperties; + + @Override + public final ApplicationCredentials get(final Credentials credentials) { + var authenticatedUser = tryToLogin.login(credentials); + var accessToken = jwtService + .generateAccessToken(authenticatedUser.userCredentials()); + var refreshToken = jwtService + .generateRefreshToken(authenticatedUser.userCredentials()); + var email = authenticatedUser.userCredentials().email(); + tokenRepository.deleteAllTokensByUserEmail(email); + tokenRepository.saveRefreshToken(email, + refreshToken, + jwtProperties.getRefreshExpiration()); + tokenRepository.saveAccessToken(email, + accessToken, + jwtProperties.getAccessExpiration()); + return new ApplicationCredentials(accessToken, refreshToken); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/application/in/services/user/impl/GetNewCredentialsUsingRefreshTokenImpl.java b/api/src/main/java/com/github/asavershin/api/application/in/services/user/impl/GetNewCredentialsUsingRefreshTokenImpl.java new file mode 100644 index 0000000..7a057b0 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/application/in/services/user/impl/GetNewCredentialsUsingRefreshTokenImpl.java @@ -0,0 +1,50 @@ +package com.github.asavershin.api.application.in.services.user.impl; + +import com.github.asavershin.api.application.in.services.user.ApplicationCredentials; +import com.github.asavershin.api.application.in.services.user.GetNewCredentialsUsingRefreshToken; +import com.github.asavershin.api.application.in.services.user.JwtService; +import com.github.asavershin.api.application.out.TokenRepository; +import com.github.asavershin.api.config.properties.JwtProperties; +import com.github.asavershin.api.domain.user.Credentials; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class GetNewCredentialsUsingRefreshTokenImpl + implements GetNewCredentialsUsingRefreshToken { + /** + * The repository for storing and retrieving tokens. + */ + private final TokenRepository tokenRepository; + + /** + * The service for generating and validating JWT tokens. + */ + private final JwtService jwtService; + + /** + * The properties containing the configuration for JWT tokens. + */ + private final JwtProperties jwtProperties; + /** + * Not final to allow spring use proxy. + */ + @Override + public ApplicationCredentials get(final Credentials credentials) { + tokenRepository.deleteAllTokensByUserEmail(credentials.email()); + var at = jwtService.generateAccessToken(credentials); + var rt = jwtService.generateRefreshToken(credentials); + tokenRepository.saveAccessToken( + credentials.email(), + at, + jwtProperties.getAccessExpiration() + ); + tokenRepository.saveRefreshToken( + credentials.email(), + rt, + jwtProperties.getRefreshExpiration() + ); + return new ApplicationCredentials(at, rt); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/application/in/services/user/impl/JwtServiceIml.java b/api/src/main/java/com/github/asavershin/api/application/in/services/user/impl/JwtServiceIml.java new file mode 100644 index 0000000..675b938 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/application/in/services/user/impl/JwtServiceIml.java @@ -0,0 +1,69 @@ +package com.github.asavershin.api.application.in.services.user.impl; + +import com.github.asavershin.api.application.in.services.user.JwtService; +import com.github.asavershin.api.config.properties.JwtProperties; +import com.github.asavershin.api.domain.user.Credentials; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.security.Key; +import java.util.Date; + +@Service +@RequiredArgsConstructor +public class JwtServiceIml implements JwtService { + + /** + * The JwtProperties object contains the properties + * for generating and validating JWT tokens. + */ + private final JwtProperties jwtProperties; + /** + * Not final to allow spring use proxy. + */ + @Override + public String generateAccessToken(final Credentials credentials) { + return buildToken(credentials, jwtProperties.getAccessExpiration()); + } + /** + * Not final to allow spring use proxy. + */ + @Override + public String generateRefreshToken(final Credentials credentials) { + return buildToken(credentials, jwtProperties.getRefreshExpiration()); + } + /** + * Not final to allow spring use proxy. + */ + @Override + public String extractSub(final String jwt) { + return Jwts.parserBuilder() + .setSigningKey(getSignInKey()) + .build() + .parseClaimsJws(jwt) + .getBody() + .getSubject(); + } + + private String buildToken(final Credentials credentials, + final long expiration) { + return Jwts + .builder() + .setSubject(credentials.email()) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration( + new Date(System.currentTimeMillis() + expiration) + ) + .signWith(getSignInKey(), SignatureAlgorithm.HS256) + .compact(); + } + + private Key getSignInKey() { + byte[] keyBytes = Decoders.BASE64.decode(jwtProperties.getSecret()); + return Keys.hmacShaKeyFor(keyBytes); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/application/in/services/user/impl/package-info.java b/api/src/main/java/com/github/asavershin/api/application/in/services/user/impl/package-info.java new file mode 100644 index 0000000..b0db315 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/application/in/services/user/impl/package-info.java @@ -0,0 +1,5 @@ +/** + * This package contains implementation application services for users. + * @author asavershin + */ +package com.github.asavershin.api.application.in.services.user.impl; diff --git a/api/src/main/java/com/github/asavershin/api/application/in/services/user/package-info.java b/api/src/main/java/com/github/asavershin/api/application/in/services/user/package-info.java new file mode 100644 index 0000000..6bdc8a8 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/application/in/services/user/package-info.java @@ -0,0 +1,5 @@ +/** + * This package contains application services for users. + * @author asavershin + */ +package com.github.asavershin.api.application.in.services.user; diff --git a/api/src/main/java/com/github/asavershin/api/application/out/CacheRepository.java b/api/src/main/java/com/github/asavershin/api/application/out/CacheRepository.java new file mode 100644 index 0000000..8c22291 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/application/out/CacheRepository.java @@ -0,0 +1,28 @@ +package com.github.asavershin.api.application.out; + +public interface CacheRepository { + /** + * Adds a cache entry with the specified key, + * token, and expiration time. + * + * @param key the unique identifier for the cache entry + * @param token the token associated with the cache entry + * @param expiration the time in milliseconds after + * which the cache entry will expire + */ + void addCache(String key, String token, long expiration); + /** + * Retrieves the cache entry associated with the specified key. + * + * @param key the unique identifier for the cache entry + * @return the token associated with the cache entry, + * or null if the entry does not exist or has expired + */ + String getCache(String key); + /** + * Deletes the cache entry associated with the specified key. + * + * @param key the unique identifier for the cache entry + */ + void deleteCache(String key); +} diff --git a/api/src/main/java/com/github/asavershin/api/application/out/FileService.java b/api/src/main/java/com/github/asavershin/api/application/out/FileService.java new file mode 100644 index 0000000..2691519 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/application/out/FileService.java @@ -0,0 +1,27 @@ +package com.github.asavershin.api.application.out; + +import java.util.List; + +public interface FileService { + /** + * Saves a file to the storage. + * + * @param file The file object to be saved. + * @param filename The name of the file to be saved. + * Must be unique. + */ + void saveFile(K file, String filename); + /** + * Retrieves the content of a file from the storage. + * + * @param link The unique identifier or link of the file. + * @return The content of the file as a byte array. + */ + byte[] getFile(String link); + /** + * Deletes multiple files from the storage. + * + * @param files A list of file links to be deleted. + */ + void deleteFiles(List files); +} diff --git a/api/src/main/java/com/github/asavershin/api/application/out/MinioService.java b/api/src/main/java/com/github/asavershin/api/application/out/MinioService.java new file mode 100644 index 0000000..93835cb --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/application/out/MinioService.java @@ -0,0 +1,6 @@ +package com.github.asavershin.api.application.out; + +import org.springframework.web.multipart.MultipartFile; + +public interface MinioService extends FileService { +} diff --git a/api/src/main/java/com/github/asavershin/api/application/out/TokenRepository.java b/api/src/main/java/com/github/asavershin/api/application/out/TokenRepository.java new file mode 100644 index 0000000..2f03cb0 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/application/out/TokenRepository.java @@ -0,0 +1,46 @@ +package com.github.asavershin.api.application.out; + +public interface TokenRepository { + /** + * Retrieves the access token for the given email. + * + * @param email the email of the user + * @return the access token for the given email + */ + String getAccessToken(String email); + + /** + * Retrieves the refresh token for the given email. + * + * @param email the email of the user + * @return the refresh token for the given email + */ + String getRefreshToken(String email); + + /** + * Saves the refresh token for the given user with + * the specified JWT token and expiration time. + * + * @param username the username of the user + * @param jwtToken the JWT token to be saved + * @param expiration the expiration time of the token + */ + void saveRefreshToken(String username, String jwtToken, Long expiration); + + /** + * Saves the access token for the given user with + * the specified JWT token and expiration time. + * + * @param username the username of the user + * @param jwtToken the JWT token to be saved + * @param expiration the expiration time of the token + */ + void saveAccessToken(String username, String jwtToken, Long expiration); + + /** + * Deletes all tokens associated with the given user email. + * + * @param username the username of the user + */ + void deleteAllTokensByUserEmail(String username); +} diff --git a/api/src/main/java/com/github/asavershin/api/application/out/package-info.java b/api/src/main/java/com/github/asavershin/api/application/out/package-info.java new file mode 100644 index 0000000..c70aea2 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/application/out/package-info.java @@ -0,0 +1,5 @@ +/** + * This package contains storage interfaces that are not domain specific. + * @author asavershin + */ +package com.github.asavershin.api.application.out; diff --git a/api/src/main/java/com/github/asavershin/api/common/NotFoundException.java b/api/src/main/java/com/github/asavershin/api/common/NotFoundException.java new file mode 100644 index 0000000..a2a8110 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/common/NotFoundException.java @@ -0,0 +1,10 @@ +package com.github.asavershin.api.common; + +public class NotFoundException extends RuntimeException { + /** + * @param message specifies information about the object not found + */ + public NotFoundException(final String message) { + super(message); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/common/Validator.java b/api/src/main/java/com/github/asavershin/api/common/Validator.java new file mode 100644 index 0000000..f0f70dc --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/common/Validator.java @@ -0,0 +1,141 @@ +package com.github.asavershin.api.common; + +import java.util.Objects; + +/** + * A utility class for performing basic validation checks. + * + * @author asavershin + */ +public abstract class Validator { + + /** + * Asserts that the given string has a length within + * the specified range. + * + * @param aString the string to validate + * @param aMinimum the minimum length + * @param aMaximum the maximum length + * @param aMessage the message to throw if the length is invalid + * @throws IllegalArgumentException if the length of the string + * is less than {@code aMinimum} or greater than {@code aMaximum} + */ + public static void assertArgumentLength(final String aString, + final int aMinimum, + final int aMaximum, + final String aMessage) { + int length = aString.length(); + if (length < aMinimum || length > aMaximum) { + throw new IllegalArgumentException(aMessage); + } + } + /** + * Asserts that the given string has a length greater than or equal + * to the specified minimum. + * + * @param aString the string to validate + * @param aMinimum the minimum length + * @param aMessage the message to throw if the length is invalid + * @throws IllegalArgumentException if the length of the string is + * less than {@code aMinimum} + */ + public static void assertArgumentLength(final String aString, + final int aMinimum, + final String aMessage) { + int length = aString.length(); + if (length < aMinimum) { + throw new IllegalArgumentException(aMessage); + } + } + + /** + * Asserts that the given string matches the specified regular + * expression. + * + * @param email the string to validate + * @param regex the regular expression to match against + * @param aMessage the message to throw if the string does not + * match the regular expression + * @throws IllegalArgumentException if the string does not + * match the regular expression + */ + public static void assertStringFormat(final String email, + final String regex, + final String aMessage) { + if (!email.matches(regex)) { + throw new IllegalArgumentException(aMessage); + } + } + + /** + * Asserts that the given array has a length greater than or + * equal to the specified minimum. + * + * @param array the array to validate + * @param minLength the minimum length + * @param aMessage the message to throw if the length of the + * array is less than {@code minLength} + * @throws IllegalArgumentException if the length of the array + * is less than {@code minLength} + */ + public static void assertArrayLength(final Object[] array, + final Integer minLength, + final String aMessage) { + if (array.length < minLength) { + throw new IllegalArgumentException(aMessage); + } + } + + /** + * Asserts that the given value is within the specified range. + * + * @param value the value to validate + * @param minLength the minimum length + * @param maxLength the maximum length + * @param aMessage the message to throw if the value is outside + * the specified range + * @throws IllegalArgumentException if the value is less than + * {@code minLength} or greater than {@code maxLength} + */ + public static void assertLongSize(final Long value, + final Long minLength, + final Long maxLength, + final String aMessage) { + if (value < minLength || value > maxLength) { + throw new IllegalArgumentException(aMessage); + } + } + + /** + * Asserts that the given value is greater than or equal to the + * specified minimum. + * + * @param value the value to validate + * @param minLength the minimum length + * @param aMessage the message to throw if the value is less + * than {@code minLength} + * @throws IllegalArgumentException if the value is less + * than {@code minLength} + */ + public static void assertLongSize(final Long value, + final Long minLength, + final String aMessage) { + if (value < minLength) { + throw new IllegalArgumentException(aMessage); + } + } + + /** + * Asserts that the given object is not null. + * + * @param object the object to validate + * @param aMessage the message to throw if the object is null + * @throws IllegalArgumentException if the object is null + */ + public static void assertNotFound(final Object object, + final String aMessage) { + if (Objects.isNull(object)) { + throw new NotFoundException(aMessage); + } + } +} diff --git a/api/src/main/java/com/github/asavershin/api/common/annotations/Command.java b/api/src/main/java/com/github/asavershin/api/common/annotations/Command.java new file mode 100644 index 0000000..51b88d9 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/common/annotations/Command.java @@ -0,0 +1,13 @@ +package com.github.asavershin.api.common.annotations; + +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Inherited; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Inherited +public @interface Command { +} diff --git a/api/src/main/java/com/github/asavershin/api/common/annotations/DomainService.java b/api/src/main/java/com/github/asavershin/api/common/annotations/DomainService.java new file mode 100644 index 0000000..30f2f55 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/common/annotations/DomainService.java @@ -0,0 +1,13 @@ +package com.github.asavershin.api.common.annotations; + +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Inherited; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Inherited +public @interface DomainService { +} diff --git a/api/src/main/java/com/github/asavershin/api/common/annotations/Query.java b/api/src/main/java/com/github/asavershin/api/common/annotations/Query.java new file mode 100644 index 0000000..1b83bf0 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/common/annotations/Query.java @@ -0,0 +1,13 @@ +package com.github.asavershin.api.common.annotations; + +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Inherited; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Inherited +public @interface Query { +} diff --git a/api/src/main/java/com/github/asavershin/api/common/annotations/package-info.java b/api/src/main/java/com/github/asavershin/api/common/annotations/package-info.java new file mode 100644 index 0000000..15967d2 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/common/annotations/package-info.java @@ -0,0 +1,5 @@ +/** + * This package annotations for classes that characterize their activities. + * @author asavershin + */ +package com.github.asavershin.api.common.annotations; diff --git a/api/src/main/java/com/github/asavershin/api/common/package-info.java b/api/src/main/java/com/github/asavershin/api/common/package-info.java new file mode 100644 index 0000000..74606bc --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/common/package-info.java @@ -0,0 +1,7 @@ +/** + * This package contains code that could be useful for coding. + * It contains validators and exceptions that often found. + * It annotations for classes that characterize their activities. + * @author asavershin + */ +package com.github.asavershin.api.common; diff --git a/api/src/main/java/com/github/asavershin/api/config/AnnotationsConfig.java b/api/src/main/java/com/github/asavershin/api/config/AnnotationsConfig.java new file mode 100644 index 0000000..6a084c2 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/config/AnnotationsConfig.java @@ -0,0 +1,23 @@ +package com.github.asavershin.api.config; + +import com.github.asavershin.api.common.annotations.Command; +import com.github.asavershin.api.common.annotations.DomainService; +import com.github.asavershin.api.common.annotations.Query; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; + +@Configuration +@ComponentScan( + basePackages = "com.github.asavershin.api", + includeFilters = { + @ComponentScan.Filter(type = FilterType.ANNOTATION, + value = DomainService.class), + @ComponentScan.Filter(type = FilterType.ANNOTATION, + value = Query.class), + @ComponentScan.Filter(type = FilterType.ANNOTATION, + value = Command.class) + } +) +public class AnnotationsConfig { +} diff --git a/api/src/main/java/com/github/asavershin/api/config/AuthConfig.java b/api/src/main/java/com/github/asavershin/api/config/AuthConfig.java new file mode 100644 index 0000000..69da912 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/config/AuthConfig.java @@ -0,0 +1,21 @@ +package com.github.asavershin.api.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +@RequiredArgsConstructor +public class AuthConfig { + /** + * Configures a BCryptPasswordEncoder bean for password encoding. + * + * @return a new instance of BCryptPasswordEncoder + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/config/MinIOConfig.java b/api/src/main/java/com/github/asavershin/api/config/MinIOConfig.java new file mode 100644 index 0000000..3249974 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/config/MinIOConfig.java @@ -0,0 +1,40 @@ +package com.github.asavershin.api.config; + +import com.github.asavershin.api.config.properties.MinIOProperties; +import io.minio.MinioClient; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration class for MinIO client. + * + * @author Asavershin + */ +@Configuration +@RequiredArgsConstructor +@Slf4j +public class MinIOConfig { + + /** + * The MinIO properties. + */ + private final MinIOProperties minioProperties; + + /** + * Creates a MinioClient instance. + * + * @return A MinioClient instance configured with the provided + * MinIO properties. + */ + @Bean + public MinioClient minioClient() { + log.info(minioProperties.toString()); + return MinioClient.builder() + .endpoint(minioProperties.getUrl()) + .credentials(minioProperties.getUser(), + minioProperties.getPassword()) + .build(); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/config/RedisConfig.java b/api/src/main/java/com/github/asavershin/api/config/RedisConfig.java new file mode 100644 index 0000000..5b920ef --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/config/RedisConfig.java @@ -0,0 +1,36 @@ +package com.github.asavershin.api.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericToStringSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +/** + * Configuration class for setting up the RedisTemplate. + * + * @author Asavershin + */ +@Configuration +public class RedisConfig { + + /** + * Creates a new instance of {@link RedisTemplate} with the provided + * {@link RedisConnectionFactory}. + * + * @param connectionFactory the RedisConnectionFactory to use + * @return a new instance of {@link RedisTemplate} + */ + @Bean + public RedisTemplate redisTemplate( + final RedisConnectionFactory connectionFactory) { + final RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer( + new GenericToStringSerializer<>(Object.class) + ); + return template; + } +} diff --git a/api/src/main/java/com/github/asavershin/api/config/package-info.java b/api/src/main/java/com/github/asavershin/api/config/package-info.java new file mode 100644 index 0000000..3d5fb9e --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/config/package-info.java @@ -0,0 +1,5 @@ +/** + * This package contains configs for infrastructure of service. + * @author asavershin + */ +package com.github.asavershin.api.config; diff --git a/api/src/main/java/com/github/asavershin/api/config/properties/JwtProperties.java b/api/src/main/java/com/github/asavershin/api/config/properties/JwtProperties.java new file mode 100644 index 0000000..5cb2e2b --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/config/properties/JwtProperties.java @@ -0,0 +1,30 @@ +package com.github.asavershin.api.config.properties; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@Data +@ConfigurationProperties(prefix = "jwt") +public class JwtProperties { + /** + * Secret key used for signing JWT tokens. + */ + private String secret; + + /** + * Expiration time for access tokens in milliseconds. + */ + private Long accessExpiration; + + /** + * Expiration time for refresh tokens in milliseconds. + */ + private Long refreshExpiration; + + /** + * Constants for the start of the token in the Authorization header. + */ + public static final int START_OF_TOKEN = 7; +} diff --git a/api/src/main/java/com/github/asavershin/api/config/properties/MinIOProperties.java b/api/src/main/java/com/github/asavershin/api/config/properties/MinIOProperties.java new file mode 100644 index 0000000..805e730 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/config/properties/MinIOProperties.java @@ -0,0 +1,32 @@ +package com.github.asavershin.api.config.properties; + +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@Data +@ConfigurationProperties(prefix = "minio") +@NoArgsConstructor +public class MinIOProperties { + /** + * The name of the bucket in the MinIO service. + */ + private String bucket; + + /** + * The URL of the MinIO service. + */ + private String url; + + /** + * The username for the MinIO service. + */ + private String user; + + /** + * The password for the MinIO service. + */ + private String password; +} diff --git a/api/src/main/java/com/github/asavershin/api/config/properties/UserProperties.java b/api/src/main/java/com/github/asavershin/api/config/properties/UserProperties.java new file mode 100644 index 0000000..a4bbd51 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/config/properties/UserProperties.java @@ -0,0 +1,18 @@ +package com.github.asavershin.api.config.properties; + +public final class UserProperties { + /** + * Minimum password length. + */ + public static final int MIN_PASSWORD_LENGTH = 8; + /** + * Maximum firstname length. + */ + public static final int MAX_FIRSTNAME_LENGTH = 20; + /** + * Maximum lastname length. + */ + public static final int MAX_LASTNAME_LENGTH = 20; + + private UserProperties() { } +} diff --git a/api/src/main/java/com/github/asavershin/api/config/properties/package-info.java b/api/src/main/java/com/github/asavershin/api/config/properties/package-info.java new file mode 100644 index 0000000..1109013 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/config/properties/package-info.java @@ -0,0 +1,6 @@ +/** + * This package contains parameters that can be used in different + * parts of the application. The values are taken from .yml/.properties file + * @author asavershin + */ +package com.github.asavershin.api.config.properties; diff --git a/api/src/main/java/com/github/asavershin/api/domain/IsEntityFound.java b/api/src/main/java/com/github/asavershin/api/domain/IsEntityFound.java new file mode 100644 index 0000000..837edcf --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/IsEntityFound.java @@ -0,0 +1,13 @@ +package com.github.asavershin.api.domain; + +import com.github.asavershin.api.common.Validator; + +public abstract class IsEntityFound { + protected final void isEntityFound(final Object entity, + final String entityName, + final String idName, + final String entityId) { + Validator.assertNotFound(entity, + entityName + " with " + idName + entityId + " not found"); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/PartOfResources.java b/api/src/main/java/com/github/asavershin/api/domain/PartOfResources.java new file mode 100644 index 0000000..1c0f0ba --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/PartOfResources.java @@ -0,0 +1,40 @@ +package com.github.asavershin.api.domain; + +import com.github.asavershin.api.common.Validator; + +import java.util.Objects; + +/** + * A record representing a part of resources to be fetched from a + * data source. + * It contains the page number and the page size for pagination purposes. + * + * @param pageNumber The zero-based index of the first record + * to retrieve. + * @param pageSize The maximum number of records to retrieve. + */ +public record PartOfResources(Long pageNumber, Long pageSize) { + + /** + * Constructor for the PartOfResources record. + * + * @param pageNumber The zero-based index of the first record + * to retrieve. + * @param pageSize The maximum number of records to retrieve. + * @throws IllegalArgumentException if the pageSize is less than + * or equal to zero. + */ + public PartOfResources { + Objects.requireNonNull(pageNumber, + "PageNumber must not be empty"); + Objects.requireNonNull(pageSize, + "PageSize must not be empty"); + Validator.assertLongSize(pageNumber, + 0L, "PageNumber must not be negative"); + Validator.assertLongSize( + pageSize, + 1L, + "PageSize must be positive" + ); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/ResourceOwnershipException.java b/api/src/main/java/com/github/asavershin/api/domain/ResourceOwnershipException.java new file mode 100644 index 0000000..3db43d2 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/ResourceOwnershipException.java @@ -0,0 +1,20 @@ +package com.github.asavershin.api.domain; + +/** + * Represents an exception that is thrown + * when there is an issue with resource ownership. + * For example, a user requests someone else's picture + * @author Asavershin + */ +public class ResourceOwnershipException extends RuntimeException { + + /** + * Constructs a new instance of the ResourceOwnershipException + * with the specified error message. + * + * @param message the error message + */ + public ResourceOwnershipException(final String message) { + super(message); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/image/DeleteImageOfUser.java b/api/src/main/java/com/github/asavershin/api/domain/image/DeleteImageOfUser.java new file mode 100644 index 0000000..404c0ab --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/image/DeleteImageOfUser.java @@ -0,0 +1,14 @@ +package com.github.asavershin.api.domain.image; + +import com.github.asavershin.api.domain.user.UserId; + +@FunctionalInterface +public interface DeleteImageOfUser { + /** + * Removes the specified image of the specified user. + * + * @param imageId the unique identifier of the image to be removed + * @param userId the unique identifier of the user who owns the image + */ + void removeImageOfUser(ImageId imageId, UserId userId); +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/image/GetImageOfUser.java b/api/src/main/java/com/github/asavershin/api/domain/image/GetImageOfUser.java new file mode 100644 index 0000000..a116ae6 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/image/GetImageOfUser.java @@ -0,0 +1,15 @@ +package com.github.asavershin.api.domain.image; + +import com.github.asavershin.api.domain.user.UserId; + +@FunctionalInterface +public interface GetImageOfUser { + /** + * Method that retrieves an image of a user. + * + * @param userId the unique identifier of the user + * @param imageId the unique identifier of the image + * @return the image of the specified user + */ + Image getImageOfUser(UserId userId, ImageId imageId); +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/image/GetPartImagesOfUser.java b/api/src/main/java/com/github/asavershin/api/domain/image/GetPartImagesOfUser.java new file mode 100644 index 0000000..db0cb2b --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/image/GetPartImagesOfUser.java @@ -0,0 +1,20 @@ +package com.github.asavershin.api.domain.image; + +import com.github.asavershin.api.domain.PartOfResources; +import com.github.asavershin.api.domain.user.UserId; + +import java.util.List; + +@FunctionalInterface +public interface GetPartImagesOfUser { + /** + * This interface represents a function that retrieves a list of + * images for a specific user and part of resources. + * + * @param userId the unique identifier of the user + * @param partOfResources pagination + * @return a list of images belonging to the specified + * user and part of resources as pagination + */ + List get(UserId userId, PartOfResources partOfResources); +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/image/Image.java b/api/src/main/java/com/github/asavershin/api/domain/image/Image.java new file mode 100644 index 0000000..f8c7063 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/image/Image.java @@ -0,0 +1,79 @@ +package com.github.asavershin.api.domain.image; + +import com.github.asavershin.api.domain.ResourceOwnershipException; +import com.github.asavershin.api.domain.user.UserId; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +import java.util.Objects; + +@ToString +@Getter +@EqualsAndHashCode +public class Image { + /** + * The unique identifier for the image. + */ + private ImageId imageId; + + /** + * The meta information associated with the image. + */ + private MetaData metaInfo; + + /** + * The user who owns the image. + */ + private UserId userId; + + /** + * Constructor for creating a new Image object. + * + * @param aImageId the unique identifier for the image + * @param aMetaInfo the meta information associated with the image + * @param aUserId the user who owns the image + */ + public Image(final ImageId aImageId, + final MetaData aMetaInfo, + final UserId aUserId) { + setImageId(aImageId); + setMetaInfo(aMetaInfo); + setUserId(aUserId); + } + + /** + * Checks if the given user owns the image. + * + * @param aUserId the user to check ownership for + * @return the same image instance if the user owns the image, + * otherwise throws a {@link ResourceOwnershipException} + * @throws ResourceOwnershipException if the image does not belong + * to the given user + */ + public Image belongsToUser(final UserId aUserId) { + if (!this.userId.equals(aUserId)) { + throw new ResourceOwnershipException( + "Image with id " + imageId.value().toString() + + " does not belong to user with id " + + aUserId.value().toString() + ); + } + return this; + } + + private void setImageId(final ImageId aImageId) { + Objects.requireNonNull(aImageId, "ImageId must not be null"); + this.imageId = aImageId; + } + + private void setMetaInfo(final MetaData aMetaInfo) { + Objects.requireNonNull(aMetaInfo, "MetaInfo must not be null"); + this.metaInfo = aMetaInfo; + } + + private void setUserId(final UserId aUserId) { + Objects.requireNonNull(aUserId, "UserId must not be null"); + this.userId = aUserId; + } +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/image/ImageExtension.java b/api/src/main/java/com/github/asavershin/api/domain/image/ImageExtension.java new file mode 100644 index 0000000..2b367f1 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/image/ImageExtension.java @@ -0,0 +1,70 @@ +package com.github.asavershin.api.domain.image; + +import java.util.Map; +import java.util.stream.Stream; + +import static java.util.stream.Collectors.toMap; + +/** + * Enum representing different image file extensions. + * + * @author asavershin + */ +public enum ImageExtension { + /** + * Represents the JPG image file extension. + */ + JPG(".jpg"), + + /** + * Represents the PNG image file extension. + */ + PNG(".png"), + + /** + * Represents the JPEG image file extension. + */ + JPEG(".jpeg"); + + /** + * Represents the string image file extension + * in format .extension. + */ + private final String extension; + + ImageExtension(final String aExtension) { + this.extension = aExtension; + } + + @Override + public String toString() { + return extension; + } + + /** + * Map that stores a string representation of an extension in keys, + * and their corresponding ENUM in values. + */ + private static final Map STRING_TO_ENUM + = Stream.of(values()).collect(toMap(Object::toString, e -> e)); + + /** + * Converts a string representation of an image extension + * to the corresponding enum instance. + * + * @param extension the string representation of the image extension + * @return the enum instance corresponding to the given + * string representation + * @throws IllegalArgumentException if the given string representation + * does not match any known image extension + */ + public static ImageExtension fromString(final String extension) { + var imageExtension = STRING_TO_ENUM.get(extension); + if (imageExtension == null) { + throw new IllegalArgumentException( + "Invalid extension: " + extension + ); + } + return imageExtension; + } +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/image/ImageId.java b/api/src/main/java/com/github/asavershin/api/domain/image/ImageId.java new file mode 100644 index 0000000..f6ef6c7 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/image/ImageId.java @@ -0,0 +1,29 @@ +package com.github.asavershin.api.domain.image; + +import java.util.Objects; +import java.util.UUID; + +/** + * A value object representing an image ID. + * + * @param value The unique identifier for the image. + */ +public record ImageId(UUID value) { + /** + * Constructs an ImageId instance with the provided UUID. + * + * @param value The unique identifier for the image. + * @throws NullPointerException if the provided value is null. + */ + public ImageId { + Objects.requireNonNull(value, "Image ID must not be null"); + } + /** + * Generates a new, unique ImageId. + * + * @return A new ImageId instance with a randomly generated UUID. + */ + public static ImageId nextIdentity() { + return new ImageId(UUID.randomUUID()); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/image/ImageNameWithExtension.java b/api/src/main/java/com/github/asavershin/api/domain/image/ImageNameWithExtension.java new file mode 100644 index 0000000..5b0f88f --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/image/ImageNameWithExtension.java @@ -0,0 +1,92 @@ +package com.github.asavershin.api.domain.image; + +import com.github.asavershin.api.common.Validator; +import lombok.EqualsAndHashCode; +import lombok.Getter; + +import java.util.Arrays; +import java.util.Objects; + +@Getter +@EqualsAndHashCode +public final class ImageNameWithExtension { + + /** + * The maximum length of an image name. + */ + private static final int MAX_IMAGE_NAME = 50; + + /** + * The image name. + */ + private final String imageName; + + /** + * The image extension. + */ + private final ImageExtension imageExtension; + + /** + * Constructs an {@code ImageNameWithExtension} object. + * + * @param aImageName the image name without last extension + * @param aImageExt the image extension + */ + private ImageNameWithExtension(final String aImageName, + final ImageExtension aImageExt) { + this.imageName = aImageName; + this.imageExtension = aImageExt; + } + /** + * Creates an {@code ImageNameWithExtension} + * object from an original file name. + * + * @param originalFileName the original file name + * @return the created {@code ImageNameWithExtension} object + */ + public static ImageNameWithExtension fromOriginalFileName( + final String originalFileName + ) { + notNullValidate(originalFileName); + String[] parts = originalFileName.split("\\."); + Validator.assertArrayLength(parts, 2, "Incorrect image format"); + var extension = ImageExtension + .fromString("." + parts[parts.length - 1]); + var imageName = String + .join(".", Arrays.copyOfRange(parts, 0, parts.length - 1)); + lengthNameValidate(imageName); + + return new ImageNameWithExtension(imageName, extension); + } + + /** + * Creates an {@code ImageNameWithExtension} + * object that founded in repository from an image name and an extension. + * + * @param imageName the image name + * @param extension the image extension + * @return the created {@code ImageNameWithExtension} object + */ + public static ImageNameWithExtension founded( + final String imageName, + final String extension) { + notNullValidate(imageName); + lengthNameValidate(imageName); + return new ImageNameWithExtension(imageName, + ImageExtension.fromString(extension)); + } + + private static void notNullValidate(final String name) { + Objects.requireNonNull(name, "ImageName must not be null"); + } + + private static void lengthNameValidate(final String name) { + Validator.assertArgumentLength(name, 0, MAX_IMAGE_NAME, + "ImageName must be " + 0 + "-" + MAX_IMAGE_NAME + " in length"); + } + + @Override + public String toString() { + return imageName + imageExtension; + } +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/image/ImageRepository.java b/api/src/main/java/com/github/asavershin/api/domain/image/ImageRepository.java new file mode 100644 index 0000000..5998b6b --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/image/ImageRepository.java @@ -0,0 +1,40 @@ +package com.github.asavershin.api.domain.image; + +import com.github.asavershin.api.domain.PartOfResources; +import com.github.asavershin.api.domain.user.UserId; + +import java.util.List; + +public interface ImageRepository { + /** + * Saves an Image object to the database. + * + * @param image the Image object to be saved + */ + void save(Image image); + /** + * Finds all Images associated with the given UserId and PartOfResources. + * + * @param userId the UserId of the user whose images are to be retrieved + * @param partOfResources the PartOfResources to filter the images by + * @return a list of Images associated with the given UserId + * and PartOfResources + */ + List findImagesByUserId(UserId userId, + PartOfResources partOfResources); + + /** + * Finds an Image by its ImageId. + * + * @param imageId the ImageId of the Image to be retrieved + * @return the Image object with the given ImageId, or null if not found + */ + Image findImageByImageId(ImageId imageId); + + /** + * Deletes an Image by its ImageId. + * + * @param imageId the ImageId of the Image to be deleted + */ + void deleteImageByImageId(Image imageId); +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/image/MetaData.java b/api/src/main/java/com/github/asavershin/api/domain/image/MetaData.java new file mode 100644 index 0000000..1a34051 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/image/MetaData.java @@ -0,0 +1,43 @@ +package com.github.asavershin.api.domain.image; + +import com.github.asavershin.api.common.Validator; + +import java.util.Objects; + +public record MetaData(ImageNameWithExtension imageNameWithExtension, + Long imageSize) { + /** + * The maximum length of an image name. + */ + private static final int MAX_IMAGE_NAME = 50; + /** + * Size is in bytes. 10MB + */ + private static final long MAX_IMAGE_SIZE = 10485760; + + /** + * Constructs a new instance of MetaInfo + * and validates the provided image name and size. + */ + public MetaData { + validateImageName(imageNameWithExtension); + validateImageSize(imageSize); + } + + private void validateImageName( + final ImageNameWithExtension aImageNameWithExtension + ) { + Objects.requireNonNull(aImageNameWithExtension, + "Name and extension must not be null"); + Validator.assertArgumentLength(aImageNameWithExtension.toString(), + 0, MAX_IMAGE_NAME, + "Image name must be " + + "0-" + MAX_IMAGE_NAME + " in length"); + } + + private void validateImageSize(final Long aImageSize) { + Objects.requireNonNull(aImageSize, "Image size must not be null"); + Validator.assertLongSize(aImageSize, 0L, MAX_IMAGE_SIZE, + "Image size must be " + "0-" + MAX_IMAGE_SIZE); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/image/StoreImageOfUser.java b/api/src/main/java/com/github/asavershin/api/domain/image/StoreImageOfUser.java new file mode 100644 index 0000000..ab0d3d4 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/image/StoreImageOfUser.java @@ -0,0 +1,11 @@ +package com.github.asavershin.api.domain.image; + +@FunctionalInterface +public interface StoreImageOfUser { + /** + * Stores the provided image of a user. + * + * @param image The image to be stored. + */ + void storeImageOfUser(Image image); +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/image/impl/DeleteImageOfUserImpl.java b/api/src/main/java/com/github/asavershin/api/domain/image/impl/DeleteImageOfUserImpl.java new file mode 100644 index 0000000..569b793 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/image/impl/DeleteImageOfUserImpl.java @@ -0,0 +1,35 @@ +package com.github.asavershin.api.domain.image.impl; + +import com.github.asavershin.api.common.annotations.Command; +import com.github.asavershin.api.domain.IsEntityFound; +import com.github.asavershin.api.domain.image.ImageId; +import com.github.asavershin.api.domain.image.ImageRepository; +import com.github.asavershin.api.domain.image.DeleteImageOfUser; +import com.github.asavershin.api.domain.user.UserId; +import lombok.RequiredArgsConstructor; + +@Command +@RequiredArgsConstructor +public class DeleteImageOfUserImpl extends IsEntityFound + implements DeleteImageOfUser { + /** + * The ImageRepository dependency for accessing image data. + */ + private final ImageRepository imageRepository; + + /** + * Not final to allow spring use proxy. + */ + @Override + public void removeImageOfUser( + final ImageId imageId, + final UserId userId + ) { + var image = imageRepository.findImageByImageId(imageId); + isEntityFound(image, "Image", "Id", imageId.value().toString()); + image.belongsToUser(userId); + imageRepository.deleteImageByImageId( + imageRepository.findImageByImageId(imageId) + ); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/image/impl/GetImageOfUserImpl.java b/api/src/main/java/com/github/asavershin/api/domain/image/impl/GetImageOfUserImpl.java new file mode 100644 index 0000000..674e5ac --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/image/impl/GetImageOfUserImpl.java @@ -0,0 +1,37 @@ +package com.github.asavershin.api.domain.image.impl; + +import com.github.asavershin.api.common.annotations.DomainService; +import com.github.asavershin.api.domain.IsEntityFound; +import com.github.asavershin.api.domain.image.GetImageOfUser; +import com.github.asavershin.api.domain.image.Image; +import com.github.asavershin.api.domain.image.ImageId; +import com.github.asavershin.api.domain.image.ImageRepository; +import com.github.asavershin.api.domain.user.UserId; +import lombok.RequiredArgsConstructor; + +@DomainService +@RequiredArgsConstructor +public class GetImageOfUserImpl extends IsEntityFound + implements GetImageOfUser { + /** + * The ImageRepository dependency for accessing image data. + */ + private final ImageRepository imageRepository; + /** + * Not final to allow spring use proxy. + */ + @Override + public Image getImageOfUser(final UserId userId, + final ImageId imageId) { + var image = imageRepository.findImageByImageId(imageId); + isEntityFound( + image, + "Image", + "Id", + imageId.value().toString() + ); + image.belongsToUser(userId); + return image; + } + +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/image/impl/GetPartImagesOfUserImpl.java b/api/src/main/java/com/github/asavershin/api/domain/image/impl/GetPartImagesOfUserImpl.java new file mode 100644 index 0000000..9a85c58 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/image/impl/GetPartImagesOfUserImpl.java @@ -0,0 +1,28 @@ +package com.github.asavershin.api.domain.image.impl; + +import com.github.asavershin.api.common.annotations.Query; +import com.github.asavershin.api.domain.PartOfResources; +import com.github.asavershin.api.domain.image.GetPartImagesOfUser; +import com.github.asavershin.api.domain.image.Image; +import com.github.asavershin.api.domain.image.ImageRepository; +import com.github.asavershin.api.domain.user.UserId; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +@Query +@RequiredArgsConstructor +public class GetPartImagesOfUserImpl implements GetPartImagesOfUser { + /** + * The ImageRepository dependency for accessing image data. + */ + private final ImageRepository imageRepository; + /** + * Not final to allow spring use proxy. + */ + @Override + public List get(final UserId userId, + final PartOfResources partOfResources) { + return imageRepository.findImagesByUserId(userId, partOfResources); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/image/impl/StoreImageOfUserImpl.java b/api/src/main/java/com/github/asavershin/api/domain/image/impl/StoreImageOfUserImpl.java new file mode 100644 index 0000000..d48c082 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/image/impl/StoreImageOfUserImpl.java @@ -0,0 +1,23 @@ +package com.github.asavershin.api.domain.image.impl; + +import com.github.asavershin.api.common.annotations.Command; +import com.github.asavershin.api.domain.image.Image; +import com.github.asavershin.api.domain.image.ImageRepository; +import com.github.asavershin.api.domain.image.StoreImageOfUser; +import lombok.RequiredArgsConstructor; + +@Command +@RequiredArgsConstructor +public class StoreImageOfUserImpl implements StoreImageOfUser { + /** + * The ImageRepository dependency for accessing image data. + */ + private final ImageRepository imageRepository; + /** + * Not final to allow spring use proxy. + */ + @Override + public void storeImageOfUser(final Image image) { + imageRepository.save(image); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/image/impl/package-info.java b/api/src/main/java/com/github/asavershin/api/domain/image/impl/package-info.java new file mode 100644 index 0000000..65e29ee --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/image/impl/package-info.java @@ -0,0 +1,7 @@ +/** + * This package contains implementations of domain services, + * queries, commands for image entity. + * + * @author asavershin + */ +package com.github.asavershin.api.domain.image.impl; diff --git a/api/src/main/java/com/github/asavershin/api/domain/image/package-info.java b/api/src/main/java/com/github/asavershin/api/domain/image/package-info.java new file mode 100644 index 0000000..3e4dfaa --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/image/package-info.java @@ -0,0 +1,5 @@ +/** + * This package contains the domain model classes related to image processing. + * @author asavershin + */ +package com.github.asavershin.api.domain.image; diff --git a/api/src/main/java/com/github/asavershin/api/domain/lombok.config b/api/src/main/java/com/github/asavershin/api/domain/lombok.config new file mode 100644 index 0000000..f4387a7 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/lombok.config @@ -0,0 +1 @@ +lombok.accessors.fluent=true \ No newline at end of file diff --git a/api/src/main/java/com/github/asavershin/api/domain/package-info.java b/api/src/main/java/com/github/asavershin/api/domain/package-info.java new file mode 100644 index 0000000..9ee4b6d --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/package-info.java @@ -0,0 +1,8 @@ +/** + * This package contains the entities, value objects + * entity repositories, queries, commands and domain + * services used in the application. + * + * @author Asavershin + */ +package com.github.asavershin.api.domain; diff --git a/api/src/main/java/com/github/asavershin/api/domain/user/AuthException.java b/api/src/main/java/com/github/asavershin/api/domain/user/AuthException.java new file mode 100644 index 0000000..e6f8635 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/user/AuthException.java @@ -0,0 +1,21 @@ +package com.github.asavershin.api.domain.user; + +/** + * Represents an exception that occurs during authentication process. + * + * @author asavershin + */ +public class AuthException extends RuntimeException { + + /** + * Constructs an instance of AuthException with + * the specified detail message. + * + * @param message the detail message. + * The detail message is saved for + * later retrieval by the {@link #getMessage()} method. + */ + public AuthException(final String message) { + super(message); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/user/AuthenticatedUser.java b/api/src/main/java/com/github/asavershin/api/domain/user/AuthenticatedUser.java new file mode 100644 index 0000000..28bfc2e --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/user/AuthenticatedUser.java @@ -0,0 +1,59 @@ +package com.github.asavershin.api.domain.user; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +import java.util.Objects; + +@Getter +@ToString +@EqualsAndHashCode +public final class AuthenticatedUser { + /** + * The unique identifier of the user. + */ + private UserId userId; + + /** + * The credentials of the user. + */ + private Credentials userCredentials; + + /** + * Constructs an instance of AuthenticatedUser + * with the provided userId and userCredentials. + * + * @param aUserId the unique identifier of the user + * @param aUserCredentials the credentials of the user + */ + private AuthenticatedUser(final UserId aUserId, + final Credentials aUserCredentials) { + setUserId(aUserId); + setUserCredentials(aUserCredentials); + } + + /** + * Creates a new instance of AuthenticatedUser that becomes from repository + * with the provided userId and userCredentials. + * + * @param userId the unique identifier of the user + * @param credentials the credentials of the user + * @return a new instance of AuthenticatedUser + */ + public static AuthenticatedUser founded(final UserId userId, + final Credentials credentials) { + return new AuthenticatedUser(userId, credentials); + } + + private void setUserId(final UserId aUserId) { + Objects.requireNonNull(aUserId, "UserId must not be null"); + this.userId = aUserId; + } + + private void setUserCredentials(final Credentials aUserCredentials) { + Objects.requireNonNull(aUserCredentials, + "UserCredentials must not be null"); + this.userCredentials = aUserCredentials; + } +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/user/AuthenticatedUserRepository.java b/api/src/main/java/com/github/asavershin/api/domain/user/AuthenticatedUserRepository.java new file mode 100644 index 0000000..5ac94d9 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/user/AuthenticatedUserRepository.java @@ -0,0 +1,14 @@ +package com.github.asavershin.api.domain.user; + +/** + * Represents a repository for finding authenticated users by their email. + */ +public interface AuthenticatedUserRepository { + /** + * Finds an authenticated user by their email. + * + * @param email the email of the user to find + * @return the authenticated user with the given email, or null if not found + */ + AuthenticatedUser findByEmail(String email); +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/user/Credentials.java b/api/src/main/java/com/github/asavershin/api/domain/user/Credentials.java new file mode 100644 index 0000000..96a59e4 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/user/Credentials.java @@ -0,0 +1,53 @@ +package com.github.asavershin.api.domain.user; + + +import com.github.asavershin.api.common.Validator; + +import java.util.Objects; + +public record Credentials(String email, String password) { + /** + * The maximum length of an email. + */ + private static final int MAX_EMAIL_LENGTH = 50; + + /** + * The minimum length of an email. + */ + private static final int MIN_EMAIL_LENGTH = 5; + + /** + * The minimum length of a password. + */ + private static final int MIN_PASSWORD_LENGTH = 8; + + /** + * Constructs a new instance of Credentials. + * Validates the email and password before creating the instance. + */ + public Credentials { + validateEmail(email); + validatePassword(password); + } + + private void validateEmail(final String aEmail) { + Objects.requireNonNull(aEmail, "Email must not be null"); + Validator.assertArgumentLength(aEmail, + MIN_EMAIL_LENGTH, MAX_EMAIL_LENGTH, + "Email must be " + + MIN_EMAIL_LENGTH + "-" + + MAX_EMAIL_LENGTH + " in length"); + + Validator.assertStringFormat(aEmail, + "[A-Za-z0-9_.+-]+@[A-Za-z0-9-]+\\.[A-Za-z0-9.-]+", + "Email is not in the correct format"); + } + + private void validatePassword(final String aPassword) { + Objects.requireNonNull(aPassword, "Password must not be null"); + Validator.assertArgumentLength(aPassword, + MIN_PASSWORD_LENGTH, + "Password must be greater than " + MIN_PASSWORD_LENGTH); + } +} + diff --git a/api/src/main/java/com/github/asavershin/api/domain/user/FullName.java b/api/src/main/java/com/github/asavershin/api/domain/user/FullName.java new file mode 100644 index 0000000..162a715 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/user/FullName.java @@ -0,0 +1,45 @@ +package com.github.asavershin.api.domain.user; + + +import com.github.asavershin.api.common.Validator; + +import java.util.Objects; + +/** + * A value object representing a user's full name, + * consisting of a first name and a last name. + * The length of each name is limited to 20 characters. + * + * @param firstname The first name of the user + * @param lastname The last name of the user + */ +public record FullName(String firstname, String lastname) { + /** + * The maximum length of a first name. + */ + private static final int MAX_FIRST_NAME_LENGTH = 20; + /** + * The maximum length of a last name. + */ + private static final int MAX_LAST_NAME_LENGTH = 20; + + /** + * Constructs a new instance of FullName and validates the length of + * the first and last names. + */ + public FullName { + validateName(firstname, MAX_FIRST_NAME_LENGTH); + validateName(lastname, MAX_LAST_NAME_LENGTH); + } + + private void validateName(final String name, final int maxLength) { + Objects.requireNonNull(name, "Firstname must not be null"); + Validator.assertArgumentLength( + name, + 0, + MAX_FIRST_NAME_LENGTH, + "Name must be " + "0-" + maxLength + " in length" + ); + } +} + diff --git a/api/src/main/java/com/github/asavershin/api/domain/user/GetPartOfImagesForAuthenticatedUser.java b/api/src/main/java/com/github/asavershin/api/domain/user/GetPartOfImagesForAuthenticatedUser.java new file mode 100644 index 0000000..a4ede32 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/user/GetPartOfImagesForAuthenticatedUser.java @@ -0,0 +1,26 @@ +package com.github.asavershin.api.domain.user; + +import com.github.asavershin.api.domain.PartOfResources; +import com.github.asavershin.api.domain.image.Image; + +import java.util.List; + +/** + * This interface represents a function that retrieves a list of images + * for a given authenticated user and a specific part of resources. + * + */ +@FunctionalInterface +public interface GetPartOfImagesForAuthenticatedUser { + /** + * Retrieves a list of images for a given authenticated user and + * a specific part of resources. + * + * @param userId the unique identifier of the authenticated user + * @param partOfResources the specific part of resources + * for which the images are requested + * @return a list of images associated with the specified + * user and part of resources + */ + List get(UserId userId, PartOfResources partOfResources); +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/user/RegisterNewUser.java b/api/src/main/java/com/github/asavershin/api/domain/user/RegisterNewUser.java new file mode 100644 index 0000000..a3a4b5f --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/user/RegisterNewUser.java @@ -0,0 +1,18 @@ +package com.github.asavershin.api.domain.user; + +/** + * A functional interface that represents a method to register a new user. + * + */ +@FunctionalInterface +public interface RegisterNewUser { + /** + * Registers a new user with the provided full name and credentials. + * + * @param fullName The FullName value object + * containing the user's full name. + * @param credentials The Credentials value object + * containing the user's credentials. + */ + void register(FullName fullName, Credentials credentials); +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/user/TryToLogin.java b/api/src/main/java/com/github/asavershin/api/domain/user/TryToLogin.java new file mode 100644 index 0000000..374164d --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/user/TryToLogin.java @@ -0,0 +1,20 @@ +package com.github.asavershin.api.domain.user; + +/** + * A functional interface representing a login operation for user + * using login, password. + * + * @author asavershin + * @since 1.0 + */ +@FunctionalInterface +public interface TryToLogin { + /** + * Attempts to log the user in using the provided credentials. + * + * @param credentials The credentials to use for login. + * @return An instance of {@link AuthenticatedUser} + * if the login is successful, otherwise null. + */ + AuthenticatedUser login(Credentials credentials); +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/user/User.java b/api/src/main/java/com/github/asavershin/api/domain/user/User.java new file mode 100644 index 0000000..d1b2133 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/user/User.java @@ -0,0 +1,66 @@ +/** + * User class represents a user entity in the system. + * It contains the user's unique identifier, full name, and credentials. + * + * @author asavershin + */ +package com.github.asavershin.api.domain.user; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +import java.util.Objects; + +@ToString +@EqualsAndHashCode +@Getter +public class User { + /** + * The unique identifier for the user. + */ + private UserId userId; + + /** + * The full name of the user. + */ + private FullName userFullName; + + /** + * The credentials of the user. + */ + private Credentials userCredentials; + + /** + * Constructor for User class. + * + * @param id the unique identifier for the user + * @param fullname the full name of the user + * @param credentials the credentials of the user + */ + public User(final UserId id, + final FullName fullname, + final Credentials credentials) { + setUserId(id); + setUserCredentials(credentials); + setUserFullName(fullname); + } + + private void setUserId(final UserId id) { + Objects.requireNonNull(id, + "UserId must not be null"); + this.userId = id; + } + + private void setUserFullName(final FullName fullName) { + Objects.requireNonNull(fullName, + "UserFullName must not be null"); + this.userFullName = fullName; + } + + private void setUserCredentials(final Credentials credentials) { + Objects.requireNonNull(credentials, + "UserCredentials must not be null"); + this.userCredentials = credentials; + } +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/user/UserId.java b/api/src/main/java/com/github/asavershin/api/domain/user/UserId.java new file mode 100644 index 0000000..c463fcf --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/user/UserId.java @@ -0,0 +1,37 @@ +/** + * A unique identifier for a user. + * + * @author asavershin + */ +package com.github.asavershin.api.domain.user; + +import java.util.Objects; +import java.util.UUID; + +/** + * A value object representing a unique identifier for a user. + * + * @param value The unique identifier for the user. + */ +public record UserId(UUID value) { + + /** + * Constructs a new instance of {@code UserId} with the provided + * unique identifier. + * + * @param value The unique identifier for the user. + * @throws NullPointerException if the provided value is null. + */ + public UserId { + Objects.requireNonNull(value, "User ID must not be null"); + } + + /** + * Generates a new unique identifier for a user. + * + * @return A new unique identifier for a user. + */ + public static UserId nextIdentity() { + return new UserId(UUID.randomUUID()); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/user/UserRepository.java b/api/src/main/java/com/github/asavershin/api/domain/user/UserRepository.java new file mode 100644 index 0000000..e21f666 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/user/UserRepository.java @@ -0,0 +1,27 @@ +package com.github.asavershin.api.domain.user; + +/** + * UserRepository interface provides methods to interact with the User + * data. + * + * @author Tabnine + */ +public interface UserRepository { + + /** + * Saves a new User object to the repository. + * + * @param newUser the User object to be saved + */ + void save(User newUser); + + /** + * Checks if a User with the given email already exists in the + * repository. + * + * @param email the email of the User to be checked + * @return true if a User with the given email already exists, + * false otherwise + */ + boolean existByUserEmail(String email); +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/user/impl/GetPartOfImagesForAuthenticatedUserImpl.java b/api/src/main/java/com/github/asavershin/api/domain/user/impl/GetPartOfImagesForAuthenticatedUserImpl.java new file mode 100644 index 0000000..d3eb2e8 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/user/impl/GetPartOfImagesForAuthenticatedUserImpl.java @@ -0,0 +1,37 @@ +package com.github.asavershin.api.domain.user.impl; + +import com.github.asavershin.api.common.annotations.Query; +import com.github.asavershin.api.domain.PartOfResources; +import com.github.asavershin.api.domain.image.Image; +import com.github.asavershin.api.domain.image.ImageRepository; +import com.github.asavershin.api.domain.user.GetPartOfImagesForAuthenticatedUser; +import com.github.asavershin.api.domain.user.UserId; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +/** + * Implementation of the {@link GetPartOfImagesForAuthenticatedUser} interface. + * This class provides methods to retrieve a part of + * images for an authenticated user. + * + * @author asavershin + */ +@Query +@RequiredArgsConstructor +public class GetPartOfImagesForAuthenticatedUserImpl + implements GetPartOfImagesForAuthenticatedUser { + /** + * The ImageRepository dependency for fetching images. + */ + private final ImageRepository imageRepository; + + /** + * Not final to allow spring use proxy. + */ + @Override + public List get(final UserId userId, + final PartOfResources partOfResources) { + return imageRepository.findImagesByUserId(userId, partOfResources); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/user/impl/RegisterNewUserImpl.java b/api/src/main/java/com/github/asavershin/api/domain/user/impl/RegisterNewUserImpl.java new file mode 100644 index 0000000..82fe96f --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/user/impl/RegisterNewUserImpl.java @@ -0,0 +1,53 @@ +package com.github.asavershin.api.domain.user.impl; + +import com.github.asavershin.api.common.annotations.Command; +import com.github.asavershin.api.domain.user.Credentials; +import com.github.asavershin.api.domain.user.FullName; +import com.github.asavershin.api.domain.user.RegisterNewUser; +import com.github.asavershin.api.domain.user.User; +import com.github.asavershin.api.domain.user.UserId; +import com.github.asavershin.api.domain.user.UserRepository; +import org.springframework.security.crypto.password.PasswordEncoder; + +import lombok.RequiredArgsConstructor; + + +@Command +@RequiredArgsConstructor +public class RegisterNewUserImpl implements RegisterNewUser { + /** + * Dependency that allow crud with User entity. + */ + private final UserRepository userRepository; + /** + * is used to encode and decode passwords securely. + */ + private final PasswordEncoder passwordEncoder; + + /** + * Not final to allow spring use proxy. + */ + @Override + public void register(final FullName fullName, + final Credentials credentials) { + checkEmailForUnique(credentials.email()); + userRepository.save( + new User( + UserId.nextIdentity(), + fullName, + new Credentials(credentials.email(), + protectPassword(credentials.password())) + ) + ); + } + + private void checkEmailForUnique(final String email) { + if (userRepository.existByUserEmail(email)) { + throw new IllegalArgumentException("Email is not unique"); + } + } + + private String protectPassword(final String unprotectedPassword) { + return passwordEncoder.encode(unprotectedPassword); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/user/impl/TryToLoginImpl.java b/api/src/main/java/com/github/asavershin/api/domain/user/impl/TryToLoginImpl.java new file mode 100644 index 0000000..3823a7d --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/user/impl/TryToLoginImpl.java @@ -0,0 +1,42 @@ +package com.github.asavershin.api.domain.user.impl; + +import com.github.asavershin.api.domain.IsEntityFound; +import com.github.asavershin.api.domain.user.AuthException; +import com.github.asavershin.api.common.annotations.DomainService; +import com.github.asavershin.api.domain.user.AuthenticatedUser; +import com.github.asavershin.api.domain.user.AuthenticatedUserRepository; +import com.github.asavershin.api.domain.user.Credentials; +import com.github.asavershin.api.domain.user.TryToLogin; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; + +@DomainService +@RequiredArgsConstructor +public class TryToLoginImpl extends IsEntityFound implements TryToLogin { + /** + * The {@link PasswordEncoder} + * is used to encode and decode passwords securely. + */ + private final PasswordEncoder passwordEncoder; + /** + * The {@link AuthenticatedUserRepository} is used + * to retrieve user data from the database. + */ + private final AuthenticatedUserRepository authenticatedUserRepository; + /** + * Not final to allow spring use proxy. + */ + @Override + public AuthenticatedUser login(final Credentials credentials) { + + var authenticatedUser = authenticatedUserRepository + .findByEmail(credentials.email()); + + isEntityFound(authenticatedUser, "User", "email", credentials.email()); + if (passwordEncoder.matches(credentials.password(), + authenticatedUser.userCredentials().password())) { + return authenticatedUser; + } + throw new AuthException("Wrong password"); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/domain/user/impl/package-info.java b/api/src/main/java/com/github/asavershin/api/domain/user/impl/package-info.java new file mode 100644 index 0000000..a591eda --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/user/impl/package-info.java @@ -0,0 +1,7 @@ +/** + * This package contains implementations of domain services, + * queries, commands for user entity. + * + * @author asavershin + */ +package com.github.asavershin.api.domain.user.impl; diff --git a/api/src/main/java/com/github/asavershin/api/domain/user/package-info.java b/api/src/main/java/com/github/asavershin/api/domain/user/package-info.java new file mode 100644 index 0000000..859ec6c --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/domain/user/package-info.java @@ -0,0 +1,6 @@ +/** + * This package contains the domain model for the user entity. + * + * @author asavershin + */ +package com.github.asavershin.api.domain.user; diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/AdviceController.java b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/AdviceController.java new file mode 100644 index 0000000..eb5e5e7 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/AdviceController.java @@ -0,0 +1,125 @@ +package com.github.asavershin.api.infrastructure.in.impl.controllers.controllers; + +import com.github.asavershin.api.common.NotFoundException; +import com.github.asavershin.api.domain.ResourceOwnershipException; +import com.github.asavershin.api.domain.user.AuthException; +import com.github.asavershin.api.infrastructure.in.impl.controllers.controllers.dto.ExceptionBody; +import com.github.asavershin.api.infrastructure.in.impl.controllers.controllers.dto.UISuccessContainer; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +@RestControllerAdvice +public class AdviceController { + + /** + * Not final to allows Spring use proxy. + * + * @param ex is handled exception + * @return Exception representation for user. + */ + @ExceptionHandler({ResourceOwnershipException.class, AuthException.class}) + @ResponseStatus(HttpStatus.UNAUTHORIZED) + public UISuccessContainer handleValidationException( + final RuntimeException ex + ) { + return new UISuccessContainer(false, ex.getMessage()); + } + + /** + * Not final to allows Spring use proxy. + * + * @param ex is handled exception + * @return Exception representation for user. + */ + @ExceptionHandler({NotFoundException.class}) + @ResponseStatus(HttpStatus.NO_CONTENT) + public UISuccessContainer handleNotFoundException( + final RuntimeException ex + ) { + return new UISuccessContainer(false, ex.getMessage()); + } + + /** + * Not final to allows Spring use proxy. + * + * @param ex is handled exception + * @return Exception representation for user. + */ + @ExceptionHandler({Exception.class}) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public UISuccessContainer handleInnerException(final Exception ex) { + ex.printStackTrace(); + return new UISuccessContainer(false, "Very bad exception"); + } + + /** + * Not final to allows Spring use proxy. + * + * @param ex is handled exception + * @return Exception representation for user. + */ + @ExceptionHandler({IllegalArgumentException.class}) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public UISuccessContainer handleIllegalImageExtension( + final IllegalArgumentException ex + ) { + return new UISuccessContainer(false, ex.getMessage()); + } + + /** + * Not final to allows Spring use proxy. + * + * @param ex is handled exception + * @return Exception representation for user. + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ExceptionBody handleValidationException( + final MethodArgumentNotValidException ex + ) { + var exceptionBody = new ExceptionBody("Validation failed: "); + Map body = new HashMap<>(); + body.put("errors", + ex.getBindingResult().getAllErrors().stream() + .map( + DefaultMessageSourceResolvable + ::getDefaultMessage + ) + .filter(Objects::nonNull) + .toList()); + exceptionBody.setErrors(body); + return exceptionBody; + } + + /** + * Not final to allows Spring use proxy. + * + * @param ex is handled exception + * @return Exception representation for user. + */ + @ExceptionHandler(ConstraintViolationException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ExceptionBody handleConstraintViolationException( + final ConstraintViolationException ex + ) { + var exceptionBody = new ExceptionBody("Validation failed: "); + Map body = new HashMap<>(); + body.put("errors", + ex.getConstraintViolations().stream() + .map(ConstraintViolation::getMessage) + .filter(Objects::nonNull) + .toList()); + exceptionBody.setErrors(body); + return exceptionBody; + } +} diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/AuthController.java b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/AuthController.java new file mode 100644 index 0000000..e654795 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/AuthController.java @@ -0,0 +1,77 @@ +package com.github.asavershin.api.infrastructure.in.impl.controllers.controllers; + +import com.github.asavershin.api.application.in.services.user.ApplicationCredentials; +import com.github.asavershin.api.application.in.services.user.GetNewCredentials; +import com.github.asavershin.api.application.in.services.user.GetNewCredentialsUsingRefreshToken; +import com.github.asavershin.api.domain.user.RegisterNewUser; +import com.github.asavershin.api.infrastructure.in.impl.controllers.controllers.dto.user.UserLoginRequest; +import com.github.asavershin.api.infrastructure.in.impl.controllers.controllers.dto.user.UserRegistrationRequest; +import com.github.asavershin.api.infrastructure.in.security.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/auth") +@Tag(name = "auth", description = "Аутентификация и регистрация") +@RequiredArgsConstructor +public class AuthController { + /** + * Dependency that allows to register new user. + */ + private final RegisterNewUser register; + /** + * Dependency that allows to get new tokens during authentication. + */ + private final GetNewCredentials getNewCredentials; + /** + * Dependency that allows to get new tokens using refresh token. + */ + private final GetNewCredentialsUsingRefreshToken getRefreshToken; + + /** + * Not final to allows Spring use proxy. + * @param request DTO that contains user data like email, password + * firstname, etc. + */ + @PostMapping("/register") + @Operation(description = "Регистрация нового пользователя") + public void register( + final @RequestBody @Valid UserRegistrationRequest request + ) { + register.register(request.toFullName(), + request.toCredentials()); + } + + /** + * Not final to allows Spring use proxy. + * @param userLoginRequest DTO that represents login, password. + * @return DTO that contains access, refresh tokens + */ + @PostMapping("/login") + @Operation(description = "Аутентификация пользователя") + public ApplicationCredentials login( + final @RequestBody @Valid UserLoginRequest userLoginRequest + ) { + return getNewCredentials.get(userLoginRequest.toCredentials()); + } + /** + * Not final to allows Spring use proxy. + * @param user Is param that injects by spring and contains + * current authenticated spring user. + * @return DTO that contains access, refresh tokens. + */ + @PostMapping("/refresh-token") + @Operation(description = "Использовать рефреш токен") + public ApplicationCredentials refreshToken( + final @AuthenticationPrincipal CustomUserDetails user + ) { + return getRefreshToken.get(user.authenticatedUser().userCredentials()); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/ImageController.java b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/ImageController.java new file mode 100644 index 0000000..88d7211 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/ImageController.java @@ -0,0 +1,130 @@ +package com.github.asavershin.api.infrastructure.in.impl.controllers.controllers; + +import com.github.asavershin.api.application.in.services.image.ImageService; +import com.github.asavershin.api.domain.PartOfResources; +import com.github.asavershin.api.domain.image.ImageId; +import com.github.asavershin.api.domain.user.GetPartOfImagesForAuthenticatedUser; +import com.github.asavershin.api.infrastructure.in.impl.controllers.controllers.dto.image.GetImagesResponse; +import com.github.asavershin.api.infrastructure.in.impl.controllers.controllers.dto.UISuccessContainer; +import com.github.asavershin.api.infrastructure.in.impl.controllers.controllers.dto.image.UploadImageResponse; +import com.github.asavershin.api.infrastructure.in.security.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/image") +@Tag(name = "image", description = "Работа с изображениями") +@RequiredArgsConstructor +public class ImageController { + /** + * Domain query that allows you take images of specific user. + */ + private final GetPartOfImagesForAuthenticatedUser getImages; + /** + * Application service that contains logic for images get, post, delete. + * It contains some others dependencies for databases and jwt service. + */ + private final ImageService imageService; + + /** + * Not final to allows Spring use proxy. + * + * @param user Is param that injects by spring and contains + * current authenticated spring user. + * @param pageNumber Page number for pagination in DB + * @param pageSize Page size for pagination in DB + * @return List of images for specific user. + */ + @GetMapping("/images") + @Operation(description = "Получить изображения пользователя") + public GetImagesResponse getImages( + final @AuthenticationPrincipal CustomUserDetails user, + final Long pageNumber, + final Long pageSize + ) { + return GetImagesResponse.getImagesResponseFromImages( + getImages.get(user.authenticatedUser().userId(), + new PartOfResources(pageNumber, pageSize) + ) + ); + } + + /** + * Not final to allows Spring use proxy. + * + * @param user Is param that injects by spring and contains + * current authenticated spring user. + * @param file Is image with data and metadata. + * @return ImageId of the new image + */ + @PostMapping + @Operation(description = "Загрузить новую картинку") + public UploadImageResponse uploadImage( + final @AuthenticationPrincipal CustomUserDetails user, + final @RequestPart("file") MultipartFile file + ) { + return new UploadImageResponse( + imageService.storeImage( + user.authenticatedUser().userId(), + file + ) + ); + } + + /** + * Not final to allows Spring use proxy. + * + * @param user Is param that injects by spring and contains + * current authenticated spring user. + * @param imageId The id of the image + * @return The response that contains status of response(true, false) + * and message for user about successful. + */ + @DeleteMapping("/{image-id}") + @Operation(description = "Удалить картинку") + public UISuccessContainer deleteImage( + final @AuthenticationPrincipal CustomUserDetails user, + final @PathVariable("image-id") String imageId + ) { + imageService.deleteImageByImageId( + user.authenticatedUser().userId(), + new ImageId(UUID.fromString(imageId)) + ); + return new UISuccessContainer( + true, + "Image with id " + imageId + " deleted successfully" + ); + } + + /** + * Not final to allows Spring use proxy. + * + * @param user Is param that injects by spring and contains + * current authenticated spring user. + * @param imageId The id of the image + * @return bytes of the image + */ + @GetMapping("/{image-id}") + @Operation(description = "Получить картинку по id") + public byte[] downloadImage( + final @AuthenticationPrincipal CustomUserDetails user, + final @PathVariable("image-id") String imageId + ) { + return imageService.downloadImage( + new ImageId(UUID.fromString(imageId)), + user.authenticatedUser().userId() + ); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/ExceptionBody.java b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/ExceptionBody.java new file mode 100644 index 0000000..12b74e1 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/ExceptionBody.java @@ -0,0 +1,33 @@ +package com.github.asavershin.api.infrastructure.in.impl.controllers.controllers.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +import java.util.Map; + +@Getter +@Setter +@AllArgsConstructor +public class ExceptionBody { + /** + * Message for user about exception. + */ + private String message; + /** + * Map of error for a lot of exceptions. + */ + private Map errors; + /** + * Constructs an instance of {@link ExceptionBody} + * with the provided message. + * + * @param aMessage Message for user about exception. + */ + public ExceptionBody( + final String aMessage + ) { + this.message = aMessage; + } + +} diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/UISuccessContainer.java b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/UISuccessContainer.java new file mode 100644 index 0000000..c80f9a8 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/UISuccessContainer.java @@ -0,0 +1,17 @@ +package com.github.asavershin.api.infrastructure.in.impl.controllers.controllers.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class UISuccessContainer { + /** + * Indicates whether the operation was successful. + */ + private boolean success; + /** + * The message to be displayed to the user. + */ + private String message; +} diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/image/GetImagesResponse.java b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/image/GetImagesResponse.java new file mode 100644 index 0000000..7792a11 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/image/GetImagesResponse.java @@ -0,0 +1,33 @@ +package com.github.asavershin.api.infrastructure.in.impl.controllers.controllers.dto.image; + +import com.github.asavershin.api.domain.image.Image; +import lombok.Getter; + +import java.util.List; + +@Getter +public final class GetImagesResponse { + /** + * List of metadata of images. + */ + private final List images; + + private GetImagesResponse(final List aImages) { + this.images = aImages; + } + + /** + * Static fabric that map List of images to list of DTO. + * + * @param images List of images from domain + * @return Instance of GetImagesResponse + */ + public static GetImagesResponse getImagesResponseFromImages( + final List images + ) { + return new GetImagesResponse( + images.stream() + .map(ImageResponse::imageResponseFromEntity).toList() + ); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/image/ImageResponse.java b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/image/ImageResponse.java new file mode 100644 index 0000000..9600724 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/image/ImageResponse.java @@ -0,0 +1,42 @@ +package com.github.asavershin.api.infrastructure.in.impl.controllers.controllers.dto.image; + +import com.github.asavershin.api.domain.image.Image; +import lombok.Getter; + +@Getter +public final class ImageResponse { + /** + * UUID of the image. + */ + private final String imageId; + /** + * Image name in format "name.extension". + */ + private final String imageName; + /** + * Count of image bytes. + */ + private final Long imageSize; + + private ImageResponse(final String aImageId, + final String aImageName, + final Long aImageSize) { + this.imageId = aImageId; + this.imageName = aImageName; + this.imageSize = aImageSize; + } + + /** + * Static fabric for creating ImageResponse from image entity. + * + * @param image is domain entity + * @return DTO + */ + public static ImageResponse imageResponseFromEntity(final Image image) { + return new ImageResponse( + image.imageId().value().toString(), + image.metaInfo().imageNameWithExtension().toString(), + image.metaInfo().imageSize() + ); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/image/UploadImageResponse.java b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/image/UploadImageResponse.java new file mode 100644 index 0000000..3e05d1a --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/image/UploadImageResponse.java @@ -0,0 +1,18 @@ +package com.github.asavershin.api.infrastructure.in.impl.controllers.controllers.dto.image; + +import com.github.asavershin.api.domain.image.ImageId; +import lombok.Getter; + +@Getter +public class UploadImageResponse { + /** + * UUID of the image. + */ + private final String imageId; + /** + * @param aImageId UUID of the image + */ + public UploadImageResponse(final ImageId aImageId) { + this.imageId = aImageId.value().toString(); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/image/package-info.java b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/image/package-info.java new file mode 100644 index 0000000..d513554 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/image/package-info.java @@ -0,0 +1,5 @@ +/** + * This package contains image DTO's for HTTP reuqests/respons. + * @author asavershin + */ +package com.github.asavershin.api.infrastructure.in.impl.controllers.controllers.dto.image; diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/package-info.java b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/package-info.java new file mode 100644 index 0000000..417722c --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/package-info.java @@ -0,0 +1,5 @@ +/** + * This package contains DTO's for HTTP reuqests/respons. + * @author asavershin + */ +package com.github.asavershin.api.infrastructure.in.impl.controllers.controllers.dto; diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/user/UserLoginRequest.java b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/user/UserLoginRequest.java new file mode 100644 index 0000000..03abb31 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/user/UserLoginRequest.java @@ -0,0 +1,39 @@ +package com.github.asavershin.api.infrastructure.in.impl.controllers.controllers.dto.user; + +import com.github.asavershin.api.domain.user.Credentials; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Setter +@Getter +public class UserLoginRequest { + /** + * Minimum password length. + */ + private static final int MIN_PASSWORD_LENGTH = 8; + /** + * User email using as login. + */ + @NotEmpty(message = "Не заполнен email") + @Email(message = "Некорректная почта") + private String userEmail; + + /** + * User password. + */ + @NotEmpty(message = "Не заполнен пароль") + @Size(min = MIN_PASSWORD_LENGTH, + message = "Длина пароля должна быть не менее 8") + private String userPassword; + + /** + * Fabric using for mapping DTO to credentials. + * @return User credentials + */ + public final Credentials toCredentials() { + return new Credentials(userEmail, userPassword); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/user/UserRegistrationRequest.java b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/user/UserRegistrationRequest.java new file mode 100644 index 0000000..55132c4 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/user/UserRegistrationRequest.java @@ -0,0 +1,64 @@ +package com.github.asavershin.api.infrastructure.in.impl.controllers.controllers.dto.user; + + +import com.github.asavershin.api.config.properties.UserProperties; +import com.github.asavershin.api.domain.user.Credentials; +import com.github.asavershin.api.domain.user.FullName; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Setter +public class UserRegistrationRequest { + + + /** + * User firstname. + */ + @NotEmpty(message = "Не заполнено имя") + @Size(min = 1, max = UserProperties.MAX_FIRSTNAME_LENGTH, + message = "Недопустимая длина имени") + private String userFirstname; + + /** + * User lastname. + */ + @NotEmpty(message = "Не заполнена фамилия") + @Size(min = 1, max = UserProperties.MAX_LASTNAME_LENGTH, + message = "Недопустимая длина фамилии") + private String userLastname; + /** + * User email using as login. + */ + @NotEmpty(message = "Не заполнен email") + @Email(message = "Некорректная почта") + @Getter + private String userEmail; + + /** + * User password. + */ + @NotEmpty(message = "Не заполнен пароль") + @Size(min = UserProperties.MIN_PASSWORD_LENGTH, + message = "Длина пароля должна быть не менее 8") + @Getter + private String userPassword; + + /** + * Fabric method to create FullName from DTO. + * @return FullName value object + */ + public FullName toFullName() { + return new FullName(userFirstname, userLastname); + } + + /** + * Fabric method to create credentials. + * @return Credentials value object + */ + public Credentials toCredentials() { + return new Credentials(userEmail, userPassword); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/user/package-info.java b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/user/package-info.java new file mode 100644 index 0000000..c81e2bd --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/dto/user/package-info.java @@ -0,0 +1,5 @@ +/** + * This package contains user DTO's for HTTP reuqests/respons. + * @author asavershin + */ +package com.github.asavershin.api.infrastructure.in.impl.controllers.controllers.dto.user; diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/package-info.java b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/package-info.java new file mode 100644 index 0000000..0b178e4 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/in/impl/controllers/controllers/package-info.java @@ -0,0 +1,4 @@ +/** + * This package contains controllers that handle http requests. + */ +package com.github.asavershin.api.infrastructure.in.impl.controllers.controllers; diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/in/security/CustomUserDetails.java b/api/src/main/java/com/github/asavershin/api/infrastructure/in/security/CustomUserDetails.java new file mode 100644 index 0000000..0a130ef --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/in/security/CustomUserDetails.java @@ -0,0 +1,47 @@ +package com.github.asavershin.api.infrastructure.in.security; + +import com.github.asavershin.api.domain.user.AuthenticatedUser; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +public record CustomUserDetails(AuthenticatedUser authenticatedUser) + implements UserDetails { + + @Override + public Collection getAuthorities() { + return List.of(); + } + + @Override + public String getPassword() { + return authenticatedUser.userCredentials().password(); + } + + @Override + public String getUsername() { + return authenticatedUser.userCredentials().email(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/in/security/JwtAuthenticationFilter.java b/api/src/main/java/com/github/asavershin/api/infrastructure/in/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..81b6bea --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/in/security/JwtAuthenticationFilter.java @@ -0,0 +1,90 @@ +package com.github.asavershin.api.infrastructure.in.security; + +import com.github.asavershin.api.application.out.TokenRepository; +import com.github.asavershin.api.application.in.services.user.JwtService; +import com.github.asavershin.api.config.properties.JwtProperties; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + /** + * JwtService dependency injection. + * + * @see com.github.asavershin.api.application.in.services.user.JwtService + */ + private final JwtService jwtService; + + /** + * UserDetailsService dependency injection. + * + * @see com.github.asavershin.api.application.out.TokenRepository + */ + private final UserDetailsService userDetailsService; + + /** + * TokenRepository dependency injection. + * + * @see com.github.asavershin.api.application.out.TokenRepository + */ + private final TokenRepository tokenRepository; + + @Override + protected final void doFilterInternal( + final @NonNull HttpServletRequest request, + final @NonNull HttpServletResponse response, + final @NonNull FilterChain filterChain + ) throws ServletException, IOException { + var path = request.getServletPath(); + + if (path.contains("/register") || path.contains("/login")) { + filterChain.doFilter(request, response); + return; + } + final String authHeader = request.getHeader("Authorization"); + final String jwt; + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + jwt = authHeader.substring(JwtProperties.START_OF_TOKEN); + var email = jwtService.extractSub(jwt); + + String token; + var pathContainsRefreshToken = path.contains("/refresh-token"); + if (pathContainsRefreshToken) { + token = tokenRepository.getRefreshToken(email); + } else { + token = tokenRepository.getAccessToken(email); + } + if (!token.equals(jwt)) { + tokenRepository.deleteAllTokensByUserEmail(email); + return; + } + + var authToken = new UsernamePasswordAuthenticationToken( + userDetailsService.loadUserByUsername(email), + null, + null + ); + authToken.setDetails( + new WebAuthenticationDetailsSource().buildDetails(request) + ); + SecurityContextHolder.getContext().setAuthentication(authToken); + + filterChain.doFilter(request, response); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/in/security/LogautHandlerImpl.java b/api/src/main/java/com/github/asavershin/api/infrastructure/in/security/LogautHandlerImpl.java new file mode 100644 index 0000000..34eefa0 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/in/security/LogautHandlerImpl.java @@ -0,0 +1,46 @@ +package com.github.asavershin.api.infrastructure.in.security; + +import com.github.asavershin.api.application.out.TokenRepository; +import com.github.asavershin.api.application.in.services.user.JwtService; +import com.github.asavershin.api.config.properties.JwtProperties; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class LogautHandlerImpl implements LogoutHandler { + /** + * TokenRepository dependency. + */ + private final TokenRepository tokenRepository; + /** + * JwtService dependency. + */ + private final JwtService jwtService; + + /** + * Not final to allow spring using proxy. + * + * @param request the HTTP request + * @param response the HTTP response + * @param authentication the current principal details + */ + @Override + public void logout( + final HttpServletRequest request, + final HttpServletResponse response, + final Authentication authentication + ) { + final String authHeader = request.getHeader("Authorization"); + final String jwt; + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + return; + } + jwt = authHeader.substring(JwtProperties.START_OF_TOKEN); + tokenRepository.deleteAllTokensByUserEmail(jwtService.extractSub(jwt)); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/in/security/SecurityConfiguration.java b/api/src/main/java/com/github/asavershin/api/infrastructure/in/security/SecurityConfiguration.java new file mode 100644 index 0000000..b39bcdc --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/in/security/SecurityConfiguration.java @@ -0,0 +1,94 @@ +package com.github.asavershin.api.infrastructure.in.security; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.logout.LogoutHandler; + +import static org.springframework.security.config.http.SessionCreationPolicy.NEVER; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +@EnableMethodSecurity +public class SecurityConfiguration { + /** + * Routes that will not be subject to spring security. + */ + private static final String[] WHITE_LIST_URL = { + "api/v1/auth/register", + "api/v1/auth/login", + "/v2/api-docs", + "/v3/api-docs", + "/v3/api-docs/**", + "/swagger-resources", + "/swagger-resources/**", + "/configuration/ui", + "/configuration/security", + "/swagger-ui/**", + "/webjars/**", + "/swagger-ui.html", + "/docs"}; + /** + * A reference to the JwtAuthenticationFilter instance. + */ + private final JwtAuthenticationFilter jwtAuthFilter; + + /** + * A reference to the LogoutHandler instance. + */ + private final LogoutHandler logoutHandler; + + /** + * Creates a SecurityFilterChain instance, + * which is used to secure the application. + * It configures the security filter chain to disable CSRF protection, + * allow access to the specified URLs without authentication, + * and add the JwtAuthenticationFilter + * before the UsernamePasswordAuthenticationFilter. + * It also configures the logout functionality + * to use the specified logout handler and clear + * the security context after logout. + * + * @param http the HttpSecurity instance to configure + * @return the SecurityFilterChain instance + * @throws Exception if an error occurs during configuration + */ + @Bean + public SecurityFilterChain securityFilterChain( + final HttpSecurity http + ) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(req -> + req.requestMatchers(WHITE_LIST_URL) + .permitAll() + .anyRequest() + .authenticated() + ) + .sessionManagement( + session -> session.sessionCreationPolicy(NEVER) + ) + .addFilterBefore( + jwtAuthFilter, + UsernamePasswordAuthenticationFilter.class + ) + .logout(logout -> + logout.logoutUrl("/auth/logout") + .addLogoutHandler(logoutHandler) + .logoutSuccessHandler( + (request, response, authentication) + -> SecurityContextHolder + .clearContext() + ) + ); + return http.build(); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/in/security/UserDetailsServiceImpl.java b/api/src/main/java/com/github/asavershin/api/infrastructure/in/security/UserDetailsServiceImpl.java new file mode 100644 index 0000000..b34c970 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/in/security/UserDetailsServiceImpl.java @@ -0,0 +1,41 @@ +package com.github.asavershin.api.infrastructure.in.security; + +import com.github.asavershin.api.domain.IsEntityFound; +import com.github.asavershin.api.domain.user.AuthenticatedUserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class UserDetailsServiceImpl + extends IsEntityFound implements UserDetailsService { + /** + * The AuthenticatedUserRepository + * dependency is used to fetch the User entity from the database. + */ + private final AuthenticatedUserRepository repository; + /** + * The loadUserByUsername method is an implementation + * of the UserDetailsService interface. + * It is responsible for loading a UserDetails object + * based on the provided username. + * Not final to allow spring use proxy. + * + * @param username The username of the User to be loaded. + * @return A UserDetails object + * representing the User with the provided username. + * @throws UsernameNotFoundException If the User with + * the provided username is not found. + */ + @Override + public UserDetails loadUserByUsername( + final String username + ) throws UsernameNotFoundException { + var authenticatedUser = repository.findByEmail(username); + isEntityFound(authenticatedUser, "User", "email", username); + return new CustomUserDetails(authenticatedUser); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/in/security/package-info.java b/api/src/main/java/com/github/asavershin/api/infrastructure/in/security/package-info.java new file mode 100644 index 0000000..3f89ddd --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/in/security/package-info.java @@ -0,0 +1,5 @@ +/** + * This package contains logic for security like filter chains, + * user details ant others. + */ +package com.github.asavershin.api.infrastructure.in.security; diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/out/persistence/AuthenticatedUserRepositoryImpl.java b/api/src/main/java/com/github/asavershin/api/infrastructure/out/persistence/AuthenticatedUserRepositoryImpl.java new file mode 100644 index 0000000..e381f04 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/out/persistence/AuthenticatedUserRepositoryImpl.java @@ -0,0 +1,51 @@ +package com.github.asavershin.api.infrastructure.out.persistence; + +import com.github.asavershin.api.domain.user.AuthenticatedUser; +import com.github.asavershin.api.domain.user.AuthenticatedUserRepository; +import com.github.asavershin.api.domain.user.Credentials; +import com.github.asavershin.api.domain.user.UserId; +import lombok.RequiredArgsConstructor; +import org.jooq.DSLContext; +import org.jooq.Record; +import org.jooq.RecordMapper; +import org.springframework.stereotype.Repository; + +import static asavershin.generated.package_.tables.Users.USERS; + +@Repository +@RequiredArgsConstructor +public class AuthenticatedUserRepositoryImpl + implements AuthenticatedUserRepository, + RecordMapper { + /** + * The DSLContext object is used to interact with the database. + */ + private final DSLContext dslContext; + /** + * Not final to allow spring use proxy. + */ + @Override + public AuthenticatedUser findByEmail(final String email) { + return dslContext.select( + USERS.USER_ID, + USERS.USER_EMAIL, + USERS.USER_PASSWORD + ) + .from(USERS) + .where(USERS.USER_EMAIL.eq(email)) + .fetchOne(this); + } + + /** + * Not final to allow spring use proxy. + */ + @Override + public AuthenticatedUser map(final Record record) { + var userId = record.get(USERS.USER_ID); + var email = record.get(USERS.USER_EMAIL); + var password = record.get(USERS.USER_PASSWORD); + return AuthenticatedUser.founded( + new UserId(userId), new Credentials(email, password) + ); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/out/persistence/ImageRepositoryImpl.java b/api/src/main/java/com/github/asavershin/api/infrastructure/out/persistence/ImageRepositoryImpl.java new file mode 100644 index 0000000..3cddeba --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/out/persistence/ImageRepositoryImpl.java @@ -0,0 +1,108 @@ +package com.github.asavershin.api.infrastructure.out.persistence; + +import com.github.asavershin.api.domain.PartOfResources; +import com.github.asavershin.api.domain.image.Image; +import com.github.asavershin.api.domain.image.ImageId; +import com.github.asavershin.api.domain.image.ImageNameWithExtension; +import com.github.asavershin.api.domain.image.ImageRepository; +import com.github.asavershin.api.domain.image.MetaData; +import com.github.asavershin.api.domain.user.UserId; +import lombok.RequiredArgsConstructor; +import org.jooq.DSLContext; +import org.jooq.Record; +import org.jooq.RecordMapper; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static asavershin.generated.package_.Tables.USER_IMAGES; +import static asavershin.generated.package_.Tables.IMAGE; + +@Repository +@RequiredArgsConstructor +public class ImageRepositoryImpl + implements ImageRepository, RecordMapper { + /** + * The DSLContext object is used to interact with the database. + */ + private final DSLContext dslContext; + + /** + * Not final to allow spring use proxy. + */ + @Override + public void save(final Image image) { + dslContext.insertInto(IMAGE) + .set(IMAGE.IMAGE_ID, image.imageId().value()) + .set( + IMAGE.IMAGE_NAME, + image.metaInfo().imageNameWithExtension().imageName() + ) + .set(IMAGE.IMAGE_SIZE, image.metaInfo().imageSize()) + .set(IMAGE.IMAGE_EXTENSION, + image.metaInfo().imageNameWithExtension() + .imageExtension().toString()) + .execute(); + dslContext.insertInto(USER_IMAGES) + .set(USER_IMAGES.IMAGE_ID, image.imageId().value()) + .set(USER_IMAGES.USER_ID, image.userId().value()) + .execute(); + } + + /** + * Not final to allow spring use proxy. + */ + @Override + public List findImagesByUserId(final UserId userId, + final PartOfResources page) { + return dslContext.select(IMAGE.fields()).select(USER_IMAGES.USER_ID) + .from(IMAGE) + .join(USER_IMAGES).using(IMAGE.IMAGE_ID) + .offset(page.pageNumber() * page.pageSize()) + .limit(page.pageSize()) + .fetch(this); + } + /** + * Not final to allow spring use proxy. + */ + @Override + public Image findImageByImageId(final ImageId imageId) { + return dslContext.select(IMAGE.fields()).select(USER_IMAGES.USER_ID) + .from(IMAGE) + .join(USER_IMAGES).using(IMAGE.IMAGE_ID) + .where(IMAGE.IMAGE_ID.eq(imageId.value())) + .fetchOne(this); + } + /** + * Not final to allow spring use proxy. + */ + @Override + public void deleteImageByImageId(final Image imageId) { + dslContext.deleteFrom(IMAGE) + .where(IMAGE.IMAGE_ID.eq(imageId.imageId().value())) + .execute(); + } + /** + * Not final to allow spring use proxy. + */ + @Override + public Image map(final Record record) { + var imageId = record.get(IMAGE.IMAGE_ID); + var imageName = record.get(IMAGE.IMAGE_NAME); + var userId = record.get(USER_IMAGES.USER_ID); + var imageSize = record.get(IMAGE.IMAGE_SIZE); + var imageExtension = record.get(IMAGE.IMAGE_EXTENSION); + return new Image( + new ImageId(imageId), + new MetaData( + ImageNameWithExtension.founded( + imageName, + imageExtension + ), + imageSize + ), + new UserId(userId) + ); + } +} + diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/out/persistence/UserRepositoryImpl.java b/api/src/main/java/com/github/asavershin/api/infrastructure/out/persistence/UserRepositoryImpl.java new file mode 100644 index 0000000..4b43728 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/out/persistence/UserRepositoryImpl.java @@ -0,0 +1,45 @@ +package com.github.asavershin.api.infrastructure.out.persistence; + +import asavershin.generated.package_.tables.records.UsersRecord; +import com.github.asavershin.api.domain.user.UserRepository; +import com.github.asavershin.api.domain.user.User; +import lombok.RequiredArgsConstructor; +import org.jooq.DSLContext; +import org.springframework.stereotype.Repository; + +import static asavershin.generated.package_.Tables.USERS; + +@Repository +@RequiredArgsConstructor +public class UserRepositoryImpl implements UserRepository { + /** + * The DSLContext object is used to interact with the database. + */ + private final DSLContext dslContext; + + /** + * Not final to allow spring use proxy. + */ + @Override + public void save(final User newUser) { + UsersRecord userRecord = dslContext.newRecord(USERS); + userRecord.setUserId(newUser.userId().value()); + userRecord.setUserFirstname(newUser.userCredentials().email()); + userRecord.setUserLastname(newUser.userFullName().lastname()); + userRecord.setUserEmail(newUser.userCredentials().email()); + userRecord.setUserPassword(newUser.userCredentials().password()); + + dslContext.insertInto(USERS) + .set(userRecord) + .execute(); + } + /** + * Not final to allow spring use proxy. + */ + @Override + public boolean existByUserEmail(final String email) { + return dslContext.fetchExists( + USERS, USERS.USER_EMAIL.eq(email) + ); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/out/persistence/package-info.java b/api/src/main/java/com/github/asavershin/api/infrastructure/out/persistence/package-info.java new file mode 100644 index 0000000..5ee66da --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/out/persistence/package-info.java @@ -0,0 +1,5 @@ +/** + * This package contains SQL repositories. + * @author asavershin + */ +package com.github.asavershin.api.infrastructure.out.persistence; diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/out/storage/CacheRepositoryIml.java b/api/src/main/java/com/github/asavershin/api/infrastructure/out/storage/CacheRepositoryIml.java new file mode 100644 index 0000000..51e0422 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/out/storage/CacheRepositoryIml.java @@ -0,0 +1,45 @@ +package com.github.asavershin.api.infrastructure.out.storage; + +import com.github.asavershin.api.application.out.CacheRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.concurrent.TimeUnit; + +@Repository +@RequiredArgsConstructor +public class CacheRepositoryIml implements CacheRepository { + /** + * Redis template is used to interact with redis server. + */ + private final RedisTemplate redisTemplate; + + /** + * Not final to allow spring use proxy. + */ + @Override + public void addCache(final String key, + final String token, + final long expiration) { + redisTemplate.opsForValue().set( + key, token, expiration, TimeUnit.MILLISECONDS + ); + } + + /** + * Not final to allow spring use proxy. + */ + @Override + public String getCache(final String key) { + return (String) redisTemplate.opsForValue().get(key); + } + + /** + * Not final to allow spring use proxy. + */ + @Override + public void deleteCache(final String key) { + redisTemplate.delete(key); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/out/storage/FileException.java b/api/src/main/java/com/github/asavershin/api/infrastructure/out/storage/FileException.java new file mode 100644 index 0000000..e7c3567 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/out/storage/FileException.java @@ -0,0 +1,12 @@ +package com.github.asavershin.api.infrastructure.out.storage; + +public class FileException extends RuntimeException { + /** + * Constructs a new FileException with the specified detail message. + * + * @param s the detail message + */ + public FileException(final String s) { + super(s); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/out/storage/MinioServiceIml.java b/api/src/main/java/com/github/asavershin/api/infrastructure/out/storage/MinioServiceIml.java new file mode 100644 index 0000000..055f46b --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/out/storage/MinioServiceIml.java @@ -0,0 +1,141 @@ +package com.github.asavershin.api.infrastructure.out.storage; + + +import com.github.asavershin.api.application.out.MinioService; +import com.github.asavershin.api.config.properties.MinIOProperties; +import io.minio.BucketExistsArgs; +import io.minio.GetObjectArgs; +import io.minio.MakeBucketArgs; +import io.minio.MinioClient; +import io.minio.PutObjectArgs; +import io.minio.RemoveObjectArgs; +import lombok.SneakyThrows; +import org.apache.commons.compress.utils.IOUtils; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.util.List; + +@Service +public class MinioServiceIml implements MinioService { + /** + * The MinioClient is used to interact with the MinIO server. + */ + private final MinioClient minioClient; + /** + * The MinIO properties from .yml. + */ + private final MinIOProperties minioProperties; + /** + * Constructor for {@link MinioServiceIml}. + * + * @param aMinioClient The {@link MinioClient} + * instance to interact with the MinIO server. + * @param aMinioProperties The {@link MinIOProperties} + * instance containing the configuration + * for the MinIO server. + */ + public MinioServiceIml(final MinioClient aMinioClient, + final MinIOProperties aMinioProperties) { + this.minioClient = aMinioClient; + this.minioProperties = aMinioProperties; + createBucket(); + } + /** + * Not final to allow spring use proxy. + */ + @Override + public void saveFile(final MultipartFile image, final String filename) { + + if (!bucketExists(minioProperties.getBucket())) { + throw new FileException( + "File upload failed: bucket does not exist" + ); + } + + if (image.isEmpty() || image.getOriginalFilename() == null) { + throw new FileException("File must have name"); + } + InputStream inputStream; + try { + inputStream = image.getInputStream(); + } catch (Exception e) { + throw new FileException("File upload failed: " + + e.getMessage()); + } + saveFile(inputStream, filename); + } + /** + * Not final to allow spring use proxy. + */ + @Override + public byte[] getFile(final String link) { + if (link == null) { + throw new FileException("File download failed: link is nullable"); + } + try { + return IOUtils.toByteArray( + minioClient.getObject(GetObjectArgs.builder() + .bucket(minioProperties.getBucket()) + .object(link) + .build())); + } catch (Exception e) { + throw new FileException("File download failed: " + e.getMessage()); + } + } + /** + * Not final to allow spring use proxy. + */ + @Override + public void deleteFiles(final List links) { + if (links == null || links.isEmpty()) { + return; + } + if (!bucketExists(minioProperties.getBucket())) { + throw new FileException("Minio bucket doesn't exist"); + } + try { + for (var link : links) { + minioClient.removeObject( + RemoveObjectArgs.builder() + .bucket(minioProperties.getBucket()) + .object(link) + .build()); + } + } catch (Exception e) { + throw new FileException("Failed to delete file: " + e.getMessage()); + } + } + + @SneakyThrows + private void createBucket() { + boolean found = minioClient.bucketExists(BucketExistsArgs.builder() + .bucket(minioProperties.getBucket()) + .build()); + if (!found) { + minioClient.makeBucket(MakeBucketArgs.builder() + .bucket(minioProperties.getBucket()) + .build()); + } + } + + @SneakyThrows + private void saveFile( + final InputStream inputStream, + final String fileName + ) { + minioClient.putObject(PutObjectArgs.builder() + .stream(inputStream, inputStream.available(), -1) + .bucket(minioProperties.getBucket()) + .object(fileName) + .build()); + } + + @SneakyThrows(Exception.class) + private boolean bucketExists(final String bucketName) { + return minioClient.bucketExists( + BucketExistsArgs.builder().bucket(bucketName).build() + ); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/out/storage/TokenRepositoryIml.java b/api/src/main/java/com/github/asavershin/api/infrastructure/out/storage/TokenRepositoryIml.java new file mode 100644 index 0000000..6344c09 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/out/storage/TokenRepositoryIml.java @@ -0,0 +1,67 @@ +package com.github.asavershin.api.infrastructure.out.storage; + +import com.github.asavershin.api.application.out.CacheRepository; +import com.github.asavershin.api.application.out.TokenRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class TokenRepositoryIml implements TokenRepository { + /** + * The prefix for access tokens in the cache. + */ + private final String accessKey = "ACCESS_TOKEN_"; + + /** + * The prefix for refresh tokens in the cache. + */ + private final String refreshKey = "REFRESH_TOKEN_"; + + /** + * CacheRepository instance for storing and retrieving tokens. + */ + private final CacheRepository cacheRepository; + + /** + * Not final to allow spring use proxy. + */ + @Override + public String getAccessToken(final String email) { + return cacheRepository.getCache(accessKey + email); + } + + /** + * Not final to allow spring use proxy. + */ + @Override + public String getRefreshToken(final String email) { + return cacheRepository.getCache(refreshKey + email); + } + /** + * Not final to allow spring use proxy. + */ + @Override + public void saveRefreshToken(final String username, + final String jwtToken, + final Long expiration) { + cacheRepository.addCache(refreshKey + username, jwtToken, expiration); + } + /** + * Not final to allow spring use proxy. + */ + @Override + public void saveAccessToken(final String username, + final String jwtToken, + final Long expiration) { + cacheRepository.addCache(accessKey + username, jwtToken, expiration); + } + /** + * Not final to allow spring use proxy. + */ + @Override + public void deleteAllTokensByUserEmail(final String username) { + cacheRepository.deleteCache(refreshKey + username); + cacheRepository.deleteCache(accessKey + username); + } +} diff --git a/api/src/main/java/com/github/asavershin/api/infrastructure/out/storage/package-info.java b/api/src/main/java/com/github/asavershin/api/infrastructure/out/storage/package-info.java new file mode 100644 index 0000000..a094d40 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/infrastructure/out/storage/package-info.java @@ -0,0 +1,5 @@ +/** + * This package contains impl of noSQL storages. + * @author asavershin + */ +package com.github.asavershin.api.infrastructure.out.storage; diff --git a/api/src/main/java/com/github/asavershin/api/package-info.java b/api/src/main/java/com/github/asavershin/api/package-info.java new file mode 100644 index 0000000..a3a5220 --- /dev/null +++ b/api/src/main/java/com/github/asavershin/api/package-info.java @@ -0,0 +1,6 @@ +/** + * This package contains the API for the application. + * Implementation of a microservice that directly interacts with the user. + * @author asavershin + */ +package com.github.asavershin.api; diff --git a/api/src/main/resources/application.yml b/api/src/main/resources/application.yml new file mode 100644 index 0000000..ee33663 --- /dev/null +++ b/api/src/main/resources/application.yml @@ -0,0 +1,46 @@ +server: + port: 8081 + +spring: + servlet: + multipart: + max-file-size: 10MB + config: + import: optional:file:.env[.properties] + datasource: + url: jdbc:postgresql://${HOST}:${PORT_DB}/${POSTGRES_DB}?currentSchema=${POSTGRES_SCHEMA} + username: ${POSTGRES_USER} + password: ${POSTGRES_PASSWORD} + liquibase: + enabled: true + drop-first: false + change-log: classpath:db/changelog/db.changelog-master.yaml + default-schema: ${POSTGRES_SCHEMA} + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + password: ${REDIS_PASSWORD} + cache: + type: redis + cache-names: redis-cache + redis: + time-to-live: ${REDIS_CACHE_TIME} + +springdoc: + swagger-ui: + path: /docs + +jwt: + access-expiration: ${ACCESS_EXPIRATION} + refresh-expiration: ${REFRESH_EXPIRATION} + secret: ${JWT_SECRET} + +minio: + url: ${MINIO_URL} + user: ${MINIO_ROOT_USER} + password: ${MINIO_ROOT_PASSWORD} + bucket: ${MINIO_BUCKET} + console-port: ${MINIO_CONSOLE_PORT} + port: ${MINIO_PORT} + diff --git a/api/src/main/resources/db/changelog/db.changelog-master.yaml b/api/src/main/resources/db/changelog/db.changelog-master.yaml new file mode 100644 index 0000000..fc3e65d --- /dev/null +++ b/api/src/main/resources/db/changelog/db.changelog-master.yaml @@ -0,0 +1,4 @@ +databaseChangeLog: + - includeAll: + path: /migrations + relativeToChangelogFile: true \ No newline at end of file diff --git a/api/src/main/resources/db/changelog/migrations/1-create-user.sql b/api/src/main/resources/db/changelog/migrations/1-create-user.sql new file mode 100644 index 0000000..64239a0 --- /dev/null +++ b/api/src/main/resources/db/changelog/migrations/1-create-user.sql @@ -0,0 +1,14 @@ +-- liquibase formatted sql + +-- changeset asavershin:createUser +CREATE TABLE users +( + user_id UUID PRIMARY KEY, + user_firstname varchar(20) NOT NULL, + user_lastname varchar(20) NOT NULL, + user_email varchar(50) UNIQUE NOT NULL, + user_password TEXT not null +); + +CREATE INDEX idx_user_id ON users(user_id); +CREATE INDEX idx_user_email ON users(user_email); \ No newline at end of file diff --git a/api/src/main/resources/db/changelog/migrations/2-create-image.sql b/api/src/main/resources/db/changelog/migrations/2-create-image.sql new file mode 100644 index 0000000..5f99b97 --- /dev/null +++ b/api/src/main/resources/db/changelog/migrations/2-create-image.sql @@ -0,0 +1,24 @@ +-- liquibase formatted sql + +-- changeset asavershin:createImage +CREATE TABLE image +( + image_id UUID PRIMARY KEY, + image_name varchar(50) NOT NULL, + image_extension varchar(10) NOT NULL, + image_size bigint not null +); + +CREATE INDEX idx_image_id ON image(image_id); + +CREATE TABLE user_images +( + image_id UUID, + user_id UUID, + CONSTRAINT fk_user_images_user FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE, + CONSTRAINT fk_users_image_image FOREIGN KEY (image_id) REFERENCES image (image_id) ON DELETE CASCADE +); + +CREATE INDEX idx_users_image_user_id ON user_images (user_id); +CREATE INDEX idx_users_image_image_id ON user_images (image_id); + diff --git a/api/src/main/resources/logback.xml b/api/src/main/resources/logback.xml new file mode 100644 index 0000000..fa47f73 --- /dev/null +++ b/api/src/main/resources/logback.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} -%kvp- %msg%n + + + + + + + + + + + diff --git a/api/src/test/java/com/github/asavershin/api/common/ImageHelper.java b/api/src/test/java/com/github/asavershin/api/common/ImageHelper.java new file mode 100644 index 0000000..6cd6c04 --- /dev/null +++ b/api/src/test/java/com/github/asavershin/api/common/ImageHelper.java @@ -0,0 +1,31 @@ +package com.github.asavershin.api.common; + +import com.github.asavershin.api.domain.image.ImageId; +import com.github.asavershin.api.domain.image.ImageNameWithExtension; +import com.github.asavershin.api.domain.image.MetaData; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +public class ImageHelper { + public static String illegalExtension = ".pdf"; + public static ImageId imageId(){ + return ImageId.nextIdentity(); + } + public static MetaData metaInfo1(){ + return new MetaData(ImageNameWithExtension.fromOriginalFileName("image.jpg"), 1L); + } + + public static MetaData metaInfo3(){ + return new MetaData(ImageNameWithExtension.fromOriginalFileName("image3.jpg"), 3L); + } + + public static MultipartFile multipartFile1() { + return new MockMultipartFile("image.jpg", "image.jpg", "image/jpeg", new byte[]{0, 1}); + } + + public static MultipartFile multipartFileWithIllegalException() { + return new MockMultipartFile("image" + illegalExtension, + "image" + illegalExtension, + "image/jpeg", new byte[]{0, 1}); + } +} diff --git a/api/src/test/java/com/github/asavershin/api/common/TestUserRepository.java b/api/src/test/java/com/github/asavershin/api/common/TestUserRepository.java new file mode 100644 index 0000000..1261574 --- /dev/null +++ b/api/src/test/java/com/github/asavershin/api/common/TestUserRepository.java @@ -0,0 +1,47 @@ +package com.github.asavershin.api.common; + + +import com.github.asavershin.api.domain.user.Credentials; +import com.github.asavershin.api.domain.user.FullName; +import com.github.asavershin.api.domain.user.UserId; +import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.Nullable; +import org.jooq.DSLContext; +import org.jooq.Record; +import org.jooq.RecordMapper; +import org.jooq.SelectConditionStep; +import org.springframework.stereotype.Repository; +import com.github.asavershin.api.domain.user.User; + +import java.util.UUID; + +import static asavershin.generated.package_.tables.Users.USERS; + +@Repository +@RequiredArgsConstructor +public class TestUserRepository implements RecordMapper { + private final DSLContext dslContext; + public SelectConditionStep findUserById(UUID id){ + return dslContext.select() + .from(USERS) + .where(USERS.USER_ID.eq(id)); + } + + public Long countUsers(){ + return dslContext.selectCount() + .from(USERS) + .fetchOne(0, Long.class); + } + + @Override + public @Nullable User map(Record record) { + var userId = record.get(USERS.USER_ID); + var email = record.get(USERS.USER_EMAIL); + var password = record.get(USERS.USER_PASSWORD); + var firstName = record.get(USERS.USER_FIRSTNAME); + var lastName = record.get(USERS.USER_LASTNAME); + return new User(new UserId(userId), + new FullName(firstName, lastName), + new Credentials(email, password)); + } +} diff --git a/api/src/test/java/com/github/asavershin/api/common/UserHelper.java b/api/src/test/java/com/github/asavershin/api/common/UserHelper.java new file mode 100644 index 0000000..64a8fae --- /dev/null +++ b/api/src/test/java/com/github/asavershin/api/common/UserHelper.java @@ -0,0 +1,46 @@ +package com.github.asavershin.api.common; + +import com.github.asavershin.api.domain.user.Credentials; +import com.github.asavershin.api.domain.user.FullName; +import com.github.asavershin.api.domain.user.User; +import com.github.asavershin.api.domain.user.UserId; + +public class UserHelper { + public static FullName fullName1(){ + return new FullName("Alexander", "Avershin"); + } + + public static FullName fullName2(){ + return new FullName("Avershin", "Alexander"); + } + + public static Credentials credentials1(){ + return new Credentials("test@test.test", "verysecretpassword"); + } + + public static Credentials credentials2(){ + return new Credentials("test2@test.test", "verysecretpassword"); + } + + public static Credentials invalidEmail(){ + return new Credentials("invalid", "verysecretpassword"); + } + + public static UserId UserId(){ + return UserId.nextIdentity(); + } + + public static User user1(UserId userId, FullName userFullName, Credentials userCredentials){ + return new User(userId, userFullName, userCredentials); + } + + public static FullName longFirstName(){ + return new FullName("TooLongFirstNameTooLongFirTooLongFirstNameTooLongFirstNameTooLongFirstNameTooLongFirstNameTooLongFirstNameTooLongFirstNameTooLongFirstNameTooLongFirstNamestNameTooLongFirstNameTooLongFirstName", + "Lastname"); + }; + + public static FullName longLastName(){ + return new FullName("Firstname", + "TooLongFirstNameTooLongFirstNameTooLongFirstNameTooLongFirstNameTooLongFirstNameTooLongFirstNameTooLongFirstNameTooLongFirstNameTooLongFirstNameTooLongFirstNameTooLongFirstNameTooLongFirstNameTooLongLastNameTooLongLastNameTooLongLastNameTooLongLastName"); + }; +} diff --git a/api/src/test/java/com/github/asavershin/api/config/CacheRedisConfig.java b/api/src/test/java/com/github/asavershin/api/config/CacheRedisConfig.java new file mode 100644 index 0000000..6a76e8f --- /dev/null +++ b/api/src/test/java/com/github/asavershin/api/config/CacheRedisConfig.java @@ -0,0 +1,44 @@ +package com.github.asavershin.api.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.stereotype.Component; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +import static java.util.Objects.isNull; + +@Slf4j +public class CacheRedisConfig { + + private static volatile GenericContainer redisContainer = null; + + private static GenericContainer getRedisContainer() { + var instance = redisContainer; + if (isNull(redisContainer)) { + synchronized (GenericContainer.class) { + redisContainer = instance = new GenericContainer<>( + DockerImageName.parse("redis:7.2-rc-alpine")) + .withExposedPorts(6379); + redisContainer.start(); + } + } + return instance; + } + + @Component("RedisInitializer") + public static class Initializer implements ApplicationContextInitializer { + @Override + public void initialize(ConfigurableApplicationContext configurableApplicationContext) { + var redisContainer = getRedisContainer(); + TestPropertyValues.of( + "spring.data.redis.host=" + redisContainer.getHost(), + "spring.data.redis.port=" + redisContainer.getMappedPort(6379) + ).applyTo(configurableApplicationContext.getEnvironment()); + } + } + +} + diff --git a/api/src/test/java/com/github/asavershin/api/config/MinioConfig.java b/api/src/test/java/com/github/asavershin/api/config/MinioConfig.java new file mode 100644 index 0000000..42412a8 --- /dev/null +++ b/api/src/test/java/com/github/asavershin/api/config/MinioConfig.java @@ -0,0 +1,52 @@ +package com.github.asavershin.api.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.stereotype.Component; +import org.testcontainers.containers.MinIOContainer; +import org.testcontainers.containers.output.Slf4jLogConsumer; + +import java.time.Duration; + +import static java.util.Objects.isNull; + +@Slf4j +public class MinioConfig { + + private static volatile MinIOContainer minioContainer = null; + + + private static MinIOContainer getMinioContainer() { + MinIOContainer instance = minioContainer; + if (isNull(minioContainer)) { + synchronized (MinIOContainer.class) { + minioContainer = instance = + new MinIOContainer("minio/minio:latest") + .withReuse(true) + .withLogConsumer(new Slf4jLogConsumer(log)) + .withExposedPorts(9000) + .withUserName("minioadmin") + .withPassword("minioadmin") + .withStartupTimeout(Duration.ofSeconds(60)); + minioContainer.start(); + } + } + return instance; + } + + @Component("MinioInitializer") + public static class Initializer implements ApplicationContextInitializer { + @Override + public void initialize(ConfigurableApplicationContext configurableApplicationContext) { + var minioContainer = getMinioContainer(); + TestPropertyValues.of( + "minio.url=" + minioContainer.getS3URL(), + "minio.access-key=" + minioContainer.getUserName(), + "minio.secret-key=" + minioContainer.getPassword() + ).applyTo(configurableApplicationContext.getEnvironment()); + } + } + +} diff --git a/api/src/test/java/com/github/asavershin/api/config/PostgreConfig.java b/api/src/test/java/com/github/asavershin/api/config/PostgreConfig.java new file mode 100644 index 0000000..94faf77 --- /dev/null +++ b/api/src/test/java/com/github/asavershin/api/config/PostgreConfig.java @@ -0,0 +1,52 @@ +package com.github.asavershin.api.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.stereotype.Component; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.containers.output.Slf4jLogConsumer; + +import java.time.Duration; + +import static java.util.Objects.isNull; + +@Slf4j +public class PostgreConfig { + private static volatile PostgreSQLContainer postgreSQLContainer = null; + + + private static PostgreSQLContainer getPostgreSQLContainer(){ + PostgreSQLContainer instance = postgreSQLContainer; + if(isNull(instance)){ + synchronized (PostgreSQLContainer.class){ + postgreSQLContainer = instance = + new PostgreSQLContainer<>("postgres:15.1") + .withDatabaseName("public") + .withUsername("postgres") + .withPassword("postgres") + .withReuse(true) + .withLogConsumer(new Slf4jLogConsumer(log)) + .withStartupTimeout(Duration.ofSeconds(60)); + postgreSQLContainer.start(); + } + } + return instance; + } + + @Component("PostgresInitializer") + public static class Initializer + implements ApplicationContextInitializer { + @Override + public void initialize(ConfigurableApplicationContext configurableApplicationContext) { + var postgreSQLContainer = getPostgreSQLContainer(); + TestPropertyValues.of( + "spring.datasource.url=" + postgreSQLContainer.getJdbcUrl(), + "spring.datasource.username=" + postgreSQLContainer.getUsername(), + "spring.datasource.password=" + postgreSQLContainer.getPassword(), + "spring.liquibase.change_log=classpath:db/changelog/db.changelog-master.yaml" + ).applyTo(configurableApplicationContext.getEnvironment()); + } + } +} diff --git a/api/src/test/java/com/github/asavershin/api/domaintest/AuthenticatedUserTest.java b/api/src/test/java/com/github/asavershin/api/domaintest/AuthenticatedUserTest.java new file mode 100644 index 0000000..0a070ef --- /dev/null +++ b/api/src/test/java/com/github/asavershin/api/domaintest/AuthenticatedUserTest.java @@ -0,0 +1,47 @@ +package com.github.asavershin.api.domaintest; + +import com.github.asavershin.api.common.UserHelper; +import com.github.asavershin.api.domain.user.*; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class AuthenticatedUserTest { + + @Test + public void testFoundedUser() { + UserId userId = UserHelper.UserId(); + Credentials credentials = UserHelper.credentials1(); + AuthenticatedUser authenticatedUser = AuthenticatedUser.founded(userId, credentials); + assertEquals(userId, authenticatedUser.userId()); + assertEquals(credentials, authenticatedUser.userCredentials()); + } + + @Test + public void testFoundedUserWithEmailEmpty() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + AuthenticatedUser.founded(UserHelper.UserId(), new Credentials("", "password")); + }); + assertEquals("Email must be 5-50 in length", exception.getMessage()); + } + + @Test + public void testFoundedUserWithPasswordNull() { + NullPointerException exception = assertThrows(NullPointerException.class, () -> { + AuthenticatedUser.founded(UserHelper.UserId(), new Credentials("test@test.com", null)); + }); + assertEquals("Password must not be null", exception.getMessage()); + } + + @Test + public void testFoundedUserWithPasswordTooShort() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + AuthenticatedUser.founded(UserHelper.UserId(), new Credentials("test@test.com", "short")); + }); + assertEquals("Password must be greater than 8", exception.getMessage()); + } + + + +} + diff --git a/api/src/test/java/com/github/asavershin/api/domaintest/ImageTest.java b/api/src/test/java/com/github/asavershin/api/domaintest/ImageTest.java new file mode 100644 index 0000000..0790394 --- /dev/null +++ b/api/src/test/java/com/github/asavershin/api/domaintest/ImageTest.java @@ -0,0 +1,93 @@ +package com.github.asavershin.api.domaintest; + +import com.github.asavershin.api.common.ImageHelper; +import com.github.asavershin.api.common.UserHelper; +import com.github.asavershin.api.domain.ResourceOwnershipException; +import com.github.asavershin.api.domain.image.Image; +import com.github.asavershin.api.domain.image.ImageId; +import com.github.asavershin.api.domain.image.MetaData; +import com.github.asavershin.api.domain.user.UserId; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +public class ImageTest { + + @Test + void testAddNewImage() { + //Given + ImageId imageId = ImageHelper.imageId(); + MetaData metaInfo = ImageHelper.metaInfo1(); + UserId userId = UserHelper.UserId(); + + //When + Image image = new Image(imageId, metaInfo, userId); + + //Then + assertEquals(image.imageId(), imageId); + assertEquals(image.metaInfo(), metaInfo); + assertEquals(image.userId(), userId); + } + + @Test + void testFoundedImage() { + //Given + ImageId imageId = ImageHelper.imageId(); + MetaData metaInfo = ImageHelper.metaInfo1(); + UserId userId = UserHelper.UserId(); + // When + Image image = new Image(imageId, metaInfo, userId); + // Then + assertEquals(image.imageId(), imageId); + assertEquals(image.metaInfo(), metaInfo); + assertEquals(image.userId(), userId); + } + + @Test + void testBelongsToUser() { + // Given + ImageId imageId = ImageHelper.imageId(); + MetaData metaInfo = ImageHelper.metaInfo1(); + UserId userId = UserHelper.UserId(); + UserId otherUserId = UserHelper.UserId(); + + // When + Image image = new Image(imageId, metaInfo, userId); + assertDoesNotThrow(() -> image.belongsToUser(userId)); + var exception = assertThrows(ResourceOwnershipException.class, () -> image.belongsToUser(otherUserId)); + + // Then + assertDoesNotThrow(() -> image.belongsToUser(userId)); + assertEquals("Image with id " + imageId.value().toString() + + " does not belong to user with id " + otherUserId.value().toString(), exception.getMessage()); + } + + @Test + void testEquals() { + var imageId = ImageId.nextIdentity(); + var userId = UserId.nextIdentity(); + + MetaData metaInfo1 = ImageHelper.metaInfo1(); + Image image1 = new Image(imageId, metaInfo1, userId); + + MetaData metaInfo2 = ImageHelper.metaInfo1(); + Image image2 = new Image(imageId, metaInfo2, userId); + + assertTrue(image1.equals(image2)); + assertTrue(image2.equals(image1)); + + assertTrue(image1.equals(image1)); + + assertFalse(image1.equals(null)); + + ImageId imageId3 = ImageId.nextIdentity(); + MetaData metaInfo3 = ImageHelper.metaInfo3(); + UserId userId3 = UserHelper.UserId(); + Image image3 = new Image(imageId3, metaInfo3, userId3); + + assertFalse(image1.equals(image3)); + assertFalse(image3.equals(image1)); + } + +} + + diff --git a/api/src/test/java/com/github/asavershin/api/domaintest/UserTest.java b/api/src/test/java/com/github/asavershin/api/domaintest/UserTest.java new file mode 100644 index 0000000..4c02076 --- /dev/null +++ b/api/src/test/java/com/github/asavershin/api/domaintest/UserTest.java @@ -0,0 +1,86 @@ +package com.github.asavershin.api.domaintest; + +import com.github.asavershin.api.common.UserHelper; +import com.github.asavershin.api.domain.user.Credentials; +import com.github.asavershin.api.domain.user.FullName; +import com.github.asavershin.api.domain.user.User; +import com.github.asavershin.api.domain.user.UserId; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class UserTest { + + @Test + public void testCreateNewUserForRegistration() { + UserId userId = UserHelper.UserId(); + FullName fullName = UserHelper.fullName1(); + Credentials credentials = UserHelper.credentials1(); + User user = new User(userId, fullName, credentials); + assertEquals(userId, user.userId()); + assertEquals(fullName, user.userFullName()); + assertEquals(credentials, user.userCredentials()); + } + + @Test + public void testCreateNewUserForRegistrationWithNullUserId() { + NullPointerException exception = assertThrows(NullPointerException.class, () -> { + new User(null, UserHelper.fullName1(), UserHelper.credentials1()); + }); + assertEquals("UserId must not be null", exception.getMessage()); + } + + @Test + public void testCreateNewUserForRegistrationWithNullFullName() { + NullPointerException exception = assertThrows(NullPointerException.class, () -> { + new User(UserHelper.UserId(), null, UserHelper.credentials1()); + }); + assertEquals("UserFullName must not be null", exception.getMessage()); + } + + @Test + public void testCreateNewUserForRegistrationWithNullCredentials() { + NullPointerException exception = assertThrows(NullPointerException.class, () -> { + new User(UserHelper.UserId(), UserHelper.fullName1(), null); + }); + assertEquals("UserCredentials must not be null", exception.getMessage()); + } + + @Test + public void testCreateNewUserForRegistrationWithInvalidFullName() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + new User(UserHelper.UserId(), UserHelper.longFirstName(), UserHelper.credentials1()); + }); + assertEquals("Name must be 0-20 in length", exception.getMessage()); + } + + @Test + void testEquals() { + + var userId = UserHelper.UserId(); + + FullName fullName1 = UserHelper.fullName1(); + Credentials credentials1 = UserHelper.credentials1(); + User user1 = new User(userId, fullName1, credentials1); + + FullName fullName2 = UserHelper.fullName1(); + Credentials credentials2 = UserHelper.credentials1(); + User user2 = new User(userId, fullName2, credentials2); + + UserId userId3 = UserId.nextIdentity(); + FullName fullName3 = UserHelper.fullName2(); + Credentials credentials3 = UserHelper.credentials2(); + User user3 = new User(userId3, fullName3, credentials3); + + assertTrue(user1.equals(user2)); + assertTrue(user2.equals(user1)); + + assertTrue(user1.equals(user1)); + + assertFalse(user1.equals(null)); + + + assertFalse(user1.equals(user3)); + assertFalse(user3.equals(user1)); + } +} diff --git a/api/src/test/java/com/github/asavershin/api/integrations/AbstractTest.java b/api/src/test/java/com/github/asavershin/api/integrations/AbstractTest.java new file mode 100644 index 0000000..42d1621 --- /dev/null +++ b/api/src/test/java/com/github/asavershin/api/integrations/AbstractTest.java @@ -0,0 +1,10 @@ +package com.github.asavershin.api.integrations; + +import com.github.asavershin.api.config.*; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + +@SpringBootTest +@ContextConfiguration(initializers = {PostgreConfig.Initializer.class, MinioConfig.Initializer.class, CacheRedisConfig.Initializer.class}) +public abstract class AbstractTest { +} diff --git a/api/src/test/java/com/github/asavershin/api/integrations/ImageLogicTest.java b/api/src/test/java/com/github/asavershin/api/integrations/ImageLogicTest.java new file mode 100644 index 0000000..48b77a0 --- /dev/null +++ b/api/src/test/java/com/github/asavershin/api/integrations/ImageLogicTest.java @@ -0,0 +1,219 @@ +package com.github.asavershin.api.integrations; + +import asavershin.generated.package_.tables.Image; +import asavershin.generated.package_.tables.Users; +import com.github.asavershin.api.application.in.services.image.ImageService; +import com.github.asavershin.api.application.out.TokenRepository; +import com.github.asavershin.api.common.ImageHelper; +import com.github.asavershin.api.common.UserHelper; +import com.github.asavershin.api.config.properties.MinIOProperties; +import com.github.asavershin.api.domain.PartOfResources; +import com.github.asavershin.api.domain.ResourceOwnershipException; +import com.github.asavershin.api.domain.image.GetPartImagesOfUser; +import com.github.asavershin.api.domain.image.ImageRepository; +import com.github.asavershin.api.domain.user.AuthenticatedUserRepository; +import com.github.asavershin.api.domain.user.RegisterNewUser; +import io.minio.*; +import io.minio.messages.Item; +import lombok.extern.slf4j.Slf4j; +import org.jooq.DSLContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + + +@Slf4j +public class ImageLogicTest extends AbstractTest{ + @Autowired + private DSLContext dslContext; + + @Autowired + private TokenRepository tokenRepository; + + @Autowired + private ImageService imageService; + + @Autowired + private ImageRepository imageRepository; + + @Autowired + private RegisterNewUser registerNewUser; + + @Autowired + private AuthenticatedUserRepository authenticatedUserRepository; + + @Autowired + private GetPartImagesOfUser getPartImagesOfUser; + + @Autowired + private MinioClient minioClient; + + @Autowired + private MinIOProperties minioProperties; + + @BeforeEach + public void clearDB(){ + dslContext.delete(Users.USERS).execute(); + dslContext.delete(Image.IMAGE).execute(); + tokenRepository.deleteAllTokensByUserEmail(UserHelper.credentials1().email()); + tokenRepository.deleteAllTokensByUserEmail(UserHelper.credentials1().email()); + + try { + Iterable> files = minioClient.listObjects(ListObjectsArgs.builder().bucket(minioProperties.getBucket()).build()); + + for (var file : files) { + minioClient.removeObject( + RemoveObjectArgs.builder() + .bucket(minioProperties.getBucket()) + .object(file.get().objectName()) + .build()); + } + } catch (Exception e) { + log.info("Clean bucket fail"); + e.printStackTrace(); + } + } + @Autowired + private ApplicationContext applicationContext; + + @Test + public void storeImageTest(){ + // Given + var fullName = UserHelper.fullName1(); + var credentials = UserHelper.credentials1(); + var multipartFile = ImageHelper.multipartFile1(); + + registerNewUser.register(fullName,credentials); + + var user = authenticatedUserRepository.findByEmail(credentials.email()); + // When + var imageId = imageService.storeImage(user.userId(), ImageHelper.multipartFile1()); + log.info("asdasd"); + log.info(applicationContext.getBean(ImageService.class, "foo").getClass().toString()); + + // Then + var image = imageRepository.findImageByImageId(imageId); + assertNotNull(image); + assertEquals(user.userId(), image.userId()); + assertEquals(multipartFile.getSize(), image.metaInfo().imageSize()); + assertEquals(multipartFile.getOriginalFilename(), + image.metaInfo().imageNameWithExtension().toString()); + assertEquals(".jpg", image.metaInfo().imageNameWithExtension().imageExtension().toString()); + + var objectsInMinio = minioClient.listObjects(ListObjectsArgs.builder().bucket("files").build()); + AtomicInteger countObjectsInMinio = new AtomicInteger(); + objectsInMinio.forEach( it -> countObjectsInMinio.addAndGet(1)); + assertEquals(1, countObjectsInMinio.get()); + } + + @Test + public void storeImageWithIllegalExtensionTest(){ + // Given + var fullName = UserHelper.fullName1(); + var credentials = UserHelper.credentials1(); + var multipartFile = ImageHelper.multipartFile1(); + + registerNewUser.register(fullName,credentials); + + var user = authenticatedUserRepository.findByEmail(credentials.email()); + assertNotNull(user); + // When + var ex = assertThrows(IllegalArgumentException.class, () -> imageService.storeImage(user.userId(), ImageHelper.multipartFileWithIllegalException())); + + // Then + assertEquals(ex.getMessage(), "Invalid extension: " + ImageHelper.illegalExtension); + } + + @Test + public void getPartImagesOfUserTest(){ + //Given + var fullName = UserHelper.fullName1(); + var credentials = UserHelper.credentials1(); + var multipartFile = ImageHelper.multipartFile1(); + + registerNewUser.register(fullName,credentials); + + var user = authenticatedUserRepository.findByEmail(credentials.email()); + + var imageId1 = imageService.storeImage(user.userId(), ImageHelper.multipartFile1()); + var imageId2 = imageService.storeImage(user.userId(), ImageHelper.multipartFile1()); + + // When + + var images = getPartImagesOfUser.get(user.userId(), new PartOfResources(1L,1L)); + + // Then + + assertEquals(images.size(), 1); + } + + @Test + public void deleteImageByImageIdTest(){ + // Given + var fullName = UserHelper.fullName1(); + var credentials = UserHelper.credentials1(); + var multipartFile = ImageHelper.multipartFile1(); + + registerNewUser.register(fullName,credentials); + + var user = authenticatedUserRepository.findByEmail(credentials.email()); + + var imageId1 = imageService.storeImage(user.userId(), ImageHelper.multipartFile1()); + var imageId2 = imageService.storeImage(user.userId(), ImageHelper.multipartFile1()); + + // When + imageService.deleteImageByImageId(user.userId(), imageId1); + + // Then + var images = imageRepository.findImagesByUserId(user.userId(), new PartOfResources(0L, 2L)); + assertEquals(images.size(), 1); + assertEquals(images.get(0).imageId(), imageId2); + } + + @Test + public void deleteImageByImageIdAnotherUser(){ + // Given + var fullName = UserHelper.fullName1(); + var credentials = UserHelper.credentials1(); + var multipartFile = ImageHelper.multipartFile1(); + var userId = UserHelper.UserId(); + + registerNewUser.register(fullName,credentials); + + var user = authenticatedUserRepository.findByEmail(credentials.email()); + + var imageId1 = imageService.storeImage(user.userId(), multipartFile); + + // When + var ex = assertThrows(ResourceOwnershipException.class, () -> imageService.deleteImageByImageId(userId, imageId1)); + assertEquals("Image with id " + imageId1.value().toString() + + " does not belong to user with id " + userId.value().toString(), ex.getMessage()); + } + + @Test + public void downloadImageTest() throws IOException { + // Given + var fullName = UserHelper.fullName1(); + var credentials = UserHelper.credentials1(); + var multipartFile = ImageHelper.multipartFile1(); + var userId = UserHelper.UserId(); + + registerNewUser.register(fullName,credentials); + + var user = authenticatedUserRepository.findByEmail(credentials.email()); + + var imageId1 = imageService.storeImage(user.userId(), multipartFile); + + // When + var image = imageService.downloadImage(imageId1, user.userId()); + + // Then + assertArrayEquals(multipartFile.getBytes(), image); + } +} diff --git a/api/src/test/java/com/github/asavershin/api/integrations/UserLogicTest.java b/api/src/test/java/com/github/asavershin/api/integrations/UserLogicTest.java new file mode 100644 index 0000000..734af26 --- /dev/null +++ b/api/src/test/java/com/github/asavershin/api/integrations/UserLogicTest.java @@ -0,0 +1,157 @@ +package com.github.asavershin.api.integrations; + +import asavershin.generated.package_.tables.Image; +import asavershin.generated.package_.tables.Users; +import com.github.asavershin.api.application.in.services.user.GetNewCredentials; +import com.github.asavershin.api.application.in.services.user.JwtService; +import com.github.asavershin.api.application.in.services.user.impl.GetNewCredentialsUsingRefreshTokenImpl; +import com.github.asavershin.api.application.out.TokenRepository; +import com.github.asavershin.api.common.NotFoundException; +import com.github.asavershin.api.common.TestUserRepository; +import com.github.asavershin.api.common.UserHelper; +import com.github.asavershin.api.domain.user.*; +import lombok.extern.slf4j.Slf4j; +import org.jooq.DSLContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static org.hibernate.validator.internal.util.Contracts.assertNotNull; +import static org.junit.jupiter.api.Assertions.*; + +@Slf4j +public class UserLogicTest extends AbstractTest { + @Autowired + private DSLContext dslContext; + + @Autowired + private RegisterNewUser registerNewUser; + + @Autowired + private TestUserRepository testUserRepository; + + @Autowired + private AuthenticatedUserRepository authenticatedUserRepository; + + @Autowired + private GetNewCredentials getNewCredentials; + + @Autowired + private JwtService jwtService; + + @Autowired + private TokenRepository tokenRepository; + + @Autowired + private GetNewCredentialsUsingRefreshTokenImpl getNewCredentialsUsingRefreshToken; + + + + @BeforeEach + public void clearDB(){ + dslContext.delete(Users.USERS).execute(); + dslContext.delete(Image.IMAGE).execute(); + tokenRepository.deleteAllTokensByUserEmail(UserHelper.credentials1().email()); + tokenRepository.deleteAllTokensByUserEmail(UserHelper.credentials1().email()); + } + + @Test + public void testRegisterNewUser(){ + // Given + var fullName = UserHelper.fullName1(); + var credentials = UserHelper.credentials1(); + var fullName2 = UserHelper.fullName1(); + var credentials2 = UserHelper.credentials2(); + + // When + registerNewUser.register(fullName, credentials); + registerNewUser.register(fullName2, credentials2); + + + // Then + assertEquals(2, testUserRepository.countUsers()); + assertNotNull(authenticatedUserRepository.findByEmail(credentials.email())); + assertNotNull(authenticatedUserRepository.findByEmail(credentials2.email())); + + } + + @Test + public void testRegisterNewUserWithoutUniqueEmail(){ + // Given + var fullName = UserHelper.fullName1(); + var credentials = UserHelper.credentials1(); + + // When + registerNewUser.register(fullName, credentials); + + // Then + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> { + registerNewUser.register(UserHelper.fullName1(), UserHelper.credentials1()); + }); + + assertEquals("Email is not unique", exception.getMessage()); + } + + @Test + public void testGetNewCredentials(){ + // Given + var fullName = UserHelper.fullName1(); + var credentials = UserHelper.credentials1(); + + // When + registerNewUser.register(fullName, credentials); + var newCredentials = getNewCredentials.get(credentials); + assertDoesNotThrow(() -> jwtService.extractSub(newCredentials.getAccessToken())); + assertDoesNotThrow(() -> jwtService.extractSub(newCredentials.getRefreshToken())); + + var access = jwtService.extractSub(newCredentials.getAccessToken()); + var refresh = jwtService.extractSub(newCredentials.getRefreshToken()); + + assertDoesNotThrow(() -> tokenRepository.getAccessToken(access)); + assertDoesNotThrow(() -> tokenRepository.getRefreshToken(refresh)); + + var accessFromRedis = tokenRepository.getAccessToken(access); + var refreshFromRedis = tokenRepository.getRefreshToken(refresh); + + // Then + assertNotNull(access); + assertNotNull(refresh); + assertEquals(credentials.email(), access); + assertEquals(credentials.email(), refresh); + + assertNotNull(accessFromRedis); + assertNotNull(refreshFromRedis); + + assertEquals(newCredentials.getAccessToken(), accessFromRedis); + assertEquals(newCredentials.getRefreshToken(), refreshFromRedis); + } + + @Test + public void testGetNewCredentialsForNotRegisteredUser(){ + // When + registerNewUser.register(UserHelper.fullName1(), UserHelper.credentials1()); + var ex = assertThrows(NotFoundException.class, () -> getNewCredentials.get(UserHelper.credentials2())); + + // Then + assertEquals(ex.getMessage(), + "User" + " with " + "email" + UserHelper.credentials2().email() + " not found"); + } + + @Test + public void getNewCredentialsUsingRefreshTokenTest() throws InterruptedException { + // Given + var fullName = UserHelper.fullName1(); + var credentials = UserHelper.credentials1(); + registerNewUser.register(fullName, credentials); + var newCredentials = getNewCredentials.get(credentials); + + // When + Thread.sleep(2000L); + var refreshedCredentials = getNewCredentialsUsingRefreshToken.get(credentials); + + // Then + assertNotNull(refreshedCredentials); + assertNotEquals(newCredentials.getAccessToken(), refreshedCredentials.getAccessToken()); + assertNotEquals(newCredentials.getRefreshToken(), refreshedCredentials.getRefreshToken()); + } +} diff --git a/api/src/test/resources/application.properties b/api/src/test/resources/application.properties new file mode 100644 index 0000000..28c3f00 --- /dev/null +++ b/api/src/test/resources/application.properties @@ -0,0 +1,29 @@ +spring.profiles.active=test +spring.datasource.url=jdbc:postgresql://db-api:5432/$postgres?currentSchema=public +spring.datasource.username=postgres +spring.datasource.password=postgres +spring.liquibase.enabled=true +spring.liquibase.drop-first=false +spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.yaml +spring.liquibase.default-schema=public + +spring.data.redis.host=redis-api +spring.data.redis.port=6379 +spring.cache.type=redis +spring.cache.cache-names=redis-cache +spring.cache.redis.time-to-live=86400000 + +jwt.access-expiration=86400000 +jwt.refresh-expiration=604800000 +jwt.secret=404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970 + +minio.url=http://minio-api:9000 +minio.user=minioadmin +minio.password=minioadmin +minio.bucket=files +minio.console-port=9090 +minio.port=9000 + +testconf=test + + diff --git a/api/src/test/resources/changelog/db.changelog-master.yaml b/api/src/test/resources/changelog/db.changelog-master.yaml new file mode 100644 index 0000000..fc3e65d --- /dev/null +++ b/api/src/test/resources/changelog/db.changelog-master.yaml @@ -0,0 +1,4 @@ +databaseChangeLog: + - includeAll: + path: /migrations + relativeToChangelogFile: true \ No newline at end of file diff --git a/api/src/test/resources/changelog/migrations/1-create-user.sql b/api/src/test/resources/changelog/migrations/1-create-user.sql new file mode 100644 index 0000000..64239a0 --- /dev/null +++ b/api/src/test/resources/changelog/migrations/1-create-user.sql @@ -0,0 +1,14 @@ +-- liquibase formatted sql + +-- changeset asavershin:createUser +CREATE TABLE users +( + user_id UUID PRIMARY KEY, + user_firstname varchar(20) NOT NULL, + user_lastname varchar(20) NOT NULL, + user_email varchar(50) UNIQUE NOT NULL, + user_password TEXT not null +); + +CREATE INDEX idx_user_id ON users(user_id); +CREATE INDEX idx_user_email ON users(user_email); \ No newline at end of file diff --git a/api/src/test/resources/changelog/migrations/2-create-image.sql b/api/src/test/resources/changelog/migrations/2-create-image.sql new file mode 100644 index 0000000..2667cf5 --- /dev/null +++ b/api/src/test/resources/changelog/migrations/2-create-image.sql @@ -0,0 +1,23 @@ +-- liquibase formatted sql + +-- changeset asavershin:createImage +CREATE TABLE image +( + image_id UUID PRIMARY KEY, + image_name varchar(50) NOT NULL, + image_size bigint not null +); + +CREATE INDEX idx_image_id ON image(image_id); + +CREATE TABLE user_images +( + image_id UUID, + user_id UUID, + CONSTRAINT fk_user_images_user FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE, + CONSTRAINT fk_users_image_image FOREIGN KEY (image_id) REFERENCES image (image_id) ON DELETE CASCADE +); + +CREATE INDEX idx_users_image_user_id ON user_images (user_id); +CREATE INDEX idx_users_image_image_id ON user_images (image_id); + diff --git a/api/src/test/resources/logback.xml b/api/src/test/resources/logback.xml new file mode 100644 index 0000000..fa47f73 --- /dev/null +++ b/api/src/test/resources/logback.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} -%kvp- %msg%n + + + + + + + + + + + diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..66df285 --- /dev/null +++ b/mvnw @@ -0,0 +1,308 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.2.0 +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /usr/local/etc/mavenrc ] ; then + . /usr/local/etc/mavenrc + fi + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "$(uname)" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + JAVA_HOME="$(/usr/libexec/java_home)"; export JAVA_HOME + else + JAVA_HOME="/Library/Java/Home"; export JAVA_HOME + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=$(java-config --jre-home) + fi +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$JAVA_HOME" ] && + JAVA_HOME=$(cygpath --unix "$JAVA_HOME") + [ -n "$CLASSPATH" ] && + CLASSPATH=$(cygpath --path --unix "$CLASSPATH") +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] && + JAVA_HOME="$(cd "$JAVA_HOME" || (echo "cannot cd into $JAVA_HOME."; exit 1); pwd)" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="$(which javac)" + if [ -n "$javaExecutable" ] && ! [ "$(expr "\"$javaExecutable\"" : '\([^ ]*\)')" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=$(which readlink) + if [ ! "$(expr "$readLink" : '\([^ ]*\)')" = "no" ]; then + if $darwin ; then + javaHome="$(dirname "\"$javaExecutable\"")" + javaExecutable="$(cd "\"$javaHome\"" && pwd -P)/javac" + else + javaExecutable="$(readlink -f "\"$javaExecutable\"")" + fi + javaHome="$(dirname "\"$javaExecutable\"")" + javaHome=$(expr "$javaHome" : '\(.*\)/bin') + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="$(\unset -f command 2>/dev/null; \command -v java)" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=$(cd "$wdir/.." || exit 1; pwd) + fi + # end of workaround + done + printf '%s' "$(cd "$basedir" || exit 1; pwd)" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + # Remove \r in case we run on Windows within Git Bash + # and check out the repository with auto CRLF management + # enabled. Otherwise, we may read lines that are delimited with + # \r\n and produce $'-Xarg\r' rather than -Xarg due to word + # splitting rules. + tr -s '\r\n' ' ' < "$1" + fi +} + +log() { + if [ "$MVNW_VERBOSE" = true ]; then + printf '%s\n' "$1" + fi +} + +BASE_DIR=$(find_maven_basedir "$(dirname "$0")") +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}; export MAVEN_PROJECTBASEDIR +log "$MAVEN_PROJECTBASEDIR" + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +wrapperJarPath="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" +if [ -r "$wrapperJarPath" ]; then + log "Found $wrapperJarPath" +else + log "Couldn't find $wrapperJarPath, downloading it ..." + + if [ -n "$MVNW_REPOURL" ]; then + wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + else + wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + fi + while IFS="=" read -r key value; do + # Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' ) + safeValue=$(echo "$value" | tr -d '\r') + case "$key" in (wrapperUrl) wrapperUrl="$safeValue"; break ;; + esac + done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" + log "Downloading from: $wrapperUrl" + + if $cygwin; then + wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath") + fi + + if command -v wget > /dev/null; then + log "Found wget ... using wget" + [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet" + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + else + wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + log "Found curl ... using curl" + [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent" + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" + else + curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" + fi + else + log "Falling back to using Java to download" + javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java" + javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaSource=$(cygpath --path --windows "$javaSource") + javaClass=$(cygpath --path --windows "$javaClass") + fi + if [ -e "$javaSource" ]; then + if [ ! -e "$javaClass" ]; then + log " - Compiling MavenWrapperDownloader.java ..." + ("$JAVA_HOME/bin/javac" "$javaSource") + fi + if [ -e "$javaClass" ]; then + log " - Running MavenWrapperDownloader.java ..." + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath" + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +# If specified, validate the SHA-256 sum of the Maven wrapper jar file +wrapperSha256Sum="" +while IFS="=" read -r key value; do + case "$key" in (wrapperSha256Sum) wrapperSha256Sum=$value; break ;; + esac +done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" +if [ -n "$wrapperSha256Sum" ]; then + wrapperSha256Result=false + if command -v sha256sum > /dev/null; then + if echo "$wrapperSha256Sum $wrapperJarPath" | sha256sum -c > /dev/null 2>&1; then + wrapperSha256Result=true + fi + elif command -v shasum > /dev/null; then + if echo "$wrapperSha256Sum $wrapperJarPath" | shasum -a 256 -c > /dev/null 2>&1; then + wrapperSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." + echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties." + exit 1 + fi + if [ $wrapperSha256Result = false ]; then + echo "Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised." >&2 + echo "Investigate or delete $wrapperJarPath to attempt a clean download." >&2 + echo "If you updated your Maven version, you need to update the specified wrapperSha256Sum property." >&2 + exit 1 + fi +fi + +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$JAVA_HOME" ] && + JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME") + [ -n "$CLASSPATH" ] && + CLASSPATH=$(cygpath --path --windows "$CLASSPATH") + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR") +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $*" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +# shellcheck disable=SC2086 # safe args +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..95ba6f5 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,205 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM https://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.2.0 +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* +if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %WRAPPER_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM If specified, validate the SHA-256 sum of the Maven wrapper jar file +SET WRAPPER_SHA_256_SUM="" +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B +) +IF NOT %WRAPPER_SHA_256_SUM%=="" ( + powershell -Command "&{"^ + "$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^ + "If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^ + " Write-Output 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^ + " Write-Output 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^ + " Write-Output 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^ + " exit 1;"^ + "}"^ + "}" + if ERRORLEVEL 1 goto error +) + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% ^ + %JVM_CONFIG_MAVEN_PROPS% ^ + %MAVEN_OPTS% ^ + %MAVEN_DEBUG_OPTS% ^ + -classpath %WRAPPER_JAR% ^ + "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ + %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" +if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%"=="on" pause + +if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% + +cmd /C exit /B %ERROR_CODE% diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..8a09635 --- /dev/null +++ b/pom.xml @@ -0,0 +1,60 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.2.4 + + + com.github.asavershin.images + images + 0.0.1-SNAPSHOT + pom + images + + + api + + + + 21 + 21 + UTF-8 + 1.18.30 + 3.3.1 + + + + + + org.springframework.boot + spring-boot-dependencies + 3.2.0 + import + pom + + + + + + + org.projectlombok + lombok + ${org.projectlombok.version} + true + + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + ${maven-checkstyle-plugin.version} + + + + +