From aa63fc66b8680aafa2688d7898ffa2fa3ba75022 Mon Sep 17 00:00:00 2001 From: Rodrigo Verdiani Date: Tue, 21 Oct 2025 13:19:28 -0300 Subject: [PATCH] feat: initial commit --- .env | 9 + .gitattributes | 2 + .gitignore | 250 +++++++++++++++ HELP.md | 27 ++ docker-compose.yml | 58 ++++ mvnw | 295 ++++++++++++++++++ mvnw.cmd | 189 +++++++++++ pom.xml | 165 ++++++++++ .../mangamochi/MangamochiApplication.java | 16 + .../mangamochi/client/JikanClient.java | 17 + .../mangamochi/client/RapidFuzzClient.java | 13 + .../mangamochi/client/WebScrapperClient.java | 16 + .../mangamochi/config/S3ClientConfig.java | 37 +++ .../mangamochi/config/SecurityConfig.java | 91 ++++++ .../mangamochi/config/WebConfig.java | 14 + .../controller/AuthenticationController.java | 64 ++++ .../controller/GenreController.java | 25 ++ .../controller/MangaController.java | 140 +++++++++ .../model/dto/AuthenticationRequestDTO.java | 3 + .../model/dto/AuthenticationResponseDTO.java | 3 + ...ontentProviderMangaChapterResponseDTO.java | 6 + .../ContentProviderMangaInfoResponseDTO.java | 7 + .../mangamochi/model/dto/GenreDTO.java | 11 + .../model/dto/JikanMangaResponseDTO.java | 28 ++ .../dto/JikanMangaSearchResponseDTO.java | 9 + .../model/dto/MangaChapterArchiveDTO.java | 6 + .../mangamochi/model/dto/MangaChapterDTO.java | 19 ++ .../model/dto/MangaChapterImagesDTO.java | 23 ++ .../model/dto/MangaChapterResponseDTO.java | 10 + .../mangamochi/model/dto/MangaDTO.java | 63 ++++ .../mangamochi/model/dto/MangaListDTO.java | 37 +++ .../mangamochi/model/dto/MangaMessageDTO.java | 6 + .../model/dto/RapidFuzzRequestDTO.java | 5 + .../model/dto/RapidFuzzResponseDTO.java | 3 + .../dto/WebScrapperClientRequestDTO.java | 3 + .../dto/WebScrapperClientResponseDTO.java | 3 + .../mangamochi/model/entity/Author.java | 32 ++ .../mangamochi/model/entity/Genre.java | 25 ++ .../mangamochi/model/entity/Image.java | 27 ++ .../mangamochi/model/entity/Manga.java | 60 ++++ .../mangamochi/model/entity/MangaAuthor.java | 25 ++ .../mangamochi/model/entity/MangaChapter.java | 40 +++ .../model/entity/MangaChapterImage.java | 34 ++ .../mangamochi/model/entity/MangaGenre.java | 25 ++ .../model/entity/MangaImportReview.java | 29 ++ .../model/entity/MangaProvider.java | 40 +++ .../mangamochi/model/entity/Provider.java | 34 ++ .../mangamochi/model/entity/User.java | 26 ++ .../model/enumeration/ArchiveFileType.java | 6 + .../model/enumeration/MangaStatus.java | 9 + .../model/enumeration/ProviderStatus.java | 6 + .../model/repository/AuthorRepository.java | 9 + .../model/repository/GenreRepository.java | 9 + .../model/repository/ImageRepository.java | 7 + .../repository/MangaAuthorRepository.java | 11 + .../MangaChapterImageRepository.java | 10 + .../repository/MangaChapterRepository.java | 15 + .../repository/MangaGenreRepository.java | 9 + .../MangaImportReviewRepository.java | 6 + .../repository/MangaProviderRepository.java | 14 + .../model/repository/MangaRepository.java | 13 + .../model/repository/ProviderRepository.java | 9 + .../model/repository/UserRepository.java | 11 + .../specification/MangaSpecification.java | 61 ++++ .../mangamochi/security/JwtRequestFilter.java | 57 ++++ .../service/CustomUserDetailsService.java | 31 ++ .../mangamochi/service/GenreService.java | 19 ++ .../mangamochi/service/ImageService.java | 24 ++ .../mangamochi/service/MangaListService.java | 142 +++++++++ .../mangamochi/service/MangaService.java | 239 ++++++++++++++ .../mangamochi/service/S3Service.java | 37 +++ .../WebScrapperClientProxyService.java | 24 ++ .../service/providers/ContentProvider.java | 15 + .../providers/ContentProviderFactory.java | 24 ++ .../service/providers/ContentProviders.java | 6 + .../impl/MangaLivreBlogProvider.java | 170 ++++++++++ .../providers/impl/MangaLivreProvider.java | 152 +++++++++ .../mangamochi/task/UpdateMangaDataTask.java | 142 +++++++++ .../mangamochi/task/UpdateMangaListTask.java | 33 ++ .../magamochi/mangamochi/util/JwtUtil.java | 68 ++++ src/main/resources/application-local.yml | 3 + src/main/resources/application.yml | 34 ++ .../db/migration/V0001__IMAGES_TABLE.sql | 7 + .../resources/db/migration/V0002__MANGA.sql | 11 + .../db/migration/V0003__PROVIDER.sql | 21 ++ .../resources/db/migration/V0004__CHAPTER.sql | 20 ++ .../migration/V0005__MANGA_IMPORT_REVIEW.sql | 8 + .../db/migration/V0006__MANGA_DATA.sql | 5 + .../db/migration/V0007__MANGA_DATA.sql | 31 ++ .../db/migration/V0008__MANGA_CHAPTERS.sql | 1 + .../db/migration/V0009__MANGA_CHAPTERS.sql | 1 + .../resources/db/migration/V0010__USERS.sql | 6 + .../MangamochiApplicationTests.java | 22 ++ 93 files changed, 3628 insertions(+) create mode 100644 .env create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 HELP.md create mode 100644 docker-compose.yml create mode 100755 mvnw create mode 100644 mvnw.cmd create mode 100644 pom.xml create mode 100644 src/main/java/com/magamochi/mangamochi/MangamochiApplication.java create mode 100644 src/main/java/com/magamochi/mangamochi/client/JikanClient.java create mode 100644 src/main/java/com/magamochi/mangamochi/client/RapidFuzzClient.java create mode 100644 src/main/java/com/magamochi/mangamochi/client/WebScrapperClient.java create mode 100644 src/main/java/com/magamochi/mangamochi/config/S3ClientConfig.java create mode 100644 src/main/java/com/magamochi/mangamochi/config/SecurityConfig.java create mode 100644 src/main/java/com/magamochi/mangamochi/config/WebConfig.java create mode 100644 src/main/java/com/magamochi/mangamochi/controller/AuthenticationController.java create mode 100644 src/main/java/com/magamochi/mangamochi/controller/GenreController.java create mode 100644 src/main/java/com/magamochi/mangamochi/controller/MangaController.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/dto/AuthenticationRequestDTO.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/dto/AuthenticationResponseDTO.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/dto/ContentProviderMangaChapterResponseDTO.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/dto/ContentProviderMangaInfoResponseDTO.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/dto/GenreDTO.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/dto/JikanMangaResponseDTO.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/dto/JikanMangaSearchResponseDTO.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/dto/MangaChapterArchiveDTO.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/dto/MangaChapterDTO.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/dto/MangaChapterImagesDTO.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/dto/MangaChapterResponseDTO.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/dto/MangaDTO.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/dto/MangaListDTO.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/dto/MangaMessageDTO.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/dto/RapidFuzzRequestDTO.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/dto/RapidFuzzResponseDTO.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/dto/WebScrapperClientRequestDTO.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/dto/WebScrapperClientResponseDTO.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/entity/Author.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/entity/Genre.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/entity/Image.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/entity/Manga.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/entity/MangaAuthor.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/entity/MangaChapter.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/entity/MangaChapterImage.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/entity/MangaGenre.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/entity/MangaImportReview.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/entity/MangaProvider.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/entity/Provider.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/entity/User.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/enumeration/ArchiveFileType.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/enumeration/MangaStatus.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/enumeration/ProviderStatus.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/repository/AuthorRepository.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/repository/GenreRepository.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/repository/ImageRepository.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/repository/MangaAuthorRepository.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/repository/MangaChapterImageRepository.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/repository/MangaChapterRepository.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/repository/MangaGenreRepository.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/repository/MangaImportReviewRepository.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/repository/MangaProviderRepository.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/repository/MangaRepository.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/repository/ProviderRepository.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/repository/UserRepository.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/specification/MangaSpecification.java create mode 100644 src/main/java/com/magamochi/mangamochi/security/JwtRequestFilter.java create mode 100644 src/main/java/com/magamochi/mangamochi/service/CustomUserDetailsService.java create mode 100644 src/main/java/com/magamochi/mangamochi/service/GenreService.java create mode 100644 src/main/java/com/magamochi/mangamochi/service/ImageService.java create mode 100644 src/main/java/com/magamochi/mangamochi/service/MangaListService.java create mode 100644 src/main/java/com/magamochi/mangamochi/service/MangaService.java create mode 100644 src/main/java/com/magamochi/mangamochi/service/S3Service.java create mode 100644 src/main/java/com/magamochi/mangamochi/service/WebScrapperClientProxyService.java create mode 100644 src/main/java/com/magamochi/mangamochi/service/providers/ContentProvider.java create mode 100644 src/main/java/com/magamochi/mangamochi/service/providers/ContentProviderFactory.java create mode 100644 src/main/java/com/magamochi/mangamochi/service/providers/ContentProviders.java create mode 100644 src/main/java/com/magamochi/mangamochi/service/providers/impl/MangaLivreBlogProvider.java create mode 100644 src/main/java/com/magamochi/mangamochi/service/providers/impl/MangaLivreProvider.java create mode 100644 src/main/java/com/magamochi/mangamochi/task/UpdateMangaDataTask.java create mode 100644 src/main/java/com/magamochi/mangamochi/task/UpdateMangaListTask.java create mode 100644 src/main/java/com/magamochi/mangamochi/util/JwtUtil.java create mode 100644 src/main/resources/application-local.yml create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/db/migration/V0001__IMAGES_TABLE.sql create mode 100644 src/main/resources/db/migration/V0002__MANGA.sql create mode 100644 src/main/resources/db/migration/V0003__PROVIDER.sql create mode 100644 src/main/resources/db/migration/V0004__CHAPTER.sql create mode 100644 src/main/resources/db/migration/V0005__MANGA_IMPORT_REVIEW.sql create mode 100644 src/main/resources/db/migration/V0006__MANGA_DATA.sql create mode 100644 src/main/resources/db/migration/V0007__MANGA_DATA.sql create mode 100644 src/main/resources/db/migration/V0008__MANGA_CHAPTERS.sql create mode 100644 src/main/resources/db/migration/V0009__MANGA_CHAPTERS.sql create mode 100644 src/main/resources/db/migration/V0010__USERS.sql create mode 100644 src/test/java/com/magamochi/mangamochi/MangamochiApplicationTests.java diff --git a/.env b/.env new file mode 100644 index 0000000..db588fb --- /dev/null +++ b/.env @@ -0,0 +1,9 @@ +DB_URL=jdbc:postgresql://localhost:5432/mangamochi?currentSchema=mangamochi +DB_USER=mangamochi +DB_PASS=mangamochi + +MINIO_ENDPOINT=http://omv.badger-pirarucu.ts.net:9000 +MINIO_USER=admin +MINIO_PASS=!E9v4i0v3 + +WEBSCRAPPER_ENDPOINT=http://localhost:8000/url \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3b41682 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f844340 --- /dev/null +++ b/.gitignore @@ -0,0 +1,250 @@ +# Created by https://www.toptal.com/developers/gitignore/api/linux,intellij,jetbrains,java,react +# Edit at https://www.toptal.com/developers/gitignore?templates=linux,intellij,jetbrains,java,react + +target/ + +### Intellij ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +### Java ### +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +### JetBrains ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff + +# AWS User-specific + +# Generated files + +# Sensitive or high-churn files + +# Gradle + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake + +# Mongo Explorer plugin + +# File-based project format + +# IntelliJ + +# mpeltonen/sbt-idea plugin + +# JIRA plugin + +# Cursive Clojure plugin + +# SonarLint plugin + +# Crashlytics plugin (for Android Studio and IntelliJ) + +# Editor-based Rest Client + +# Android studio 3.1+ serialized cache file + +### JetBrains Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### react ### +.DS_* +logs +**/*.backup.* +**/*.back.* + +node_modules +bower_components + +*.sublime* + +psd +thumb +sketch + +# End of https://www.toptal.com/developers/gitignore/api/linux,intellij,jetbrains,java,react diff --git a/HELP.md b/HELP.md new file mode 100644 index 0000000..1d74995 --- /dev/null +++ b/HELP.md @@ -0,0 +1,27 @@ +# Getting Started + +### Reference Documentation +For further reference, please consider the following sections: + +* [Official Apache Maven documentation](https://maven.apache.org/guides/index.html) +* [Spring Boot Maven Plugin Reference Guide](https://docs.spring.io/spring-boot/3.5.6/maven-plugin) +* [Create an OCI image](https://docs.spring.io/spring-boot/3.5.6/maven-plugin/build-image.html) +* [Spring Data JPA](https://docs.spring.io/spring-boot/3.5.6/reference/data/sql.html#data.sql.jpa-and-spring-data) +* [Spring Web](https://docs.spring.io/spring-boot/3.5.6/reference/web/servlet.html) +* [Flyway Migration](https://docs.spring.io/spring-boot/3.5.6/how-to/data-initialization.html#howto.data-initialization.migration-tool.flyway) + +### Guides +The following guides illustrate how to use some features concretely: + +* [Accessing Data with JPA](https://spring.io/guides/gs/accessing-data-jpa/) +* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/) +* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/) +* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/) + +### Maven Parent overrides + +Due to Maven's design, elements are inherited from the parent POM to the project POM. +While most of the inheritance is fine, it also inherits unwanted elements like `` and `` from the parent. +To prevent this, the project POM contains empty overrides for these elements. +If you manually switch to a different parent and actually want the inheritance, you need to remove those overrides. + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c98a5e5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,58 @@ +services: + mangamochi: + container_name: mangamochi + build: + context: . + dockerfile: Dockerfile + image: mangamochi:latest + depends_on: + - db + profiles: + - all + ports: + - "8080:8080" + networks: + - mangamochi-network + environment: + - DB_URL=jdbc:postgresql://db:5432/mangamochi?currentSchema=mangamochi + - DB_USER=mangamochi + - DB_PASS=mangamochi + - MINIO_ENDPOINT=http://omv.badger-pirarucu.ts.net:9000 + - MINIO_USER=admin + - MINIO_PASS=!E9v4i0v3 + + db: + image: 'postgres:15' + container_name: mangamochi_db + environment: + - POSTGRES_USER=mangamochi + - POSTGRES_PASSWORD=mangamochi + profiles: + - all + - minimal + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + networks: + - mangamochi-network + + rabbit-mq: + image: 'rabbitmq:4-management' + container_name: mangamochi_rabbit-mq + profiles: + - all + - minimal + ports: + - 5672:5672 + - 15672:15672 + - 15692:15692 + networks: + - mangamochi-network + +networks: + mangamochi-network: + driver: bridge + +volumes: + pgdata: diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/mvnw @@ -0,0 +1,295 @@ +#!/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 +# +# http://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.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + 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" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@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 http://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.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..be89dd2 --- /dev/null +++ b/pom.xml @@ -0,0 +1,165 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.5.6 + + + com.magamochi + mangamochi + 0.0.1-SNAPSHOT + mangamochi + Demo project for Spring Boot + + + + + + + + + + + + + + + 21 + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-web + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + + + + org.postgresql + postgresql + runtime + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + software.amazon.awssdk + s3 + 2.34.5 + compile + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.13 + + + org.springframework.cloud + spring-cloud-starter-openfeign + 4.3.0 + + + org.jsoup + jsoup + 1.21.2 + + + io.hypersistence + hypersistence-utils-hibernate-63 + 3.11.0 + + + + + com.google.guava + guava + 33.5.0-jre + + + + org.springframework.boot + spring-boot-starter-security + + + + + io.jsonwebtoken + jjwt-api + 0.13.0 + + + + io.jsonwebtoken + jjwt-impl + 0.13.0 + runtime + + + + io.jsonwebtoken + jjwt-jackson + 0.13.0 + runtime + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + com.diffplug.spotless + spotless-maven-plugin + 2.46.1 + + + + + + + + + + diff --git a/src/main/java/com/magamochi/mangamochi/MangamochiApplication.java b/src/main/java/com/magamochi/mangamochi/MangamochiApplication.java new file mode 100644 index 0000000..b1a409a --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/MangamochiApplication.java @@ -0,0 +1,16 @@ +package com.magamochi.mangamochi; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableFeignClients +@EnableScheduling +public class MangamochiApplication { + + public static void main(String[] args) { + SpringApplication.run(MangamochiApplication.class, args); + } +} diff --git a/src/main/java/com/magamochi/mangamochi/client/JikanClient.java b/src/main/java/com/magamochi/mangamochi/client/JikanClient.java new file mode 100644 index 0000000..73f23a3 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/client/JikanClient.java @@ -0,0 +1,17 @@ +package com.magamochi.mangamochi.client; + +import com.magamochi.mangamochi.model.dto.JikanMangaResponseDTO; +import com.magamochi.mangamochi.model.dto.JikanMangaSearchResponseDTO; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +@FeignClient(name = "jikan", url = "https://api.jikan.moe/v4/manga") +public interface JikanClient { + @GetMapping + JikanMangaSearchResponseDTO mangaSearch(@RequestParam String q); + + @GetMapping("/{id}") + JikanMangaResponseDTO getMangaById(@PathVariable Long id); +} diff --git a/src/main/java/com/magamochi/mangamochi/client/RapidFuzzClient.java b/src/main/java/com/magamochi/mangamochi/client/RapidFuzzClient.java new file mode 100644 index 0000000..8546e7e --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/client/RapidFuzzClient.java @@ -0,0 +1,13 @@ +package com.magamochi.mangamochi.client; + +import com.magamochi.mangamochi.model.dto.RapidFuzzRequestDTO; +import com.magamochi.mangamochi.model.dto.RapidFuzzResponseDTO; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@FeignClient(name = "rapidFuzz", url = "http://127.0.0.1:9000/match-title") +public interface RapidFuzzClient { + @PostMapping + RapidFuzzResponseDTO mangaSearch(@RequestBody RapidFuzzRequestDTO dto); +} diff --git a/src/main/java/com/magamochi/mangamochi/client/WebScrapperClient.java b/src/main/java/com/magamochi/mangamochi/client/WebScrapperClient.java new file mode 100644 index 0000000..a821ef4 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/client/WebScrapperClient.java @@ -0,0 +1,16 @@ +package com.magamochi.mangamochi.client; + +import com.magamochi.mangamochi.model.dto.WebScrapperClientRequestDTO; +import com.magamochi.mangamochi.model.dto.WebScrapperClientResponseDTO; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@FeignClient(name = "web-scrapper", url = "${web-scrapper.endpoint}") +public interface WebScrapperClient { + @PostMapping( + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + WebScrapperClientResponseDTO scrape(@RequestBody WebScrapperClientRequestDTO dto); +} diff --git a/src/main/java/com/magamochi/mangamochi/config/S3ClientConfig.java b/src/main/java/com/magamochi/mangamochi/config/S3ClientConfig.java new file mode 100644 index 0000000..bcc3f26 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/config/S3ClientConfig.java @@ -0,0 +1,37 @@ +package com.magamochi.mangamochi.config; + +import java.net.URI; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; + +@Configuration +public class S3ClientConfig { + @Value("${minio.accessKey}") + private String accessKey; + + @Value("${minio.secretKey}") + private String secretKey; + + @Value("${minio.endpoint}") + private String endpoint; + + @Bean + public S3Client s3Client() { + var credentials = AwsBasicCredentials.create(accessKey, secretKey); + + var configuration = S3Configuration.builder().pathStyleAccessEnabled(true).build(); + + return S3Client.builder() + .endpointOverride(URI.create(endpoint)) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .region(Region.US_EAST_1) + .serviceConfiguration(configuration) + .build(); + } +} diff --git a/src/main/java/com/magamochi/mangamochi/config/SecurityConfig.java b/src/main/java/com/magamochi/mangamochi/config/SecurityConfig.java new file mode 100644 index 0000000..12abe90 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/config/SecurityConfig.java @@ -0,0 +1,91 @@ +package com.magamochi.mangamochi.config; + +import com.magamochi.mangamochi.security.JwtRequestFilter; +import com.magamochi.mangamochi.service.CustomUserDetailsService; +import com.magamochi.mangamochi.util.JwtUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final CustomUserDetailsService userDetailsService; + private final JwtUtil jwtUtil; + + @Bean + public JwtRequestFilter jwtRequestFilter() { + return new JwtRequestFilter(jwtUtil, userDetailsService); + } + + @Bean + public DaoAuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userDetailsService); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception { + return authConfig.getAuthenticationManager(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/auth/**").permitAll() + .requestMatchers("/swagger-ui/**").permitAll() + .requestMatchers("/api-docs/**").permitAll() + .requestMatchers("/h2-console/**").permitAll() + .anyRequest().permitAll() + ) + .headers(headers -> headers + .frameOptions(frame -> frame.disable()) + ) + .authenticationProvider(authenticationProvider()) + .addFilterBefore(jwtRequestFilter(), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOriginPatterns(List.of("http://localhost:3000")); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With")); + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} \ No newline at end of file diff --git a/src/main/java/com/magamochi/mangamochi/config/WebConfig.java b/src/main/java/com/magamochi/mangamochi/config/WebConfig.java new file mode 100644 index 0000000..7f0df7a --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/config/WebConfig.java @@ -0,0 +1,14 @@ +package com.magamochi.mangamochi.config; + +import lombok.NonNull; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + @Override + public void addCorsMappings(@NonNull CorsRegistry registry) { + registry.addMapping("/**").allowedOrigins("*").allowedMethods("*").allowedHeaders("*"); + } +} diff --git a/src/main/java/com/magamochi/mangamochi/controller/AuthenticationController.java b/src/main/java/com/magamochi/mangamochi/controller/AuthenticationController.java new file mode 100644 index 0000000..681c660 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/controller/AuthenticationController.java @@ -0,0 +1,64 @@ +package com.magamochi.mangamochi.controller; + +import com.magamochi.mangamochi.model.dto.AuthenticationRequestDTO; +import com.magamochi.mangamochi.model.dto.AuthenticationResponseDTO; +import com.magamochi.mangamochi.model.entity.User; +import com.magamochi.mangamochi.model.repository.UserRepository; +import com.magamochi.mangamochi.util.JwtUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/auth") +@CrossOrigin(origins = "*") +@RequiredArgsConstructor +public class AuthenticationController { + private final AuthenticationManager authenticationManager; + private final UserDetailsService userDetailsService; + private final JwtUtil jwtUtil; + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @PostMapping("/login") + public ResponseEntity createAuthenticationToken( + @RequestBody AuthenticationRequestDTO authenticationRequestDTO) { + try { + authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken( + authenticationRequestDTO.username(), authenticationRequestDTO.password())); + } catch (BadCredentialsException e) { + return ResponseEntity.badRequest().body("Incorrect username or password"); + } + + final var userDetails = + userDetailsService.loadUserByUsername(authenticationRequestDTO.username()); + final var jwt = jwtUtil.generateToken(userDetails); + + var user = userRepository.findByUsername(userDetails.getUsername()).orElseThrow(); + var role = user.getRole(); + + return ResponseEntity.ok(new AuthenticationResponseDTO(jwt, userDetails.getUsername(), role)); + } + + @PostMapping("/register") + public ResponseEntity registerUser( + @RequestBody AuthenticationRequestDTO registrationRequestDTO) { + if (userRepository.existsByUsername(registrationRequestDTO.username())) { + return ResponseEntity.badRequest().body("Username is already taken"); + } + + userRepository.save( + User.builder() + .username(registrationRequestDTO.username()) + .password(passwordEncoder.encode(registrationRequestDTO.password())) + .build()); + + return ResponseEntity.ok("User registered successfully"); + } +} diff --git a/src/main/java/com/magamochi/mangamochi/controller/GenreController.java b/src/main/java/com/magamochi/mangamochi/controller/GenreController.java new file mode 100644 index 0000000..8481b6f --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/controller/GenreController.java @@ -0,0 +1,25 @@ +package com.magamochi.mangamochi.controller; + +import com.magamochi.mangamochi.model.dto.GenreDTO; +import com.magamochi.mangamochi.service.GenreService; +import io.swagger.v3.oas.annotations.Operation; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/genres") +@RequiredArgsConstructor +public class GenreController { + private final GenreService genreService; + + @Operation( + summary = "Get a list of genres", + description = "Retrieve a list of genres.", + tags = {"Genre"}, + operationId = "getGenres") + @GetMapping + public List getGenres() { + return genreService.getGenres(); + } +} diff --git a/src/main/java/com/magamochi/mangamochi/controller/MangaController.java b/src/main/java/com/magamochi/mangamochi/controller/MangaController.java new file mode 100644 index 0000000..81c3809 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/controller/MangaController.java @@ -0,0 +1,140 @@ +package com.magamochi.mangamochi.controller; + +import com.magamochi.mangamochi.model.dto.MangaChapterDTO; +import com.magamochi.mangamochi.model.dto.MangaChapterImagesDTO; +import com.magamochi.mangamochi.model.dto.MangaDTO; +import com.magamochi.mangamochi.model.dto.MangaListDTO; +import com.magamochi.mangamochi.model.enumeration.ArchiveFileType; +import com.magamochi.mangamochi.model.specification.MangaSpecification; +import com.magamochi.mangamochi.service.MangaService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import java.io.IOException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/mangas") +@RequiredArgsConstructor +public class MangaController { + private final MangaService mangaService; + + @Operation( + summary = "Get a list of mangas", + description = "Retrieve a list of mangas with their details.", + tags = {"Manga"}, + operationId = "getMangas") + @GetMapping + public Page getMangas( + @ParameterObject MangaSpecification specification, + @Parameter(hidden = true) @ParameterObject @PageableDefault Pageable pageable) { + return mangaService.getMangas(specification, pageable); + } + + @Operation( + summary = "Get the details of a manga", + tags = {"Manga"}, + operationId = "getManga") + @GetMapping("/{mangaId}") + public MangaDTO getManga(@PathVariable Long mangaId) { + return mangaService.getManga(mangaId); + } + + @Operation( + summary = "Get the available chapters for a specific manga/provider combination", + description = "Retrieve a list of manga chapters for a specific manga/provider combination.", + tags = {"Manga"}, + operationId = "getMangaChapters") + @GetMapping("/{mangaProviderId}/chapters") + public List getMangaChapters(@PathVariable Long mangaProviderId) { + return mangaService.getMangaChapters(mangaProviderId); + } + + @Operation( + summary = "Fetch the available chapters for a specific manga/provider combination", + description = "Fetch a list of manga chapters for a specific manga/provider combination.", + tags = {"Manga"}, + operationId = "fetchMangaChapters") + @PostMapping("/{mangaProviderId}/fetch-chapters") + public void fetchMangaChapters(@PathVariable Long mangaProviderId) { + mangaService.fetchMangaChapters(mangaProviderId); + } + + @Operation(summary = "Fetch chapter", operationId = "fetchChapter") + @PostMapping(value = "/chapter/{chapterId}/fetch") + public ResponseEntity fetchChapter(@PathVariable Long chapterId) { + mangaService.fetchChapter(chapterId); + return ResponseEntity.ok().build(); + } + + @Operation( + summary = "Get the available chapters for a specific manga/provider combination", + description = "Retrieve a list of manga chapters for a specific manga/provider combination.", + tags = {"Manga"}, + operationId = "getMangaChapterImages") + @GetMapping("/{chapterId}/images") + public MangaChapterImagesDTO getMangaChapterImages(@PathVariable Long chapterId) { + return mangaService.getMangaChapterImages(chapterId); + } + + @Operation( + summary = "Mark a chapter as read", + description = "Mark a chapter as read by its ID.", + tags = {"Manga"}, + operationId = "markAsRead") + @PostMapping("/{chapterId}/mark-as-read") + public void markAsRead(@PathVariable Long chapterId) { + mangaService.markAsRead(chapterId); + } + + @Operation( + summary = "Download all chapters for a manga provider", + operationId = "downloadAllChapters") + @PostMapping(value = "/chapter/{mangaProviderId}/download-all") + public ResponseEntity downloadAllChapters(@PathVariable Long mangaProviderId) { + mangaService.downloadAllChapters(mangaProviderId); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "Download chapter archive", operationId = "downloadChapterArchive") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "Successful download", + content = + @Content( + mediaType = "application/octet-stream", + schema = @Schema(type = "string", format = "binary"))), + }) + @PostMapping( + value = "/chapter/{chapterId}/download-archive", + produces = MediaType.APPLICATION_OCTET_STREAM_VALUE) + public ResponseEntity downloadChapterArchive( + @PathVariable Long chapterId, @RequestParam ArchiveFileType archiveFileType) + throws IOException { + + var response = mangaService.downloadChapter(chapterId, archiveFileType); + return ResponseEntity.ok() + .header("Content-Disposition", "attachment; filename=\"" + response.filename() + "\"") + .body(response.content()); + } + + @Operation(summary = "Update manga info", operationId = "updateMangaInfo") + @PostMapping(value = "/manga/{mangaId}/info") + public ResponseEntity updateMangaInfo(@PathVariable Long mangaId) { + + mangaService.updateInfo(mangaId); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/magamochi/mangamochi/model/dto/AuthenticationRequestDTO.java b/src/main/java/com/magamochi/mangamochi/model/dto/AuthenticationRequestDTO.java new file mode 100644 index 0000000..5780b40 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/dto/AuthenticationRequestDTO.java @@ -0,0 +1,3 @@ +package com.magamochi.mangamochi.model.dto; + +public record AuthenticationRequestDTO(String username, String password) {} diff --git a/src/main/java/com/magamochi/mangamochi/model/dto/AuthenticationResponseDTO.java b/src/main/java/com/magamochi/mangamochi/model/dto/AuthenticationResponseDTO.java new file mode 100644 index 0000000..7d2636a --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/dto/AuthenticationResponseDTO.java @@ -0,0 +1,3 @@ +package com.magamochi.mangamochi.model.dto; + +public record AuthenticationResponseDTO(String token, String username, String role) {} diff --git a/src/main/java/com/magamochi/mangamochi/model/dto/ContentProviderMangaChapterResponseDTO.java b/src/main/java/com/magamochi/mangamochi/model/dto/ContentProviderMangaChapterResponseDTO.java new file mode 100644 index 0000000..e2fcebf --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/dto/ContentProviderMangaChapterResponseDTO.java @@ -0,0 +1,6 @@ +package com.magamochi.mangamochi.model.dto; + +import jakarta.validation.constraints.NotBlank; + +public record ContentProviderMangaChapterResponseDTO( + @NotBlank String chapterTitle, @NotBlank String chapterUrl) {} diff --git a/src/main/java/com/magamochi/mangamochi/model/dto/ContentProviderMangaInfoResponseDTO.java b/src/main/java/com/magamochi/mangamochi/model/dto/ContentProviderMangaInfoResponseDTO.java new file mode 100644 index 0000000..bd6b5bc --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/dto/ContentProviderMangaInfoResponseDTO.java @@ -0,0 +1,7 @@ +package com.magamochi.mangamochi.model.dto; + +import com.magamochi.mangamochi.model.enumeration.MangaStatus; +import jakarta.validation.constraints.NotBlank; + +public record ContentProviderMangaInfoResponseDTO( + @NotBlank String title, @NotBlank String url, String imgUrl, MangaStatus status) {} diff --git a/src/main/java/com/magamochi/mangamochi/model/dto/GenreDTO.java b/src/main/java/com/magamochi/mangamochi/model/dto/GenreDTO.java new file mode 100644 index 0000000..f6f50c1 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/dto/GenreDTO.java @@ -0,0 +1,11 @@ +package com.magamochi.mangamochi.model.dto; + +import com.magamochi.mangamochi.model.entity.Genre; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record GenreDTO(@NotNull Long id, @NotBlank String name) { + public static GenreDTO from(Genre genre) { + return new GenreDTO(genre.getId(), genre.getName()); + } +} diff --git a/src/main/java/com/magamochi/mangamochi/model/dto/JikanMangaResponseDTO.java b/src/main/java/com/magamochi/mangamochi/model/dto/JikanMangaResponseDTO.java new file mode 100644 index 0000000..7dcd24b --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/dto/JikanMangaResponseDTO.java @@ -0,0 +1,28 @@ +package com.magamochi.mangamochi.model.dto; + +import java.time.OffsetDateTime; +import java.util.List; + +public record JikanMangaResponseDTO(MangaData data) { + public record MangaData( + Long mal_id, + ImageData images, + List title_synonyms, + String status, + boolean publishing, + String synopsis, + Double score, + PublishData published, + List authors, + List genres) { + public record ImageData(ImageUrls jpg) { + public record ImageUrls(String large_image_url) {} + } + + public record PublishData(OffsetDateTime from, OffsetDateTime to) {} + + public record AuthorData(Long mal_id, String name) {} + + public record GenreData(Long mal_id, String name) {} + } +} diff --git a/src/main/java/com/magamochi/mangamochi/model/dto/JikanMangaSearchResponseDTO.java b/src/main/java/com/magamochi/mangamochi/model/dto/JikanMangaSearchResponseDTO.java new file mode 100644 index 0000000..e4a7cc4 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/dto/JikanMangaSearchResponseDTO.java @@ -0,0 +1,9 @@ +package com.magamochi.mangamochi.model.dto; + +import java.util.List; + +public record JikanMangaSearchResponseDTO(List data) { + public record MangaData(Long mal_id, String title, List titles) { + public record TitleData(String type, String title) {} + } +} diff --git a/src/main/java/com/magamochi/mangamochi/model/dto/MangaChapterArchiveDTO.java b/src/main/java/com/magamochi/mangamochi/model/dto/MangaChapterArchiveDTO.java new file mode 100644 index 0000000..503602a --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/dto/MangaChapterArchiveDTO.java @@ -0,0 +1,6 @@ +package com.magamochi.mangamochi.model.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record MangaChapterArchiveDTO(@NotBlank String filename, @NotNull byte[] content) {} diff --git a/src/main/java/com/magamochi/mangamochi/model/dto/MangaChapterDTO.java b/src/main/java/com/magamochi/mangamochi/model/dto/MangaChapterDTO.java new file mode 100644 index 0000000..dff52ef --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/dto/MangaChapterDTO.java @@ -0,0 +1,19 @@ +package com.magamochi.mangamochi.model.dto; + +import com.magamochi.mangamochi.model.entity.MangaChapter; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record MangaChapterDTO( + @NotNull Long id, + @NotBlank String title, + @NotNull Boolean downloaded, + @NotNull Boolean isRead) { + public static MangaChapterDTO from(MangaChapter mangaChapter) { + return new MangaChapterDTO( + mangaChapter.getId(), + mangaChapter.getTitle(), + mangaChapter.getDownloaded(), + mangaChapter.getRead()); + } +} diff --git a/src/main/java/com/magamochi/mangamochi/model/dto/MangaChapterImagesDTO.java b/src/main/java/com/magamochi/mangamochi/model/dto/MangaChapterImagesDTO.java new file mode 100644 index 0000000..d7d2542 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/dto/MangaChapterImagesDTO.java @@ -0,0 +1,23 @@ +package com.magamochi.mangamochi.model.dto; + +import com.magamochi.mangamochi.model.entity.MangaChapter; +import com.magamochi.mangamochi.model.entity.MangaChapterImage; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.Comparator; +import java.util.List; + +public record MangaChapterImagesDTO( + @NotNull Long id, + @NotBlank String mangaTitle, + @NotNull List<@NotBlank String> chapterImageKeys) { + public static MangaChapterImagesDTO from(MangaChapter mangaChapter) { + return new MangaChapterImagesDTO( + mangaChapter.getId(), + mangaChapter.getTitle(), + mangaChapter.getMangaChapterImages().stream() + .sorted(Comparator.comparing(MangaChapterImage::getPosition)) + .map(mangaChapterImage -> mangaChapterImage.getImage().getFileKey()) + .toList()); + } +} diff --git a/src/main/java/com/magamochi/mangamochi/model/dto/MangaChapterResponseDTO.java b/src/main/java/com/magamochi/mangamochi/model/dto/MangaChapterResponseDTO.java new file mode 100644 index 0000000..2d7890d --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/dto/MangaChapterResponseDTO.java @@ -0,0 +1,10 @@ +package com.magamochi.mangamochi.model.dto; + +import jakarta.validation.constraints.NotNull; +import java.util.UUID; + +public record MangaChapterResponseDTO( + @NotNull UUID id, + // @NotNull Long mangaProviderId, + @NotNull String chapterTitle, + @NotNull String chapterUrl) {} diff --git a/src/main/java/com/magamochi/mangamochi/model/dto/MangaDTO.java b/src/main/java/com/magamochi/mangamochi/model/dto/MangaDTO.java new file mode 100644 index 0000000..623fb1b --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/dto/MangaDTO.java @@ -0,0 +1,63 @@ +package com.magamochi.mangamochi.model.dto; + +import static java.util.Objects.isNull; + +import com.magamochi.mangamochi.model.entity.Manga; +import com.magamochi.mangamochi.model.entity.MangaChapter; +import com.magamochi.mangamochi.model.entity.MangaProvider; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.OffsetDateTime; +import java.util.List; + +public record MangaDTO( + @NotNull Long id, + @NotBlank String title, + String coverImageKey, + String status, + OffsetDateTime publishedFrom, + OffsetDateTime publishedTo, + String synopsis, + Integer providerCount, + @NotNull List alternativeTitles, + @NotNull List genres, + @NotNull List authors, + @NotNull Double score, + @NotNull List providers) { + public static MangaDTO from(Manga manga) { + return new MangaDTO( + manga.getId(), + manga.getTitle(), + isNull(manga.getCoverImage()) ? null : manga.getCoverImage().getFileKey(), + manga.getStatus(), + manga.getPublishedFrom(), + manga.getPublishedTo(), + manga.getSynopsis(), + manga.getMangaProviders().size(), + manga.getAlternativeTitles(), + manga.getMangaGenres().stream().map(mangaGenre -> mangaGenre.getGenre().getName()).toList(), + manga.getMangaAuthors().stream() + .map(mangaAuthor -> mangaAuthor.getAuthor().getName()) + .toList(), + 0.0, + manga.getMangaProviders().stream().map(MangaProviderDTO::from).toList()); + } + + public record MangaProviderDTO( + @NotNull long id, + @NotBlank String providerName, + @NotNull Integer chaptersAvailable, + @NotNull Integer chaptersDownloaded) { + public static MangaProviderDTO from(MangaProvider mangaProvider) { + var chapters = mangaProvider.getMangaChapters(); + var chaptersAvailable = chapters.size(); + var chaptersDownloaded = (int) chapters.stream().filter(MangaChapter::getDownloaded).count(); + + return new MangaProviderDTO( + mangaProvider.getId(), + mangaProvider.getProvider().getName(), + chaptersAvailable, + chaptersDownloaded); + } + } +} diff --git a/src/main/java/com/magamochi/mangamochi/model/dto/MangaListDTO.java b/src/main/java/com/magamochi/mangamochi/model/dto/MangaListDTO.java new file mode 100644 index 0000000..3b791ce --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/dto/MangaListDTO.java @@ -0,0 +1,37 @@ +package com.magamochi.mangamochi.model.dto; + +import static java.util.Objects.nonNull; + +import com.magamochi.mangamochi.model.entity.Manga; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.OffsetDateTime; +import java.util.List; + +public record MangaListDTO( + @NotNull Long id, + @NotBlank String title, + String coverImageKey, + String status, + OffsetDateTime publishedFrom, + OffsetDateTime publishedTo, + Integer providerCount, + @NotNull List genres, + @NotNull List authors, + @NotNull Double score) { + public static MangaListDTO from(Manga manga) { + return new MangaListDTO( + manga.getId(), + manga.getTitle(), + nonNull(manga.getCoverImage()) ? manga.getCoverImage().getFileKey() : null, + manga.getStatus(), + manga.getPublishedFrom(), + manga.getPublishedTo(), + manga.getMangaProviders().size(), + manga.getMangaGenres().stream().map(mangaGenre -> mangaGenre.getGenre().getName()).toList(), + manga.getMangaAuthors().stream() + .map(mangaAuthor -> mangaAuthor.getAuthor().getName()) + .toList(), + 0.0); + } +} diff --git a/src/main/java/com/magamochi/mangamochi/model/dto/MangaMessageDTO.java b/src/main/java/com/magamochi/mangamochi/model/dto/MangaMessageDTO.java new file mode 100644 index 0000000..910d99b --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/dto/MangaMessageDTO.java @@ -0,0 +1,6 @@ +package com.magamochi.mangamochi.model.dto; + +import java.util.List; + +public record MangaMessageDTO( + String contentProviderName, List mangaInfoResponseDTOs) {} diff --git a/src/main/java/com/magamochi/mangamochi/model/dto/RapidFuzzRequestDTO.java b/src/main/java/com/magamochi/mangamochi/model/dto/RapidFuzzRequestDTO.java new file mode 100644 index 0000000..4332de1 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/dto/RapidFuzzRequestDTO.java @@ -0,0 +1,5 @@ +package com.magamochi.mangamochi.model.dto; + +import java.util.List; + +public record RapidFuzzRequestDTO(String title, List options) {} diff --git a/src/main/java/com/magamochi/mangamochi/model/dto/RapidFuzzResponseDTO.java b/src/main/java/com/magamochi/mangamochi/model/dto/RapidFuzzResponseDTO.java new file mode 100644 index 0000000..d106358 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/dto/RapidFuzzResponseDTO.java @@ -0,0 +1,3 @@ +package com.magamochi.mangamochi.model.dto; + +public record RapidFuzzResponseDTO(boolean match_found, String best_match, double similarity) {} diff --git a/src/main/java/com/magamochi/mangamochi/model/dto/WebScrapperClientRequestDTO.java b/src/main/java/com/magamochi/mangamochi/model/dto/WebScrapperClientRequestDTO.java new file mode 100644 index 0000000..e1ac086 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/dto/WebScrapperClientRequestDTO.java @@ -0,0 +1,3 @@ +package com.magamochi.mangamochi.model.dto; + +public record WebScrapperClientRequestDTO(String url) {} diff --git a/src/main/java/com/magamochi/mangamochi/model/dto/WebScrapperClientResponseDTO.java b/src/main/java/com/magamochi/mangamochi/model/dto/WebScrapperClientResponseDTO.java new file mode 100644 index 0000000..e389c21 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/dto/WebScrapperClientResponseDTO.java @@ -0,0 +1,3 @@ +package com.magamochi.mangamochi.model.dto; + +public record WebScrapperClientResponseDTO(String page_source) {} diff --git a/src/main/java/com/magamochi/mangamochi/model/entity/Author.java b/src/main/java/com/magamochi/mangamochi/model/entity/Author.java new file mode 100644 index 0000000..e5217c6 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/entity/Author.java @@ -0,0 +1,32 @@ +package com.magamochi.mangamochi.model.entity; + +import jakarta.persistence.*; +import java.time.Instant; +import java.util.List; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +@Entity +@Table(name = "authors") +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class Author { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long malId; + + private String name; + + @CreationTimestamp private Instant createdAt; + + @UpdateTimestamp private Instant updatedAt; + + @OneToMany(mappedBy = "author") + private List mangaAuthors; +} diff --git a/src/main/java/com/magamochi/mangamochi/model/entity/Genre.java b/src/main/java/com/magamochi/mangamochi/model/entity/Genre.java new file mode 100644 index 0000000..de1b80d --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/entity/Genre.java @@ -0,0 +1,25 @@ +package com.magamochi.mangamochi.model.entity; + +import jakarta.persistence.*; +import java.util.List; +import lombok.*; + +@Entity +@Table(name = "genres") +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class Genre { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long malId; + + private String name; + + @OneToMany(mappedBy = "genre") + private List mangaGenres; +} diff --git a/src/main/java/com/magamochi/mangamochi/model/entity/Image.java b/src/main/java/com/magamochi/mangamochi/model/entity/Image.java new file mode 100644 index 0000000..c13ce49 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/entity/Image.java @@ -0,0 +1,27 @@ +package com.magamochi.mangamochi.model.entity; + +import jakarta.persistence.*; +import java.time.Instant; +import java.util.UUID; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +@Entity +@Table(name = "images") +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class Image { + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + private String fileKey; + + @CreationTimestamp private Instant createdAt; + + @UpdateTimestamp private Instant updatedAt; +} diff --git a/src/main/java/com/magamochi/mangamochi/model/entity/Manga.java b/src/main/java/com/magamochi/mangamochi/model/entity/Manga.java new file mode 100644 index 0000000..80eb464 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/entity/Manga.java @@ -0,0 +1,60 @@ +package com.magamochi.mangamochi.model.entity; + +import io.hypersistence.utils.hibernate.type.array.ListArrayType; +import jakarta.persistence.*; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.List; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.Type; +import org.hibernate.annotations.UpdateTimestamp; + +@Entity +@Table(name = "mangas") +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class Manga { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long malId; + + private String title; + + @Type(ListArrayType.class) + @Column(name = "alternative_titles", columnDefinition = "text[]") + private List alternativeTitles; + + // @Enumerated(EnumType.STRING) + private String status; + + private String synopsis; + + @CreationTimestamp private Instant createdAt; + + @UpdateTimestamp private Instant updatedAt; + + @OneToMany(mappedBy = "manga") + private List mangaProviders; + + @ManyToOne + @JoinColumn(name = "cover_image_id") + private Image coverImage; + + private Double score; + + private OffsetDateTime publishedFrom; + + private OffsetDateTime publishedTo; + + @OneToMany(mappedBy = "manga") + private List mangaAuthors; + + @OneToMany(mappedBy = "manga") + private List mangaGenres; +} diff --git a/src/main/java/com/magamochi/mangamochi/model/entity/MangaAuthor.java b/src/main/java/com/magamochi/mangamochi/model/entity/MangaAuthor.java new file mode 100644 index 0000000..d85e216 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/entity/MangaAuthor.java @@ -0,0 +1,25 @@ +package com.magamochi.mangamochi.model.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "manga_author") +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class MangaAuthor { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "manga_id") + private Manga manga; + + @ManyToOne + @JoinColumn(name = "author_id") + private Author author; +} diff --git a/src/main/java/com/magamochi/mangamochi/model/entity/MangaChapter.java b/src/main/java/com/magamochi/mangamochi/model/entity/MangaChapter.java new file mode 100644 index 0000000..2df8f4f --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/entity/MangaChapter.java @@ -0,0 +1,40 @@ +package com.magamochi.mangamochi.model.entity; + +import jakarta.persistence.*; +import java.time.Instant; +import java.util.List; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +@Entity +@Table(name = "manga_chapters") +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class MangaChapter { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "manga_provider_id") + private MangaProvider mangaProvider; + + private String title; + + private String url; + + @Builder.Default private Boolean downloaded = false; + + @Builder.Default private Boolean read = false; + + @CreationTimestamp private Instant createdAt; + + @UpdateTimestamp private Instant updatedAt; + + @OneToMany(mappedBy = "mangaChapter") + private List mangaChapterImages; +} diff --git a/src/main/java/com/magamochi/mangamochi/model/entity/MangaChapterImage.java b/src/main/java/com/magamochi/mangamochi/model/entity/MangaChapterImage.java new file mode 100644 index 0000000..abcfd00 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/entity/MangaChapterImage.java @@ -0,0 +1,34 @@ +package com.magamochi.mangamochi.model.entity; + +import jakarta.persistence.*; +import java.time.Instant; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +@Entity +@Table(name = "manga_chapter_images") +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class MangaChapterImage { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "manga_chapter_id") + private MangaChapter mangaChapter; + + @OneToOne + @JoinColumn(name = "image_id") + private Image image; + + private int position; + + @CreationTimestamp private Instant createdAt; + + @UpdateTimestamp private Instant updatedAt; +} diff --git a/src/main/java/com/magamochi/mangamochi/model/entity/MangaGenre.java b/src/main/java/com/magamochi/mangamochi/model/entity/MangaGenre.java new file mode 100644 index 0000000..5f05135 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/entity/MangaGenre.java @@ -0,0 +1,25 @@ +package com.magamochi.mangamochi.model.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "manga_genre") +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class MangaGenre { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "manga_id") + private Manga manga; + + @ManyToOne + @JoinColumn(name = "genre_id") + private Genre genre; +} diff --git a/src/main/java/com/magamochi/mangamochi/model/entity/MangaImportReview.java b/src/main/java/com/magamochi/mangamochi/model/entity/MangaImportReview.java new file mode 100644 index 0000000..9321c84 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/entity/MangaImportReview.java @@ -0,0 +1,29 @@ +package com.magamochi.mangamochi.model.entity; + +import jakarta.persistence.*; +import java.time.Instant; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +@Entity +@Table(name = "manga_import_reviews") +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class MangaImportReview { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + + private String url; + + @ManyToOne + @JoinColumn(name = "provider_id") + private Provider provider; + + @CreationTimestamp private Instant createdAt; +} diff --git a/src/main/java/com/magamochi/mangamochi/model/entity/MangaProvider.java b/src/main/java/com/magamochi/mangamochi/model/entity/MangaProvider.java new file mode 100644 index 0000000..fbcb97d --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/entity/MangaProvider.java @@ -0,0 +1,40 @@ +package com.magamochi.mangamochi.model.entity; + +import jakarta.persistence.*; +import java.time.Instant; +import java.util.List; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +@Entity +@Table(name = "manga_provider") +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class MangaProvider { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "manga_id", nullable = false) + private Manga manga; + + @ManyToOne + @JoinColumn(name = "provider_id", nullable = false) + private Provider provider; + + private String mangaTitle; + + private String url; + + @OneToMany(mappedBy = "mangaProvider") + List mangaChapters; + + @CreationTimestamp private Instant createdAt; + + @UpdateTimestamp private Instant updatedAt; +} diff --git a/src/main/java/com/magamochi/mangamochi/model/entity/Provider.java b/src/main/java/com/magamochi/mangamochi/model/entity/Provider.java new file mode 100644 index 0000000..0813681 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/entity/Provider.java @@ -0,0 +1,34 @@ +package com.magamochi.mangamochi.model.entity; + +import com.magamochi.mangamochi.model.enumeration.ProviderStatus; +import jakarta.persistence.*; +import java.time.Instant; +import java.util.List; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +@Entity +@Table(name = "providers") +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class Provider { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + @Enumerated(EnumType.STRING) + private ProviderStatus status; + + @CreationTimestamp private Instant createdAt; + + @UpdateTimestamp private Instant updatedAt; + + @OneToMany(mappedBy = "provider") + List mangaProviders; +} diff --git a/src/main/java/com/magamochi/mangamochi/model/entity/User.java b/src/main/java/com/magamochi/mangamochi/model/entity/User.java new file mode 100644 index 0000000..0a52afe --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/entity/User.java @@ -0,0 +1,26 @@ +package com.magamochi.mangamochi.model.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "users") +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class User { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true, nullable = false) + private String username; + + @Column(nullable = false) + private String password; + + @Column(nullable = false) + private String role; +} diff --git a/src/main/java/com/magamochi/mangamochi/model/enumeration/ArchiveFileType.java b/src/main/java/com/magamochi/mangamochi/model/enumeration/ArchiveFileType.java new file mode 100644 index 0000000..9982b20 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/enumeration/ArchiveFileType.java @@ -0,0 +1,6 @@ +package com.magamochi.mangamochi.model.enumeration; + +public enum ArchiveFileType { + CBZ, + CBR +} diff --git a/src/main/java/com/magamochi/mangamochi/model/enumeration/MangaStatus.java b/src/main/java/com/magamochi/mangamochi/model/enumeration/MangaStatus.java new file mode 100644 index 0000000..d0dc22e --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/enumeration/MangaStatus.java @@ -0,0 +1,9 @@ +package com.magamochi.mangamochi.model.enumeration; + +public enum MangaStatus { + ONGOING, + COMPLETED, + HIATUS, + CANCELLED, + UNKNOWN +} diff --git a/src/main/java/com/magamochi/mangamochi/model/enumeration/ProviderStatus.java b/src/main/java/com/magamochi/mangamochi/model/enumeration/ProviderStatus.java new file mode 100644 index 0000000..cb09460 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/enumeration/ProviderStatus.java @@ -0,0 +1,6 @@ +package com.magamochi.mangamochi.model.enumeration; + +public enum ProviderStatus { + ACTIVE, + INACTIVE +} diff --git a/src/main/java/com/magamochi/mangamochi/model/repository/AuthorRepository.java b/src/main/java/com/magamochi/mangamochi/model/repository/AuthorRepository.java new file mode 100644 index 0000000..4fb3499 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/repository/AuthorRepository.java @@ -0,0 +1,9 @@ +package com.magamochi.mangamochi.model.repository; + +import com.magamochi.mangamochi.model.entity.Author; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AuthorRepository extends JpaRepository { + Optional findByMalId(Long aLong); +} diff --git a/src/main/java/com/magamochi/mangamochi/model/repository/GenreRepository.java b/src/main/java/com/magamochi/mangamochi/model/repository/GenreRepository.java new file mode 100644 index 0000000..57732c7 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/repository/GenreRepository.java @@ -0,0 +1,9 @@ +package com.magamochi.mangamochi.model.repository; + +import com.magamochi.mangamochi.model.entity.Genre; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface GenreRepository extends JpaRepository { + Optional findByMalId(Long malId); +} diff --git a/src/main/java/com/magamochi/mangamochi/model/repository/ImageRepository.java b/src/main/java/com/magamochi/mangamochi/model/repository/ImageRepository.java new file mode 100644 index 0000000..688fa9b --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/repository/ImageRepository.java @@ -0,0 +1,7 @@ +package com.magamochi.mangamochi.model.repository; + +import com.magamochi.mangamochi.model.entity.Image; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ImageRepository extends JpaRepository {} diff --git a/src/main/java/com/magamochi/mangamochi/model/repository/MangaAuthorRepository.java b/src/main/java/com/magamochi/mangamochi/model/repository/MangaAuthorRepository.java new file mode 100644 index 0000000..5020397 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/repository/MangaAuthorRepository.java @@ -0,0 +1,11 @@ +package com.magamochi.mangamochi.model.repository; + +import com.magamochi.mangamochi.model.entity.Author; +import com.magamochi.mangamochi.model.entity.Manga; +import com.magamochi.mangamochi.model.entity.MangaAuthor; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MangaAuthorRepository extends JpaRepository { + Optional findByMangaAndAuthor(Manga manga, Author author); +} diff --git a/src/main/java/com/magamochi/mangamochi/model/repository/MangaChapterImageRepository.java b/src/main/java/com/magamochi/mangamochi/model/repository/MangaChapterImageRepository.java new file mode 100644 index 0000000..a312418 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/repository/MangaChapterImageRepository.java @@ -0,0 +1,10 @@ +package com.magamochi.mangamochi.model.repository; + +import com.magamochi.mangamochi.model.entity.MangaChapter; +import com.magamochi.mangamochi.model.entity.MangaChapterImage; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MangaChapterImageRepository extends JpaRepository { + List findAllByMangaChapter(MangaChapter mangaChapter); +} diff --git a/src/main/java/com/magamochi/mangamochi/model/repository/MangaChapterRepository.java b/src/main/java/com/magamochi/mangamochi/model/repository/MangaChapterRepository.java new file mode 100644 index 0000000..7e104f0 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/repository/MangaChapterRepository.java @@ -0,0 +1,15 @@ +package com.magamochi.mangamochi.model.repository; + +import com.magamochi.mangamochi.model.entity.MangaChapter; +import com.magamochi.mangamochi.model.entity.MangaProvider; +import jakarta.validation.constraints.NotBlank; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MangaChapterRepository extends JpaRepository { + Optional findByMangaProviderAndUrlIgnoreCase( + MangaProvider mangaProvider, @NotBlank String url); + + List findByMangaProviderId(Long mangaProvider_id); +} diff --git a/src/main/java/com/magamochi/mangamochi/model/repository/MangaGenreRepository.java b/src/main/java/com/magamochi/mangamochi/model/repository/MangaGenreRepository.java new file mode 100644 index 0000000..fd100fc --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/repository/MangaGenreRepository.java @@ -0,0 +1,9 @@ +package com.magamochi.mangamochi.model.repository; + +import com.magamochi.mangamochi.model.entity.*; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MangaGenreRepository extends JpaRepository { + Optional findByMangaAndGenre(Manga manga, Genre genre); +} diff --git a/src/main/java/com/magamochi/mangamochi/model/repository/MangaImportReviewRepository.java b/src/main/java/com/magamochi/mangamochi/model/repository/MangaImportReviewRepository.java new file mode 100644 index 0000000..6c2872f --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/repository/MangaImportReviewRepository.java @@ -0,0 +1,6 @@ +package com.magamochi.mangamochi.model.repository; + +import com.magamochi.mangamochi.model.entity.MangaImportReview; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MangaImportReviewRepository extends JpaRepository {} diff --git a/src/main/java/com/magamochi/mangamochi/model/repository/MangaProviderRepository.java b/src/main/java/com/magamochi/mangamochi/model/repository/MangaProviderRepository.java new file mode 100644 index 0000000..829f7e2 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/repository/MangaProviderRepository.java @@ -0,0 +1,14 @@ +package com.magamochi.mangamochi.model.repository; + +import com.magamochi.mangamochi.model.entity.Manga; +import com.magamochi.mangamochi.model.entity.MangaProvider; +import com.magamochi.mangamochi.model.entity.Provider; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MangaProviderRepository extends JpaRepository { + Optional findByMangaAndProvider(Manga manga, Provider provider); + + Optional findByMangaTitleIgnoreCaseAndProvider( + String mangaTitle, Provider provider); +} diff --git a/src/main/java/com/magamochi/mangamochi/model/repository/MangaRepository.java b/src/main/java/com/magamochi/mangamochi/model/repository/MangaRepository.java new file mode 100644 index 0000000..eff6cb3 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/repository/MangaRepository.java @@ -0,0 +1,13 @@ +package com.magamochi.mangamochi.model.repository; + +import com.magamochi.mangamochi.model.entity.Manga; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface MangaRepository + extends JpaRepository, JpaSpecificationExecutor { + Optional findByTitleIgnoreCase(String title); + + Optional findByMalId(Long malId); +} diff --git a/src/main/java/com/magamochi/mangamochi/model/repository/ProviderRepository.java b/src/main/java/com/magamochi/mangamochi/model/repository/ProviderRepository.java new file mode 100644 index 0000000..762bc93 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/repository/ProviderRepository.java @@ -0,0 +1,9 @@ +package com.magamochi.mangamochi.model.repository; + +import com.magamochi.mangamochi.model.entity.Provider; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProviderRepository extends JpaRepository { + Optional findByNameIgnoreCase(String name); +} diff --git a/src/main/java/com/magamochi/mangamochi/model/repository/UserRepository.java b/src/main/java/com/magamochi/mangamochi/model/repository/UserRepository.java new file mode 100644 index 0000000..62391db --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/repository/UserRepository.java @@ -0,0 +1,11 @@ +package com.magamochi.mangamochi.model.repository; + +import com.magamochi.mangamochi.model.entity.User; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { + Optional findByUsername(String username); + + boolean existsByUsername(String username); +} diff --git a/src/main/java/com/magamochi/mangamochi/model/specification/MangaSpecification.java b/src/main/java/com/magamochi/mangamochi/model/specification/MangaSpecification.java new file mode 100644 index 0000000..30e8be7 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/specification/MangaSpecification.java @@ -0,0 +1,61 @@ +package com.magamochi.mangamochi.model.specification; + +import static java.util.Objects.nonNull; + +import com.magamochi.mangamochi.model.entity.Author; +import com.magamochi.mangamochi.model.entity.Manga; +import jakarta.persistence.criteria.*; +import java.util.ArrayList; +import java.util.List; +import lombok.NonNull; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.jpa.domain.Specification; + +public record MangaSpecification(String searchQuery, List genreIds, List statuses) + implements Specification { + @Override + public Predicate toPredicate( + @NonNull Root root, CriteriaQuery query, @NonNull CriteriaBuilder criteriaBuilder) { + List predicates = new ArrayList<>(); + + if (StringUtils.isNotBlank(searchQuery)) { + var searchPattern = "%" + searchQuery.toLowerCase() + "%"; + + var titlePredicate = + criteriaBuilder.like(criteriaBuilder.lower(root.get("title")), searchPattern); + + var authorsPredicate = searchInAuthors(root, query, criteriaBuilder, searchPattern); + + var searchPredicate = criteriaBuilder.or(titlePredicate, authorsPredicate); + + predicates.add(searchPredicate); + } + + if (nonNull(genreIds) && !genreIds.isEmpty()) { + var genreJoin = root.join("mangaGenres", JoinType.LEFT); + predicates.add(genreJoin.get("genre").get("id").in(genreIds)); + } + + if (nonNull(statuses) && !statuses.isEmpty()) { + predicates.add( + criteriaBuilder + .lower(root.get("status")) + .in(statuses.stream().map(String::toLowerCase).toList())); + } + + query.distinct(true); + + return criteriaBuilder.and(predicates.toArray(Predicate[]::new)); + } + + private Predicate searchInAuthors( + Root root, + CriteriaQuery query, + CriteriaBuilder criteriaBuilder, + String searchPattern) { + Join mangaAuthorsJoin = root.join("mangaAuthors", JoinType.LEFT); + Join authorJoin = mangaAuthorsJoin.join("author", JoinType.LEFT); + + return criteriaBuilder.like(criteriaBuilder.lower(authorJoin.get("name")), searchPattern); + } +} diff --git a/src/main/java/com/magamochi/mangamochi/security/JwtRequestFilter.java b/src/main/java/com/magamochi/mangamochi/security/JwtRequestFilter.java new file mode 100644 index 0000000..6f5b84d --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/security/JwtRequestFilter.java @@ -0,0 +1,57 @@ +package com.magamochi.mangamochi.security; + +import com.magamochi.mangamochi.service.CustomUserDetailsService; +import com.magamochi.mangamochi.util.JwtUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +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 JwtRequestFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final CustomUserDetailsService userDetailsService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain chain) throws ServletException, IOException { + + final String authorizationHeader = request.getHeader("Authorization"); + + String username = null; + String jwt = null; + + if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + jwt = authorizationHeader.substring(7); + try { + username = jwtUtil.extractUsername(jwt); + } catch (Exception e) { + logger.warn("JWT token validation failed", e); + } + } + + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetails userDetails = this.userDetailsService.loadUserByUsername(username); + + if (jwtUtil.validateToken(jwt, userDetails)) { + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + chain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/com/magamochi/mangamochi/service/CustomUserDetailsService.java b/src/main/java/com/magamochi/mangamochi/service/CustomUserDetailsService.java new file mode 100644 index 0000000..12a48cd --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/service/CustomUserDetailsService.java @@ -0,0 +1,31 @@ +package com.magamochi.mangamochi.service; + +import com.magamochi.mangamochi.model.repository.UserRepository; +import java.util.Collections; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +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 CustomUserDetailsService implements UserDetailsService { + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + var user = + userRepository + .findByUsername(username) + .orElseThrow( + () -> new UsernameNotFoundException("User not found with username: " + username)); + + return new User( + user.getUsername(), + user.getPassword(), + Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + user.getRole()))); + } +} diff --git a/src/main/java/com/magamochi/mangamochi/service/GenreService.java b/src/main/java/com/magamochi/mangamochi/service/GenreService.java new file mode 100644 index 0000000..bf6d4c2 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/service/GenreService.java @@ -0,0 +1,19 @@ +package com.magamochi.mangamochi.service; + +import com.magamochi.mangamochi.model.dto.GenreDTO; +import com.magamochi.mangamochi.model.repository.GenreRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class GenreService { + private final GenreRepository genreRepository; + + public List getGenres() { + var genres = genreRepository.findAll(); + + return genres.stream().map(GenreDTO::from).toList(); + } +} diff --git a/src/main/java/com/magamochi/mangamochi/service/ImageService.java b/src/main/java/com/magamochi/mangamochi/service/ImageService.java new file mode 100644 index 0000000..b9ed188 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/service/ImageService.java @@ -0,0 +1,24 @@ +package com.magamochi.mangamochi.service; + +import com.magamochi.mangamochi.model.entity.Image; +import com.magamochi.mangamochi.model.repository.ImageRepository; +import java.io.InputStream; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ImageService { + private final S3Service s3Service; + private final ImageRepository imageRepository; + + public Image uploadImage(byte[] data, String contentType, String path) { + var fileKey = s3Service.uploadFile(data, contentType, path); + + return imageRepository.save(Image.builder().fileKey(fileKey).build()); + } + + public InputStream getImageStream(Image image) { + return s3Service.getFile(image.getFileKey()); + } +} diff --git a/src/main/java/com/magamochi/mangamochi/service/MangaListService.java b/src/main/java/com/magamochi/mangamochi/service/MangaListService.java new file mode 100644 index 0000000..c0c5254 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/service/MangaListService.java @@ -0,0 +1,142 @@ +package com.magamochi.mangamochi.service; + +import static java.util.Objects.isNull; + +import com.google.common.util.concurrent.RateLimiter; +import com.magamochi.mangamochi.client.JikanClient; +import com.magamochi.mangamochi.client.RapidFuzzClient; +import com.magamochi.mangamochi.model.dto.ContentProviderMangaInfoResponseDTO; +import com.magamochi.mangamochi.model.dto.JikanMangaSearchResponseDTO; +import com.magamochi.mangamochi.model.dto.RapidFuzzRequestDTO; +import com.magamochi.mangamochi.model.entity.Manga; +import com.magamochi.mangamochi.model.entity.MangaImportReview; +import com.magamochi.mangamochi.model.entity.MangaProvider; +import com.magamochi.mangamochi.model.entity.Provider; +import com.magamochi.mangamochi.model.enumeration.ProviderStatus; +import com.magamochi.mangamochi.model.repository.MangaImportReviewRepository; +import com.magamochi.mangamochi.model.repository.MangaProviderRepository; +import com.magamochi.mangamochi.model.repository.MangaRepository; +import com.magamochi.mangamochi.model.repository.ProviderRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Service; + +@Log4j2 +@Service +@RequiredArgsConstructor +public class MangaListService { + private final ProviderRepository providerRepository; + private final MangaRepository mangaRepository; + private final MangaProviderRepository mangaProviderRepository; + private final MangaImportReviewRepository mangaImportReviewRepository; + + private final JikanClient jikanClient; + private final RapidFuzzClient rapidFuzzClient; + + public void updateMangaList( + String contentProviderName, List mangaInfoResponseDTOs) { + var rateLimiter = RateLimiter.create(1); + + var provider = + providerRepository + .findByNameIgnoreCase(contentProviderName) + .orElseGet( + () -> + providerRepository.save( + Provider.builder() + .name(contentProviderName) + .status(ProviderStatus.ACTIVE) + .build())); + + mangaInfoResponseDTOs.forEach( + mangaResponse -> { + var mangaProvider = + mangaProviderRepository.findByMangaTitleIgnoreCaseAndProvider( + mangaResponse.title(), provider); + + if (mangaProvider.isPresent()) { + return; + } + + rateLimiter.acquire(); + var manga = getOrCreateManga(mangaResponse.title(), mangaResponse.url(), provider); + + if (isNull(manga)) { + return; + } + + try { + + mangaProviderRepository.save( + MangaProvider.builder() + .manga(manga) + .mangaTitle(mangaResponse.title()) + .provider(provider) + .url(mangaResponse.url()) + .build()); + } catch (Exception e) { + log.error(e.getMessage()); + } + }); + } + + private Manga getOrCreateManga(String title, String url, Provider provider) { + var existingManga = mangaRepository.findByTitleIgnoreCase(title); + if (existingManga.isPresent()) { + return existingManga.get(); + } + + var jikanResults = jikanClient.mangaSearch(title).data(); + if (jikanResults.isEmpty()) { + createMangaImportReview(title, url, provider); + log.warn("No manga found with title {}", title); + return null; + } + + var request = + new RapidFuzzRequestDTO( + title, + jikanResults.stream() + .flatMap( + results -> + results.titles().stream() + .map(JikanMangaSearchResponseDTO.MangaData.TitleData::title)) + .toList()); + + var fuzzResults = rapidFuzzClient.mangaSearch(request); + if (!fuzzResults.match_found()) { + createMangaImportReview(title, url, provider); + log.warn("No match found for manga with title {}", title); + return null; + } + + var resultOptional = + jikanResults.stream() + .filter( + results -> + results.titles().stream() + .map(JikanMangaSearchResponseDTO.MangaData.TitleData::title) + .toList() + .contains(fuzzResults.best_match())) + .findFirst(); + if (resultOptional.isEmpty()) { + createMangaImportReview(title, url, provider); + log.warn("No match found for manga with title {}", title); + return null; + } + + var result = resultOptional.get(); + + existingManga = mangaRepository.findByTitleIgnoreCase(result.title()); + return existingManga.orElseGet( + () -> + mangaRepository.save( + Manga.builder().title(result.title()).malId(result.mal_id()).build())); + } + + private void createMangaImportReview(String title, String url, Provider provider) { + mangaImportReviewRepository.save( + MangaImportReview.builder().title(title).url(url).provider(provider).build()); + } +} diff --git a/src/main/java/com/magamochi/mangamochi/service/MangaService.java b/src/main/java/com/magamochi/mangamochi/service/MangaService.java new file mode 100644 index 0000000..ae0b6db --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/service/MangaService.java @@ -0,0 +1,239 @@ +package com.magamochi.mangamochi.service; + +import com.magamochi.mangamochi.client.JikanClient; +import com.magamochi.mangamochi.model.dto.*; +import com.magamochi.mangamochi.model.entity.MangaChapter; +import com.magamochi.mangamochi.model.entity.MangaChapterImage; +import com.magamochi.mangamochi.model.entity.MangaProvider; +import com.magamochi.mangamochi.model.enumeration.ArchiveFileType; +import com.magamochi.mangamochi.model.repository.MangaChapterImageRepository; +import com.magamochi.mangamochi.model.repository.MangaChapterRepository; +import com.magamochi.mangamochi.model.repository.MangaProviderRepository; +import com.magamochi.mangamochi.model.repository.MangaRepository; +import com.magamochi.mangamochi.model.specification.MangaSpecification; +import com.magamochi.mangamochi.service.providers.ContentProviderFactory; +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.*; +import java.net.URL; +import java.util.Comparator; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import lombok.RequiredArgsConstructor; +import org.apache.tomcat.util.http.fileupload.IOUtils; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MangaService { + private final MangaChapterRepository mangaChapterRepository; + private final MangaRepository mangaRepository; + private final MangaProviderRepository mangaProviderRepository; + private final MangaChapterImageRepository mangaChapterImageRepository; + private final ImageService imageService; + + private final JikanClient jikanClient; + + private final ContentProviderFactory contentProviderFactory; + + public Page getMangas(MangaSpecification specification, Pageable pageable) { + return mangaRepository.findAll(specification, pageable).map(MangaListDTO::from); + } + + public List getMangaChapters(Long mangaProviderId) { + var mangaProvider = + mangaProviderRepository + .findById(mangaProviderId) + .orElseThrow(() -> new RuntimeException("manga provider not found")); + + return mangaProvider.getMangaChapters().stream() + .sorted(Comparator.comparing(MangaChapter::getId)) + .map(MangaChapterDTO::from) + .toList(); + } + + public void fetchChapter(Long chapterId) { + try { + var chapter = + mangaChapterRepository + .findById(chapterId) + .orElseThrow(() -> new RuntimeException("Chapter not found")); + + var mangaProvider = chapter.getMangaProvider(); + var provider = + contentProviderFactory.getContentProvider(mangaProvider.getProvider().getName()); + + var chapterImagesUrls = provider.getChapterImagesUrls(chapter.getUrl()); + if (chapterImagesUrls.isEmpty()) { + throw new RuntimeException("Chapter image not found"); + } + + var chapterImages = + chapterImagesUrls.entrySet().stream() + .map( + entry -> { + try { + var inputStream = + new BufferedInputStream( + new URL(new URI(entry.getValue()).toASCIIString()).openStream()); + + var bytes = inputStream.readAllBytes(); + + inputStream.close(); + var image = + imageService.uploadImage(bytes, "image/jpeg", "chapter/" + chapterId); + + return MangaChapterImage.builder() + .mangaChapter(chapter) + .position(entry.getKey()) + .image(image) + .build(); + } catch (IOException | URISyntaxException e) { + throw new RuntimeException(e); + } + }) + .toList(); + + mangaChapterImageRepository.saveAll(chapterImages); + + chapter.setDownloaded(true); + mangaChapterRepository.save(chapter); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public MangaChapterArchiveDTO downloadChapter(Long chapterId, ArchiveFileType archiveFileType) + throws IOException { + var chapter = + mangaChapterRepository + .findById(chapterId) + .orElseThrow(() -> new RuntimeException("Chapter not found")); + + var chapterImages = mangaChapterImageRepository.findAllByMangaChapter(chapter); + + var byteArrayOutputStream = + switch (archiveFileType) { + case CBZ -> getChapterCbzArchive(chapterImages); + default -> throw new RuntimeException("Unsupported archive file type"); + }; + + return new MangaChapterArchiveDTO( + chapter.getTitle() + ".cbz", byteArrayOutputStream.toByteArray()); + } + + private ByteArrayOutputStream getChapterCbzArchive(List chapterImages) + throws IOException { + var byteArrayOutputStream = new ByteArrayOutputStream(); + var bufferedOutputStream = new BufferedOutputStream(byteArrayOutputStream); + var zipOutputStream = new ZipOutputStream(bufferedOutputStream); + + var totalPages = chapterImages.size(); + var paddingLength = String.valueOf(totalPages).length(); + + for (var pageNumber = 1; pageNumber <= totalPages; pageNumber++) { + var imgSrc = chapterImages.get(pageNumber - 1); + + var paddedFileName = String.format("%0" + paddingLength + "d.jpg", imgSrc.getPosition()); + + zipOutputStream.putNextEntry(new ZipEntry(paddedFileName)); + IOUtils.copy(imageService.getImageStream(imgSrc.getImage()), zipOutputStream); + zipOutputStream.closeEntry(); + } + + zipOutputStream.finish(); + zipOutputStream.flush(); + IOUtils.closeQuietly(zipOutputStream); + return byteArrayOutputStream; + } + + private void persistMangaChapters( + MangaProvider mangaProvider, ContentProviderMangaChapterResponseDTO chapter) { + var mangaChapter = + mangaChapterRepository + .findByMangaProviderAndUrlIgnoreCase(mangaProvider, chapter.chapterUrl()) + .orElseGet(MangaChapter::new); + + mangaChapter.setMangaProvider(mangaProvider); + mangaChapter.setTitle(chapter.chapterTitle()); + mangaChapter.setUrl(chapter.chapterUrl()); + + mangaChapterRepository.save(mangaChapter); + } + + public void downloadAllChapters(Long mangaProviderId) { + var mangaProvider = + mangaProviderRepository + .findById(mangaProviderId) + .orElseThrow(() -> new RuntimeException("Manga provider not found")); + + var mangaChapters = mangaChapterRepository.findByMangaProviderId(mangaProviderId); + + mangaChapters.forEach(mangaChapter -> fetchChapter(mangaChapter.getId())); + } + + public void updateInfo(Long mangaId) { + var manga = + mangaRepository + .findById(mangaId) + .orElseThrow(() -> new RuntimeException("Manga not found")); + + var mangaSearchResponse = jikanClient.mangaSearch(manga.getTitle()); + if (mangaSearchResponse.data().isEmpty()) { + throw new RuntimeException("Manga not found"); + } + + // TODO: create logic to select appropriate manga + var mangaResponse = mangaSearchResponse.data().getFirst(); + manga.setTitle(mangaResponse.title()); + manga.setMalId(mangaResponse.mal_id()); + + mangaRepository.save(manga); + } + + public MangaDTO getManga(Long mangaId) { + var manga = + mangaRepository + .findById(mangaId) + .orElseThrow(() -> new RuntimeException("Manga not found")); + + return MangaDTO.from(manga); + } + + public void fetchMangaChapters(Long mangaProviderId) { + var mangaProvider = + mangaProviderRepository + .findById(mangaProviderId) + .orElseThrow(() -> new RuntimeException("manga provider not found")); + + var contentProvider = + contentProviderFactory.getContentProvider(mangaProvider.getProvider().getName()); + var availableChapters = contentProvider.getAvailableChapters(mangaProvider); + + availableChapters.forEach(chapter -> persistMangaChapters(mangaProvider, chapter)); + } + + public MangaChapterImagesDTO getMangaChapterImages(Long chapterId) { + var chapter = + mangaChapterRepository + .findById(chapterId) + .orElseThrow(() -> new RuntimeException("Chapter not found")); + + return MangaChapterImagesDTO.from(chapter); + } + + public void markAsRead(Long chapterId) { + var chapter = + mangaChapterRepository + .findById(chapterId) + .orElseThrow(() -> new RuntimeException("Chapter not found")); + chapter.setRead(true); + + mangaChapterRepository.save(chapter); + } +} diff --git a/src/main/java/com/magamochi/mangamochi/service/S3Service.java b/src/main/java/com/magamochi/mangamochi/service/S3Service.java new file mode 100644 index 0000000..eee40cc --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/service/S3Service.java @@ -0,0 +1,37 @@ +package com.magamochi.mangamochi.service; + +import java.io.InputStream; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +@Service +@RequiredArgsConstructor +public class S3Service { + @Value("${minio.bucket}") + private String bucket; + + private final S3Client s3Client; + + public String uploadFile(byte[] data, String contentType, String path) { + var filename = "manga/" + path + "/" + UUID.randomUUID(); + + var request = + PutObjectRequest.builder().bucket(bucket).key(filename).contentType(contentType).build(); + + s3Client.putObject(request, RequestBody.fromBytes(data)); + + return filename; + } + + public InputStream getFile(String key) { + var request = GetObjectRequest.builder().bucket(bucket).key(key).build(); + + return s3Client.getObject(request); + } +} diff --git a/src/main/java/com/magamochi/mangamochi/service/WebScrapperClientProxyService.java b/src/main/java/com/magamochi/mangamochi/service/WebScrapperClientProxyService.java new file mode 100644 index 0000000..57faa73 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/service/WebScrapperClientProxyService.java @@ -0,0 +1,24 @@ +package com.magamochi.mangamochi.service; + +import com.magamochi.mangamochi.client.WebScrapperClient; +import com.magamochi.mangamochi.model.dto.WebScrapperClientRequestDTO; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class WebScrapperClientProxyService { + private final WebScrapperClient webScrapperClient; + + public Document scrapeToJsoupDocument(String url) throws IOException { + var htmlContent = scrape(url); + return Jsoup.parse(htmlContent); + } + + private String scrape(String url) { + return webScrapperClient.scrape(new WebScrapperClientRequestDTO(url)).page_source(); + } +} diff --git a/src/main/java/com/magamochi/mangamochi/service/providers/ContentProvider.java b/src/main/java/com/magamochi/mangamochi/service/providers/ContentProvider.java new file mode 100644 index 0000000..c239d12 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/service/providers/ContentProvider.java @@ -0,0 +1,15 @@ +package com.magamochi.mangamochi.service.providers; + +import com.magamochi.mangamochi.model.dto.ContentProviderMangaChapterResponseDTO; +import com.magamochi.mangamochi.model.dto.ContentProviderMangaInfoResponseDTO; +import com.magamochi.mangamochi.model.entity.MangaProvider; +import java.util.List; +import java.util.Map; + +public interface ContentProvider { + List getAvailableMangas(); + + List getAvailableChapters(MangaProvider provider); + + Map getChapterImagesUrls(String chapterUrl); +} diff --git a/src/main/java/com/magamochi/mangamochi/service/providers/ContentProviderFactory.java b/src/main/java/com/magamochi/mangamochi/service/providers/ContentProviderFactory.java new file mode 100644 index 0000000..c3b6ef9 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/service/providers/ContentProviderFactory.java @@ -0,0 +1,24 @@ +package com.magamochi.mangamochi.service.providers; + +import java.util.Map; +import java.util.Objects; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Getter +@Component +@RequiredArgsConstructor +public class ContentProviderFactory { + private final Map contentProviders; + + public ContentProvider getContentProvider(String providerName) { + var provider = contentProviders.get(providerName); + + if (Objects.isNull(provider)) { + throw new IllegalArgumentException("No such provider " + providerName); + } + + return provider; + } +} diff --git a/src/main/java/com/magamochi/mangamochi/service/providers/ContentProviders.java b/src/main/java/com/magamochi/mangamochi/service/providers/ContentProviders.java new file mode 100644 index 0000000..af315c5 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/service/providers/ContentProviders.java @@ -0,0 +1,6 @@ +package com.magamochi.mangamochi.service.providers; + +public class ContentProviders { + public static final String MANGA_LIVRE = "Manga Livre"; + public static final String MANGA_LIVRE_BLOG = "Manga Livre Blog"; +} diff --git a/src/main/java/com/magamochi/mangamochi/service/providers/impl/MangaLivreBlogProvider.java b/src/main/java/com/magamochi/mangamochi/service/providers/impl/MangaLivreBlogProvider.java new file mode 100644 index 0000000..378c0a3 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/service/providers/impl/MangaLivreBlogProvider.java @@ -0,0 +1,170 @@ +package com.magamochi.mangamochi.service.providers.impl; + +import com.magamochi.mangamochi.model.dto.ContentProviderMangaChapterResponseDTO; +import com.magamochi.mangamochi.model.dto.ContentProviderMangaInfoResponseDTO; +import com.magamochi.mangamochi.model.entity.MangaProvider; +import com.magamochi.mangamochi.model.enumeration.MangaStatus; +import com.magamochi.mangamochi.service.WebScrapperClientProxyService; +import com.magamochi.mangamochi.service.providers.ContentProvider; +import com.magamochi.mangamochi.service.providers.ContentProviders; +import java.io.IOException; +import java.util.*; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.jsoup.nodes.Element; +import org.springframework.stereotype.Service; + +@Log4j2 +@Service(ContentProviders.MANGA_LIVRE_BLOG) +@RequiredArgsConstructor +public class MangaLivreBlogProvider implements ContentProvider { + private static final Pattern NUMERIC_PATTERN = Pattern.compile("-?\\d+"); + + private final String url = "https://mangalivre.blog/manga/"; + + private final WebScrapperClientProxyService webScrapperClientProxyService; + + @Override + public List getAvailableMangas() { + var totalPages = getTotalPages(); + + if (Objects.isNull(totalPages) || totalPages < 1) { + return List.of(); + } + + return IntStream.rangeClosed(1, totalPages) + .mapToObj(this::getMangasFromPage) + .filter(Objects::nonNull) + .flatMap(List::stream) + .toList(); + } + + @Override + public List getAvailableChapters( + MangaProvider mangaProvider) { + try { + var document = webScrapperClientProxyService.scrapeToJsoupDocument(mangaProvider.getUrl()); + + var chapterList = document.getElementsByClass("chapters-list").getFirst(); + var chapterItems = chapterList.getElementsByClass("chapter-item"); + + return chapterItems.stream() + .map( + chapterItemElement -> { + var chapterDetailsContainer = + chapterItemElement.getElementsByClass("chapter-details").getFirst(); + var linkElement = chapterDetailsContainer.getElementsByTag("a").getFirst(); + var chapterNumberElement = + linkElement.getElementsByClass("chapter-number").getFirst(); + + return new ContentProviderMangaChapterResponseDTO( + chapterNumberElement.text(), linkElement.attr("href")); + }) + .toList(); + } catch (IOException | NoSuchElementException e) { + log.error("Error fetching mangas from MangaLivre", e); + return List.of(); + } + } + + @Override + public Map getChapterImagesUrls(String chapterUrl) { + try { + var document = webScrapperClientProxyService.scrapeToJsoupDocument(chapterUrl); + + var chapterImageContainers = document.getElementsByClass("chapter-image-container"); + var imageUrls = + chapterImageContainers.stream() + .map( + chapterImageContainerElement -> { + var pageNumber = chapterImageContainerElement.id(); + var imageElement = + chapterImageContainerElement.getElementsByTag("img").getFirst(); + return imageElement.attr("data-lazy-src"); + }) + .toList(); + + return IntStream.range(0, imageUrls.size()) + .boxed() + .collect( + Collectors.toMap( + i -> i, imageUrls::get, (existing, replacement) -> existing, LinkedHashMap::new)); + } catch (IOException | NoSuchElementException e) { + log.error("Error fetching mangas from MangaLivre", e); + return Map.of(); + } + } + + private List getMangasFromPage(int page) { + try { + var document = webScrapperClientProxyService.scrapeToJsoupDocument(url + "page/" + page); + + var mangaGrid = document.getElementsByClass("manga-grid").getFirst(); + var mangaElements = mangaGrid.getElementsByTag("article"); + + return mangaElements.stream() + .map( + element -> { + try { + var linkElement = element.getElementsByTag("a").getFirst(); + + var imageContainer = + linkElement.getElementsByClass("manga-card-image").getFirst(); + var contentContainer = + linkElement.getElementsByClass("manga-card-content").getFirst(); + + var imageUrl = + imageContainer + .getElementsByClass("attachment-manga-cover") + .getFirst() + .attr("data-lazy-src"); + + var title = contentContainer.getElementsByTag("h3").text(); + var url = linkElement.attr("href"); + var status = + switch (imageContainer + .getElementsByClass("manga-status") + .text() + .toLowerCase()) { + case "em andamento" -> MangaStatus.ONGOING; + case "completo" -> MangaStatus.COMPLETED; + case "hiato" -> MangaStatus.HIATUS; + default -> MangaStatus.UNKNOWN; + }; + + return new ContentProviderMangaInfoResponseDTO(title, url, imageUrl, status); + } catch (Exception e) { + return null; + } + }) + .filter(Objects::nonNull) + .toList(); + } catch (IOException | NoSuchElementException e) { + log.error("Error fetching mangas from MangaLivre", e); + return List.of(); + } + } + + private Integer getTotalPages() { + try { + var document = webScrapperClientProxyService.scrapeToJsoupDocument(url); + + var navLinks = document.getElementsByClass("nav-links").getFirst(); + var links = navLinks.getElementsByTag("a"); + + var pageNumbers = + links.stream() + .map(Element::text) + .filter(NUMERIC_PATTERN.asMatchPredicate()) + .map(Integer::parseInt) + .toList(); + return pageNumbers.stream().max(Integer::compareTo).orElse(null); + } catch (IOException | NoSuchElementException e) { + log.error("Error fetching total pages from MangaLivre", e); + return null; + } + } +} diff --git a/src/main/java/com/magamochi/mangamochi/service/providers/impl/MangaLivreProvider.java b/src/main/java/com/magamochi/mangamochi/service/providers/impl/MangaLivreProvider.java new file mode 100644 index 0000000..8027434 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/service/providers/impl/MangaLivreProvider.java @@ -0,0 +1,152 @@ +package com.magamochi.mangamochi.service.providers.impl; + +import com.magamochi.mangamochi.model.dto.ContentProviderMangaChapterResponseDTO; +import com.magamochi.mangamochi.model.dto.ContentProviderMangaInfoResponseDTO; +import com.magamochi.mangamochi.model.entity.MangaProvider; +import com.magamochi.mangamochi.model.enumeration.MangaStatus; +import com.magamochi.mangamochi.service.WebScrapperClientProxyService; +import com.magamochi.mangamochi.service.providers.ContentProvider; +import com.magamochi.mangamochi.service.providers.ContentProviders; +import java.io.IOException; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Service; + +@Log4j2 +@Service(ContentProviders.MANGA_LIVRE) +@RequiredArgsConstructor +public class MangaLivreProvider implements ContentProvider { + private final String url = "https://mangalivre.tv/manga/"; + + private final WebScrapperClientProxyService webScrapperClientProxyService; + + @Override + public List getAvailableMangas() { + var totalPages = getTotalPages(); + + if (Objects.isNull(totalPages) || totalPages < 1) { + return List.of(); + } + + return IntStream.rangeClosed(1, totalPages) + .mapToObj(this::getMangasFromPage) + .filter(Objects::nonNull) + .flatMap(List::stream) + .toList(); + } + + @Override + public List getAvailableChapters(MangaProvider provider) { + try { + var document = webScrapperClientProxyService.scrapeToJsoupDocument(provider.getUrl()); + + var chapterItems = document.getElementsByClass("wp-manga-chapter"); + + return chapterItems.stream() + .map( + chapterItemElement -> { + var linkElement = chapterItemElement.getElementsByTag("a").getFirst(); + + return new ContentProviderMangaChapterResponseDTO( + linkElement.text(), linkElement.attr("href")); + }) + .toList(); + } catch (NoSuchElementException | IOException e) { + log.error("Error parsing mangas from MangaLivre", e); + return List.of(); + } + } + + @Override + public Map getChapterImagesUrls(String chapterUrl) { + try { + var document = webScrapperClientProxyService.scrapeToJsoupDocument(chapterUrl); + + var chapterImagesContainer = document.getElementsByClass("chapter-images").getFirst(); + var chapterImagesElements = chapterImagesContainer.getElementsByClass("page-break"); + + var imageUrls = + chapterImagesElements.stream() + .map( + chapterImagesElement -> { + var imageElement = chapterImagesElement.getElementsByTag("img").getFirst(); + return imageElement.attr("src"); + }) + .toList(); + + return IntStream.range(0, imageUrls.size()) + .boxed() + .collect( + Collectors.toMap( + i -> i, imageUrls::get, (existing, replacement) -> existing, LinkedHashMap::new)); + } catch (NoSuchElementException | IOException e) { + log.error("Error parsing mangas from MangaLivre", e); + return Map.of(); + } + } + + private List getMangasFromPage(int page) { + try { + var document = webScrapperClientProxyService.scrapeToJsoupDocument(url + "page/" + page); + + var mangaElements = document.getElementsByClass("manga__item"); + + return mangaElements.stream() + .map( + element -> { + var mangaTitleElement = + element + .getElementsByClass("manga__content") + .getFirst() + .getElementsByClass("manga__content_item") + .getFirst() + .getElementsByClass("post-title font-title") + .getFirst() + .getElementsByTag("h2") + .getFirst(); + + var linkElement = mangaTitleElement.getElementsByTag("a").getFirst(); + var url = linkElement.attr("href"); + var title = linkElement.text().trim(); + + var imageElement = + element + .getElementsByClass("manga__thumb") + .getFirst() + .getElementsByClass("manga__thumb_item") + .getFirst() + .getElementsByTag("a") + .getFirst() + .getElementsByTag("img") + .getFirst(); + var imgUrl = imageElement.attr("src"); + + return new ContentProviderMangaInfoResponseDTO( + title, url, imgUrl, MangaStatus.UNKNOWN); + }) + .toList(); + } catch (NoSuchElementException | IOException e) { + log.error("Error parsing mangas from MangaLivre", e); + return List.of(); + } + } + + private Integer getTotalPages() { + try { + var document = webScrapperClientProxyService.scrapeToJsoupDocument(url); + + var navLinks = document.getElementsByClass("wp-pagenavi").getFirst(); + var lastPageElement = navLinks.getElementsByClass("last").getFirst(); + var links = lastPageElement.attr("href"); + + var totalPages = links.replaceAll("\\D+", ""); + return Integer.parseInt(totalPages); + } catch (NoSuchElementException | IOException e) { + log.error("Error parsing total pages from MangaLivre", e); + return null; + } + } +} diff --git a/src/main/java/com/magamochi/mangamochi/task/UpdateMangaDataTask.java b/src/main/java/com/magamochi/mangamochi/task/UpdateMangaDataTask.java new file mode 100644 index 0000000..80580f3 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/task/UpdateMangaDataTask.java @@ -0,0 +1,142 @@ +package com.magamochi.mangamochi.task; + +import static java.util.Objects.isNull; + +import com.google.common.util.concurrent.RateLimiter; +import com.magamochi.mangamochi.client.JikanClient; +import com.magamochi.mangamochi.model.entity.Author; +import com.magamochi.mangamochi.model.entity.Genre; +import com.magamochi.mangamochi.model.entity.MangaAuthor; +import com.magamochi.mangamochi.model.entity.MangaGenre; +import com.magamochi.mangamochi.model.repository.*; +import com.magamochi.mangamochi.service.ImageService; +import java.io.BufferedInputStream; +import java.net.URI; +import java.net.URL; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Component; + +@Log4j2 +@Component +@RequiredArgsConstructor +public class UpdateMangaDataTask { + private final AuthorRepository authorRepository; + private final MangaAuthorRepository mangaAuthorRepository; + private final MangaRepository mangaRepository; + + private final JikanClient jikanClient; + + private final ImageService imageService; + private final GenreRepository genreRepository; + private final MangaGenreRepository mangaGenreRepository; + + // @Scheduled(fixedDelayString = "1d") + public void updateMangaData() { + var rateLimiter = RateLimiter.create(1); + + var mangas = + mangaRepository.findAll().stream().filter(manga -> isNull(manga.getCoverImage())).toList(); + + mangas.forEach( + manga -> { + log.info("Updating manga {}", manga.getTitle()); + + try { + rateLimiter.acquire(); + var mangaData = jikanClient.getMangaById(manga.getMalId()); + + manga.setAlternativeTitles(mangaData.data().title_synonyms()); + manga.setSynopsis(mangaData.data().synopsis()); + manga.setStatus(mangaData.data().status()); + // manga.setScore(mangaData.data().score()); + manga.setPublishedFrom(mangaData.data().published().from()); + manga.setPublishedTo(mangaData.data().published().to()); + + var authors = + mangaData.data().authors().stream() + .map( + authorData -> { + return authorRepository + .findByMalId(authorData.mal_id()) + .orElseGet( + () -> + authorRepository.save( + Author.builder() + .malId(authorData.mal_id()) + .name(authorData.name()) + .build())); + }) + .toList(); + + var mangaAuthors = + authors.stream() + .map( + author -> { + return mangaAuthorRepository + .findByMangaAndAuthor(manga, author) + .orElseGet( + () -> + mangaAuthorRepository.save( + MangaAuthor.builder() + .manga(manga) + .author(author) + .build())); + }) + .toList(); + + manga.setMangaAuthors(mangaAuthors); + + var genres = + mangaData.data().genres().stream() + .map( + genreData -> { + return genreRepository + .findByMalId(genreData.mal_id()) + .orElseGet( + () -> + genreRepository.save( + Genre.builder() + .malId(genreData.mal_id()) + .name(genreData.name()) + .build())); + }) + .toList(); + + var mangaGenres = + genres.stream() + .map( + genre -> { + return mangaGenreRepository + .findByMangaAndGenre(manga, genre) + .orElseGet( + () -> + mangaGenreRepository.save( + MangaGenre.builder().manga(manga).genre(genre).build())); + }) + .toList(); + + manga.setMangaGenres(mangaGenres); + + var inputStream = + new BufferedInputStream( + new URL( + new URI(mangaData.data().images().jpg().large_image_url()) + .toASCIIString()) + .openStream()); + + var bytes = inputStream.readAllBytes(); + + inputStream.close(); + var image = imageService.uploadImage(bytes, "image/jpeg", "cover"); + + manga.setCoverImage(image); + + mangaRepository.save(manga); + + } catch (Exception e) { + log.warn("Error updating manga data for manga {}. {}", manga.getTitle(), e); + } + }); + } +} diff --git a/src/main/java/com/magamochi/mangamochi/task/UpdateMangaListTask.java b/src/main/java/com/magamochi/mangamochi/task/UpdateMangaListTask.java new file mode 100644 index 0000000..ddc8c3e --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/task/UpdateMangaListTask.java @@ -0,0 +1,33 @@ +package com.magamochi.mangamochi.task; + +import com.magamochi.mangamochi.service.MangaListService; +import com.magamochi.mangamochi.service.providers.ContentProvider; +import com.magamochi.mangamochi.service.providers.ContentProviderFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.stereotype.Component; + +@Log4j2 +@Component +@RequiredArgsConstructor +public class UpdateMangaListTask { + private final ContentProviderFactory contentProviderFactory; + private final MangaListService mangaListService; + + // @Scheduled(fixedDelayString = "1d") + public void updateMangaList() { + log.info("Updating manga list..."); + + var contentProviders = contentProviderFactory.getContentProviders(); + contentProviders.forEach(this::updateProviderMangaList); + + log.info("Manga list updated."); + } + + private void updateProviderMangaList( + String contentProviderName, ContentProvider contentProvider) { + log.info("Updating manga list for content provider {}", contentProviderName); + mangaListService.updateMangaList(contentProviderName, contentProvider.getAvailableMangas()); + log.info("Manga list for content provider {} updated.", contentProviderName); + } +} diff --git a/src/main/java/com/magamochi/mangamochi/util/JwtUtil.java b/src/main/java/com/magamochi/mangamochi/util/JwtUtil.java new file mode 100644 index 0000000..6c4311e --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/util/JwtUtil.java @@ -0,0 +1,68 @@ +package com.magamochi.mangamochi.util; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import java.security.Key; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +@Component +public class JwtUtil { + @Value("${jwt.secret}") + private String secret; + + @Value("${jwt.expiration}") + private Long expiration; + + private Key getSigningKey() { + return Keys.hmacShaKeyFor(secret.getBytes()); + } + + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); + } + + public Date extractExpiration(String token) { + return extractClaim(token, Claims::getExpiration); + } + + public T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + private Claims extractAllClaims(String token) { + return Jwts.parser().setSigningKey(getSigningKey()).build().parseClaimsJws(token).getBody(); + } + + private Boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } + + public String generateToken(UserDetails userDetails) { + Map claims = new HashMap<>(); + return createToken(claims, userDetails.getUsername()); + } + + private String createToken(Map claims, String subject) { + return Jwts.builder() + .setClaims(claims) + .setSubject(subject) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + expiration)) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .compact(); + } + + public Boolean validateToken(String token, UserDetails userDetails) { + final String username = extractUsername(token); + return (username.equals(userDetails.getUsername()) && !isTokenExpired(token)); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..e5b5bdd --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,3 @@ +spring: + config: + import: optional:file:.env[.properties] \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..e7e8224 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,34 @@ +spring: + application: + name: mangamochi + datasource: + url: ${DB_URL} + username: ${DB_USER} + password: ${DB_PASS} + jpa: + properties: + hibernate: + default_schema: mangamochi + flyway: + enabled: true + schemas: + - mangamochi + default-schema: mangamochi + +springdoc: + api-docs: + path: /api-docs + +web-scrapper: + endpoint: ${WEBSCRAPPER_ENDPOINT} + +minio: + endpoint: ${MINIO_ENDPOINT} + accessKey: ${MINIO_USER} + secretKey: ${MINIO_PASS} + bucket: mangamochi + +# JWT Configuration +jwt: + secret: mySecretKeymySecretKeymySecretKeymySecretKeymySecretKeymySecretKey + expiration: 86400000 # 24 hours in milliseconds diff --git a/src/main/resources/db/migration/V0001__IMAGES_TABLE.sql b/src/main/resources/db/migration/V0001__IMAGES_TABLE.sql new file mode 100644 index 0000000..f501dbf --- /dev/null +++ b/src/main/resources/db/migration/V0001__IMAGES_TABLE.sql @@ -0,0 +1,7 @@ +CREATE TABLE images +( + id UUID NOT NULL PRIMARY KEY, + file_key VARCHAR NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/src/main/resources/db/migration/V0002__MANGA.sql b/src/main/resources/db/migration/V0002__MANGA.sql new file mode 100644 index 0000000..ba54fe3 --- /dev/null +++ b/src/main/resources/db/migration/V0002__MANGA.sql @@ -0,0 +1,11 @@ +CREATE TABLE mangas +( + id BIGSERIAL NOT NULL PRIMARY KEY, + mal_id BIGINT UNIQUE, + title VARCHAR, + alternative_titles TEXT[], + status VARCHAR, + synopsis VARCHAR, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/src/main/resources/db/migration/V0003__PROVIDER.sql b/src/main/resources/db/migration/V0003__PROVIDER.sql new file mode 100644 index 0000000..95add80 --- /dev/null +++ b/src/main/resources/db/migration/V0003__PROVIDER.sql @@ -0,0 +1,21 @@ +CREATE TABLE providers +( + id BIGSERIAL NOT NULL PRIMARY KEY, + name VARCHAR NOT NULL, + status VARCHAR NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE manga_provider +( + id BIGSERIAL NOT NULL PRIMARY KEY, + manga_id BIGINT NOT NULL REFERENCES mangas (id) ON DELETE CASCADE, + provider_id BIGINT NOT NULL REFERENCES providers (id) ON DELETE CASCADE, + manga_title VARCHAR NOT NULL, + url VARCHAR NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (manga_id, provider_id) +) + diff --git a/src/main/resources/db/migration/V0004__CHAPTER.sql b/src/main/resources/db/migration/V0004__CHAPTER.sql new file mode 100644 index 0000000..b809161 --- /dev/null +++ b/src/main/resources/db/migration/V0004__CHAPTER.sql @@ -0,0 +1,20 @@ +CREATE TABLE manga_chapters +( + id BIGSERIAL NOT NULL PRIMARY KEY, + manga_provider_id BIGINT NOT NULL REFERENCES manga_provider (id) ON DELETE CASCADE, + title VARCHAR NOT NULL, + url VARCHAR NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE manga_chapter_images +( + id BIGSERIAL NOT NULL PRIMARY KEY, + manga_chapter_id BIGINT NOT NULL REFERENCES manga_chapters (id) ON DELETE CASCADE, + image_id UUID REFERENCES images (id) ON DELETE CASCADE, + position INT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +) + diff --git a/src/main/resources/db/migration/V0005__MANGA_IMPORT_REVIEW.sql b/src/main/resources/db/migration/V0005__MANGA_IMPORT_REVIEW.sql new file mode 100644 index 0000000..a470787 --- /dev/null +++ b/src/main/resources/db/migration/V0005__MANGA_IMPORT_REVIEW.sql @@ -0,0 +1,8 @@ +CREATE TABLE manga_import_reviews +( + id BIGSERIAL NOT NULL PRIMARY KEY, + provider_id BIGINT NOT NULL REFERENCES providers (id) ON DELETE CASCADE, + title VARCHAR NOT NULL, + url VARCHAR NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/src/main/resources/db/migration/V0006__MANGA_DATA.sql b/src/main/resources/db/migration/V0006__MANGA_DATA.sql new file mode 100644 index 0000000..07855df --- /dev/null +++ b/src/main/resources/db/migration/V0006__MANGA_DATA.sql @@ -0,0 +1,5 @@ +ALTER TABLE mangas + ADD COLUMN cover_image_id UUID REFERENCES images (id), + ADD COLUMN score DOUBLE PRECISION, + ADD COLUMN published_from TIMESTAMPTZ, + ADD COLUMN published_to TIMESTAMPTZ; \ No newline at end of file diff --git a/src/main/resources/db/migration/V0007__MANGA_DATA.sql b/src/main/resources/db/migration/V0007__MANGA_DATA.sql new file mode 100644 index 0000000..423884e --- /dev/null +++ b/src/main/resources/db/migration/V0007__MANGA_DATA.sql @@ -0,0 +1,31 @@ +CREATE TABLE authors +( + id BIGSERIAL NOT NULL PRIMARY KEY, + mal_id BIGINT UNIQUE, + name VARCHAR, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE manga_author +( + id BIGSERIAL NOT NULL PRIMARY KEY, + manga_id BIGINT REFERENCES mangas (id), + author_id BIGINT REFERENCES authors (id), + UNIQUE (manga_id, author_id) +); + +CREATE TABLE genres +( + id BIGSERIAL NOT NULL PRIMARY KEY, + mal_id BIGINT UNIQUE, + name VARCHAR +); + +CREATE TABLE manga_genre +( + id BIGSERIAL NOT NULL PRIMARY KEY, + manga_id BIGINT REFERENCES mangas (id), + genre_id BIGINT REFERENCES genres (id), + UNIQUE (manga_id, genre_id) +); \ No newline at end of file diff --git a/src/main/resources/db/migration/V0008__MANGA_CHAPTERS.sql b/src/main/resources/db/migration/V0008__MANGA_CHAPTERS.sql new file mode 100644 index 0000000..38a5d65 --- /dev/null +++ b/src/main/resources/db/migration/V0008__MANGA_CHAPTERS.sql @@ -0,0 +1 @@ +ALTER TABLE manga_chapters ADD COLUMN downloaded BOOLEAN DEFAULT FALSE; \ No newline at end of file diff --git a/src/main/resources/db/migration/V0009__MANGA_CHAPTERS.sql b/src/main/resources/db/migration/V0009__MANGA_CHAPTERS.sql new file mode 100644 index 0000000..919388c --- /dev/null +++ b/src/main/resources/db/migration/V0009__MANGA_CHAPTERS.sql @@ -0,0 +1 @@ +ALTER TABLE manga_chapters ADD COLUMN read BOOLEAN DEFAULT FALSE; \ No newline at end of file diff --git a/src/main/resources/db/migration/V0010__USERS.sql b/src/main/resources/db/migration/V0010__USERS.sql new file mode 100644 index 0000000..ed99c03 --- /dev/null +++ b/src/main/resources/db/migration/V0010__USERS.sql @@ -0,0 +1,6 @@ +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + username VARCHAR NOT NULL UNIQUE, + password VARCHAR NOT NULL, + role VARCHAR +); \ No newline at end of file diff --git a/src/test/java/com/magamochi/mangamochi/MangamochiApplicationTests.java b/src/test/java/com/magamochi/mangamochi/MangamochiApplicationTests.java new file mode 100644 index 0000000..4e942f5 --- /dev/null +++ b/src/test/java/com/magamochi/mangamochi/MangamochiApplicationTests.java @@ -0,0 +1,22 @@ +package com.magamochi.mangamochi; + +import com.magamochi.mangamochi.client.JikanClient; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.openfeign.EnableFeignClients; + +@Log4j2 +@SpringBootTest +@RequiredArgsConstructor +@EnableFeignClients +class MangamochiApplicationTests { + private final JikanClient jikanClient; + + @Test + void testJikan() { + var response = jikanClient.mangaSearch("Saint Seiya"); + log.info(response.toString()); + } +}