From ad767e021e6808dcf1c60fd20386a649a134eef0 Mon Sep 17 00:00:00 2001 From: Rodrigo Verdiani Date: Tue, 21 Oct 2025 22:32:18 -0300 Subject: [PATCH] feat: add login and favorite manga functionalities --- .env | 2 +- .gitignore | 1 + .../mangamochi/config/SecurityConfig.java | 103 ++++++++---------- .../controller/AuthenticationController.java | 58 +++------- .../controller/MangaController.java | 10 +- .../UserFavoriteMangaController.java | 33 ++++++ .../model/dto/AuthenticationRequestDTO.java | 4 +- .../model/dto/AuthenticationResponseDTO.java | 10 +- .../mangamochi/model/dto/MangaListDTO.java | 8 +- .../model/dto/MangaListFilterDTO.java | 6 + .../model/dto/RegistrationRequestDTO.java | 3 + .../mangamochi/model/entity/Manga.java | 3 + .../mangamochi/model/entity/User.java | 13 ++- .../model/entity/UserFavoriteManga.java | 29 +++++ .../model/enumeration/UserRole.java | 5 + .../UserFavoriteMangaRepository.java | 16 +++ .../model/repository/UserRepository.java | 4 +- .../specification/MangaSpecification.java | 68 +++++++----- .../mangamochi/security/JwtRequestFilter.java | 64 +++++------ .../service/CustomUserDetailsService.java | 6 +- .../mangamochi/service/MangaService.java | 49 ++++++--- .../service/UserFavoriteMangaService.java | 46 ++++++++ .../mangamochi/service/UserService.java | 84 ++++++++++++++ .../impl/MangaLivreBlogProvider.java | 15 ++- .../resources/db/migration/V0010__USERS.sql | 3 +- .../migration/V0011__USER_FAVORITE_MANGA.sql | 8 ++ 26 files changed, 458 insertions(+), 193 deletions(-) create mode 100644 src/main/java/com/magamochi/mangamochi/controller/UserFavoriteMangaController.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/dto/MangaListFilterDTO.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/dto/RegistrationRequestDTO.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/entity/UserFavoriteManga.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/enumeration/UserRole.java create mode 100644 src/main/java/com/magamochi/mangamochi/model/repository/UserFavoriteMangaRepository.java create mode 100644 src/main/java/com/magamochi/mangamochi/service/UserFavoriteMangaService.java create mode 100644 src/main/java/com/magamochi/mangamochi/service/UserService.java create mode 100644 src/main/resources/db/migration/V0011__USER_FAVORITE_MANGA.sql diff --git a/.env b/.env index db588fb..715a91d 100644 --- a/.env +++ b/.env @@ -6,4 +6,4 @@ 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 +WEBSCRAPPER_ENDPOINT=http://localhost:8090/url \ No newline at end of file diff --git a/.gitignore b/.gitignore index f844340..6ffd5a5 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ target/ # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 # User-specific stuff +.idea/** .idea/**/workspace.xml .idea/**/tasks.xml .idea/**/usage.statistics.xml diff --git a/src/main/java/com/magamochi/mangamochi/config/SecurityConfig.java b/src/main/java/com/magamochi/mangamochi/config/SecurityConfig.java index 12abe90..26125c0 100644 --- a/src/main/java/com/magamochi/mangamochi/config/SecurityConfig.java +++ b/src/main/java/com/magamochi/mangamochi/config/SecurityConfig.java @@ -3,8 +3,8 @@ package com.magamochi.mangamochi.config; import com.magamochi.mangamochi.security.JwtRequestFilter; import com.magamochi.mangamochi.service.CustomUserDetailsService; import com.magamochi.mangamochi.util.JwtUtil; +import java.util.List; 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; @@ -21,71 +21,62 @@ 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; + private final CustomUserDetailsService userDetailsService; + private final JwtUtil jwtUtil; - @Bean - public JwtRequestFilter jwtRequestFilter() { - return new JwtRequestFilter(jwtUtil, userDetailsService); - } + @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 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 AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) + throws Exception { + return authConfig.getAuthenticationManager(); + } - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } + @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); + @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.anyRequest().permitAll()) + .headers(headers -> headers.frameOptions(frame -> frame.disable())) + .authenticationProvider(authenticationProvider()) + .addFilterBefore(jwtRequestFilter(), UsernamePasswordAuthenticationFilter.class); - return http.build(); - } + 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); + @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 + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/src/main/java/com/magamochi/mangamochi/controller/AuthenticationController.java b/src/main/java/com/magamochi/mangamochi/controller/AuthenticationController.java index 681c660..e6ba6ee 100644 --- a/src/main/java/com/magamochi/mangamochi/controller/AuthenticationController.java +++ b/src/main/java/com/magamochi/mangamochi/controller/AuthenticationController.java @@ -2,15 +2,11 @@ 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.dto.RegistrationRequestDTO; import com.magamochi.mangamochi.model.repository.UserRepository; -import com.magamochi.mangamochi.util.JwtUtil; +import com.magamochi.mangamochi.service.UserService; +import io.swagger.v3.oas.annotations.Operation; 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.*; @@ -19,46 +15,28 @@ import org.springframework.web.bind.annotation.*; @CrossOrigin(origins = "*") @RequiredArgsConstructor public class AuthenticationController { - private final AuthenticationManager authenticationManager; - private final UserDetailsService userDetailsService; - private final JwtUtil jwtUtil; + private final UserService userService; private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; + @Operation( + summary = "Authenticate user", + description = "Authenticate user with email and password.", + tags = {"Auth"}, + operationId = "authenticateUser") @PostMapping("/login") - public ResponseEntity createAuthenticationToken( + public AuthenticationResponseDTO authenticateUser( @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)); + return userService.authenticate(authenticationRequestDTO); } + @Operation( + summary = "Register user", + description = "Register a new user.", + tags = {"Auth"}, + operationId = "registerUser") @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"); + public void registerUser(@RequestBody RegistrationRequestDTO registrationRequestDTO) { + userService.register(registrationRequestDTO); } } diff --git a/src/main/java/com/magamochi/mangamochi/controller/MangaController.java b/src/main/java/com/magamochi/mangamochi/controller/MangaController.java index 81c3809..4ce3671 100644 --- a/src/main/java/com/magamochi/mangamochi/controller/MangaController.java +++ b/src/main/java/com/magamochi/mangamochi/controller/MangaController.java @@ -1,11 +1,7 @@ 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.dto.*; 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; @@ -37,9 +33,9 @@ public class MangaController { operationId = "getMangas") @GetMapping public Page getMangas( - @ParameterObject MangaSpecification specification, + @ParameterObject MangaListFilterDTO filterDTO, @Parameter(hidden = true) @ParameterObject @PageableDefault Pageable pageable) { - return mangaService.getMangas(specification, pageable); + return mangaService.getMangas(filterDTO, pageable); } @Operation( diff --git a/src/main/java/com/magamochi/mangamochi/controller/UserFavoriteMangaController.java b/src/main/java/com/magamochi/mangamochi/controller/UserFavoriteMangaController.java new file mode 100644 index 0000000..0ed6e5c --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/controller/UserFavoriteMangaController.java @@ -0,0 +1,33 @@ +package com.magamochi.mangamochi.controller; + +import com.magamochi.mangamochi.service.UserFavoriteMangaService; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/mangas") +@RequiredArgsConstructor +public class UserFavoriteMangaController { + private final UserFavoriteMangaService userFavoriteMangaService; + + @Operation( + summary = "Favorite a manga", + description = "Set a manga as favorite for the logged user.", + tags = {"Favorite Mangas"}, + operationId = "setFavorite") + @PostMapping("/{id}/favorite") + public void setFavorite(@PathVariable Long id) { + userFavoriteMangaService.setFavorite(id); + } + + @Operation( + summary = "Unfavorite a manga", + description = "Remove a manga from favorites for the logged user.", + tags = {"Favorite Mangas"}, + operationId = "setUnfavorite") + @PostMapping("/{id}/unfavorite") + public void setUnfavorite(@PathVariable Long id) { + userFavoriteMangaService.setUnfavorite(id); + } +} diff --git a/src/main/java/com/magamochi/mangamochi/model/dto/AuthenticationRequestDTO.java b/src/main/java/com/magamochi/mangamochi/model/dto/AuthenticationRequestDTO.java index 5780b40..f5fada2 100644 --- a/src/main/java/com/magamochi/mangamochi/model/dto/AuthenticationRequestDTO.java +++ b/src/main/java/com/magamochi/mangamochi/model/dto/AuthenticationRequestDTO.java @@ -1,3 +1,5 @@ package com.magamochi.mangamochi.model.dto; -public record AuthenticationRequestDTO(String username, String password) {} +import jakarta.validation.constraints.NotNull; + +public record AuthenticationRequestDTO(@NotNull String email, @NotNull 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 index 7d2636a..0caf1e0 100644 --- a/src/main/java/com/magamochi/mangamochi/model/dto/AuthenticationResponseDTO.java +++ b/src/main/java/com/magamochi/mangamochi/model/dto/AuthenticationResponseDTO.java @@ -1,3 +1,11 @@ package com.magamochi.mangamochi.model.dto; -public record AuthenticationResponseDTO(String token, String username, String role) {} +import com.magamochi.mangamochi.model.enumeration.UserRole; +import jakarta.validation.constraints.NotNull; + +public record AuthenticationResponseDTO( + @NotNull Long id, + @NotNull String token, + @NotNull String email, + @NotNull String name, + @NotNull UserRole role) {} diff --git a/src/main/java/com/magamochi/mangamochi/model/dto/MangaListDTO.java b/src/main/java/com/magamochi/mangamochi/model/dto/MangaListDTO.java index 3b791ce..957eb75 100644 --- a/src/main/java/com/magamochi/mangamochi/model/dto/MangaListDTO.java +++ b/src/main/java/com/magamochi/mangamochi/model/dto/MangaListDTO.java @@ -18,8 +18,9 @@ public record MangaListDTO( Integer providerCount, @NotNull List genres, @NotNull List authors, - @NotNull Double score) { - public static MangaListDTO from(Manga manga) { + @NotNull Double score, + @NotNull Boolean favorite) { + public static MangaListDTO from(Manga manga, boolean favorite) { return new MangaListDTO( manga.getId(), manga.getTitle(), @@ -32,6 +33,7 @@ public record MangaListDTO( manga.getMangaAuthors().stream() .map(mangaAuthor -> mangaAuthor.getAuthor().getName()) .toList(), - 0.0); + 0.0, + favorite); } } diff --git a/src/main/java/com/magamochi/mangamochi/model/dto/MangaListFilterDTO.java b/src/main/java/com/magamochi/mangamochi/model/dto/MangaListFilterDTO.java new file mode 100644 index 0000000..e369468 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/dto/MangaListFilterDTO.java @@ -0,0 +1,6 @@ +package com.magamochi.mangamochi.model.dto; + +import java.util.List; + +public record MangaListFilterDTO( + String searchQuery, List genreIds, List statuses, Boolean userFavorites) {} diff --git a/src/main/java/com/magamochi/mangamochi/model/dto/RegistrationRequestDTO.java b/src/main/java/com/magamochi/mangamochi/model/dto/RegistrationRequestDTO.java new file mode 100644 index 0000000..55cb983 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/dto/RegistrationRequestDTO.java @@ -0,0 +1,3 @@ +package com.magamochi.mangamochi.model.dto; + +public record RegistrationRequestDTO(String name, String email, String password) {} diff --git a/src/main/java/com/magamochi/mangamochi/model/entity/Manga.java b/src/main/java/com/magamochi/mangamochi/model/entity/Manga.java index 80eb464..ef543d9 100644 --- a/src/main/java/com/magamochi/mangamochi/model/entity/Manga.java +++ b/src/main/java/com/magamochi/mangamochi/model/entity/Manga.java @@ -57,4 +57,7 @@ public class Manga { @OneToMany(mappedBy = "manga") private List mangaGenres; + + @OneToMany(mappedBy = "manga") + private List userFavoriteMangas; } diff --git a/src/main/java/com/magamochi/mangamochi/model/entity/User.java b/src/main/java/com/magamochi/mangamochi/model/entity/User.java index 0a52afe..b40f6ff 100644 --- a/src/main/java/com/magamochi/mangamochi/model/entity/User.java +++ b/src/main/java/com/magamochi/mangamochi/model/entity/User.java @@ -1,6 +1,8 @@ package com.magamochi.mangamochi.model.entity; +import com.magamochi.mangamochi.model.enumeration.UserRole; import jakarta.persistence.*; +import java.util.Set; import lombok.*; @Entity @@ -16,11 +18,18 @@ public class User { private Long id; @Column(unique = true, nullable = false) - private String username; + private String email; + + @Column(nullable = false) + private String name; @Column(nullable = false) private String password; @Column(nullable = false) - private String role; + @Enumerated(EnumType.STRING) + private UserRole role; + + @OneToMany(mappedBy = "user") + private Set favoriteMangas; } diff --git a/src/main/java/com/magamochi/mangamochi/model/entity/UserFavoriteManga.java b/src/main/java/com/magamochi/mangamochi/model/entity/UserFavoriteManga.java new file mode 100644 index 0000000..a684f17 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/entity/UserFavoriteManga.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 = "user_favorite_mangas") +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class UserFavoriteManga { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne + @JoinColumn(name = "manga_id") + private Manga manga; + + @CreationTimestamp private Instant createdAt; +} diff --git a/src/main/java/com/magamochi/mangamochi/model/enumeration/UserRole.java b/src/main/java/com/magamochi/mangamochi/model/enumeration/UserRole.java new file mode 100644 index 0000000..0823d20 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/enumeration/UserRole.java @@ -0,0 +1,5 @@ +package com.magamochi.mangamochi.model.enumeration; + +public enum UserRole { + USER +} diff --git a/src/main/java/com/magamochi/mangamochi/model/repository/UserFavoriteMangaRepository.java b/src/main/java/com/magamochi/mangamochi/model/repository/UserFavoriteMangaRepository.java new file mode 100644 index 0000000..5567b26 --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/model/repository/UserFavoriteMangaRepository.java @@ -0,0 +1,16 @@ +package com.magamochi.mangamochi.model.repository; + +import com.magamochi.mangamochi.model.entity.Manga; +import com.magamochi.mangamochi.model.entity.User; +import com.magamochi.mangamochi.model.entity.UserFavoriteManga; +import java.util.Optional; +import java.util.Set; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserFavoriteMangaRepository extends JpaRepository { + boolean existsByUserAndManga(User user, Manga manga); + + Optional findByUserAndManga(User user, Manga manga); + + Set findByUser(User user); +} diff --git a/src/main/java/com/magamochi/mangamochi/model/repository/UserRepository.java b/src/main/java/com/magamochi/mangamochi/model/repository/UserRepository.java index 62391db..6b31929 100644 --- a/src/main/java/com/magamochi/mangamochi/model/repository/UserRepository.java +++ b/src/main/java/com/magamochi/mangamochi/model/repository/UserRepository.java @@ -5,7 +5,7 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface UserRepository extends JpaRepository { - Optional findByUsername(String username); + boolean existsByEmail(String email); - boolean existsByUsername(String username); + Optional findByEmail(String email); } diff --git a/src/main/java/com/magamochi/mangamochi/model/specification/MangaSpecification.java b/src/main/java/com/magamochi/mangamochi/model/specification/MangaSpecification.java index 30e8be7..3059858 100644 --- a/src/main/java/com/magamochi/mangamochi/model/specification/MangaSpecification.java +++ b/src/main/java/com/magamochi/mangamochi/model/specification/MangaSpecification.java @@ -2,53 +2,65 @@ package com.magamochi.mangamochi.model.specification; import static java.util.Objects.nonNull; +import com.magamochi.mangamochi.model.dto.MangaListFilterDTO; import com.magamochi.mangamochi.model.entity.Author; import com.magamochi.mangamochi.model.entity.Manga; +import com.magamochi.mangamochi.model.entity.User; import jakarta.persistence.criteria.*; import java.util.ArrayList; import java.util.List; -import lombok.NonNull; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; 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<>(); +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class MangaSpecification { + public static Specification getMangaListSpecification( + MangaListFilterDTO filterDTO, User loggedUser) { + return (root, query, criteriaBuilder) -> { + List predicates = new ArrayList<>(); - if (StringUtils.isNotBlank(searchQuery)) { - var searchPattern = "%" + searchQuery.toLowerCase() + "%"; + if (StringUtils.isNotBlank(filterDTO.searchQuery())) { + var searchPattern = "%" + filterDTO.searchQuery().toLowerCase() + "%"; - var titlePredicate = - criteriaBuilder.like(criteriaBuilder.lower(root.get("title")), searchPattern); + var titlePredicate = + criteriaBuilder.like(criteriaBuilder.lower(root.get("title")), searchPattern); - var authorsPredicate = searchInAuthors(root, query, criteriaBuilder, searchPattern); + var authorsPredicate = searchInAuthors(root, query, criteriaBuilder, searchPattern); - var searchPredicate = criteriaBuilder.or(titlePredicate, authorsPredicate); + var searchPredicate = criteriaBuilder.or(titlePredicate, authorsPredicate); - predicates.add(searchPredicate); - } + 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(filterDTO.genreIds()) && !filterDTO.genreIds().isEmpty()) { + var genreJoin = root.join("mangaGenres", JoinType.LEFT); + predicates.add(genreJoin.get("genre").get("id").in(filterDTO.genreIds())); + } - if (nonNull(statuses) && !statuses.isEmpty()) { - predicates.add( - criteriaBuilder - .lower(root.get("status")) - .in(statuses.stream().map(String::toLowerCase).toList())); - } + if (nonNull(filterDTO.statuses()) && !filterDTO.statuses().isEmpty()) { + predicates.add( + criteriaBuilder + .lower(root.get("status")) + .in(filterDTO.statuses().stream().map(String::toLowerCase).toList())); + } - query.distinct(true); + if (nonNull(filterDTO.userFavorites()) && filterDTO.userFavorites().equals(Boolean.TRUE)) { + if (nonNull(loggedUser)) { + var userFavoritesJoin = root.join("userFavoriteMangas", JoinType.INNER); + predicates.add( + criteriaBuilder.equal(userFavoritesJoin.get("user").get("id"), loggedUser.getId())); + } + } - return criteriaBuilder.and(predicates.toArray(Predicate[]::new)); + query.distinct(true); + + return criteriaBuilder.and(predicates.toArray(Predicate[]::new)); + }; } - private Predicate searchInAuthors( + private static Predicate searchInAuthors( Root root, CriteriaQuery query, CriteriaBuilder criteriaBuilder, diff --git a/src/main/java/com/magamochi/mangamochi/security/JwtRequestFilter.java b/src/main/java/com/magamochi/mangamochi/security/JwtRequestFilter.java index 6f5b84d..1b1b10f 100644 --- a/src/main/java/com/magamochi/mangamochi/security/JwtRequestFilter.java +++ b/src/main/java/com/magamochi/mangamochi/security/JwtRequestFilter.java @@ -6,8 +6,8 @@ import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; 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; @@ -15,43 +15,43 @@ import org.springframework.security.web.authentication.WebAuthenticationDetailsS 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; + private final JwtUtil jwtUtil; + private final CustomUserDetailsService userDetailsService; - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, - FilterChain chain) throws ServletException, IOException { + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { - final String authorizationHeader = request.getHeader("Authorization"); + final String authorizationHeader = request.getHeader("Authorization"); - String username = null; - String jwt = null; + 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); + 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); + } } -} \ No newline at end of file + + 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); + } +} diff --git a/src/main/java/com/magamochi/mangamochi/service/CustomUserDetailsService.java b/src/main/java/com/magamochi/mangamochi/service/CustomUserDetailsService.java index 12a48cd..9ce19af 100644 --- a/src/main/java/com/magamochi/mangamochi/service/CustomUserDetailsService.java +++ b/src/main/java/com/magamochi/mangamochi/service/CustomUserDetailsService.java @@ -19,12 +19,12 @@ public class CustomUserDetailsService implements UserDetailsService { public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { var user = userRepository - .findByUsername(username) + .findByEmail(username) .orElseThrow( - () -> new UsernameNotFoundException("User not found with username: " + username)); + () -> new UsernameNotFoundException("User not found with email: " + username)); return new User( - user.getUsername(), + user.getEmail(), user.getPassword(), Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + user.getRole()))); } diff --git a/src/main/java/com/magamochi/mangamochi/service/MangaService.java b/src/main/java/com/magamochi/mangamochi/service/MangaService.java index ae0b6db..75cfef0 100644 --- a/src/main/java/com/magamochi/mangamochi/service/MangaService.java +++ b/src/main/java/com/magamochi/mangamochi/service/MangaService.java @@ -1,15 +1,15 @@ package com.magamochi.mangamochi.service; +import static java.util.Objects.nonNull; + import com.magamochi.mangamochi.client.JikanClient; import com.magamochi.mangamochi.model.dto.*; +import com.magamochi.mangamochi.model.entity.Manga; 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.repository.*; import com.magamochi.mangamochi.model.specification.MangaSpecification; import com.magamochi.mangamochi.service.providers.ContentProviderFactory; import java.io.BufferedInputStream; @@ -20,6 +20,8 @@ import java.net.*; import java.net.URL; import java.util.Comparator; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; import lombok.RequiredArgsConstructor; @@ -31,6 +33,7 @@ import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor public class MangaService { + private final UserService userService; private final MangaChapterRepository mangaChapterRepository; private final MangaRepository mangaRepository; private final MangaProviderRepository mangaProviderRepository; @@ -40,9 +43,27 @@ public class MangaService { private final JikanClient jikanClient; private final ContentProviderFactory contentProviderFactory; + private final UserFavoriteMangaRepository userFavoriteMangaRepository; - public Page getMangas(MangaSpecification specification, Pageable pageable) { - return mangaRepository.findAll(specification, pageable).map(MangaListDTO::from); + public Page getMangas(MangaListFilterDTO filterDTO, Pageable pageable) { + var user = userService.getLoggedUser(); + + var specification = MangaSpecification.getMangaListSpecification(filterDTO, user); + + var favoriteMangasIds = + nonNull(user) + ? userFavoriteMangaRepository.findByUser(user).stream() + .map(ufm -> ufm.getManga().getId()) + .collect(Collectors.toSet()) + : Set.of(); + + return mangaRepository + .findAll(specification, pageable) + .map( + manga -> { + var favorite = favoriteMangasIds.contains(manga.getId()); + return MangaListDTO.from(manga, favorite); + }); } public List getMangaChapters(Long mangaProviderId) { @@ -178,10 +199,7 @@ public class MangaService { } public void updateInfo(Long mangaId) { - var manga = - mangaRepository - .findById(mangaId) - .orElseThrow(() -> new RuntimeException("Manga not found")); + var manga = findMangaByIdThrowIfNotFound(mangaId); var mangaSearchResponse = jikanClient.mangaSearch(manga.getTitle()); if (mangaSearchResponse.data().isEmpty()) { @@ -197,10 +215,7 @@ public class MangaService { } public MangaDTO getManga(Long mangaId) { - var manga = - mangaRepository - .findById(mangaId) - .orElseThrow(() -> new RuntimeException("Manga not found")); + var manga = findMangaByIdThrowIfNotFound(mangaId); return MangaDTO.from(manga); } @@ -236,4 +251,10 @@ public class MangaService { mangaChapterRepository.save(chapter); } + + public Manga findMangaByIdThrowIfNotFound(Long mangaId) { + return mangaRepository + .findById(mangaId) + .orElseThrow(() -> new RuntimeException("Manga not found")); + } } diff --git a/src/main/java/com/magamochi/mangamochi/service/UserFavoriteMangaService.java b/src/main/java/com/magamochi/mangamochi/service/UserFavoriteMangaService.java new file mode 100644 index 0000000..a21092a --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/service/UserFavoriteMangaService.java @@ -0,0 +1,46 @@ +package com.magamochi.mangamochi.service; + +import com.magamochi.mangamochi.model.entity.Manga; +import com.magamochi.mangamochi.model.entity.UserFavoriteManga; +import com.magamochi.mangamochi.model.repository.MangaRepository; +import com.magamochi.mangamochi.model.repository.UserFavoriteMangaRepository; +import java.util.NoSuchElementException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class UserFavoriteMangaService { + private final UserService userService; + private final MangaRepository mangaRepository; + private final UserFavoriteMangaRepository userFavoriteMangaRepository; + + public void setFavorite(Long id) { + var user = userService.getLoggedUserThrowIfNotFound(); + var manga = findMangaByIdThrowIfNotFound(id); + + if (userFavoriteMangaRepository.existsByUserAndManga(user, manga)) { + return; + } + + userFavoriteMangaRepository.save(UserFavoriteManga.builder().user(user).manga(manga).build()); + } + + public void setUnfavorite(Long id) { + var user = userService.getLoggedUserThrowIfNotFound(); + var manga = findMangaByIdThrowIfNotFound(id); + + var favoriteManga = + userFavoriteMangaRepository + .findByUserAndManga(user, manga) + .orElseThrow(() -> new NoSuchElementException("No manga found")); + + userFavoriteMangaRepository.delete(favoriteManga); + } + + private Manga findMangaByIdThrowIfNotFound(Long mangaId) { + return mangaRepository + .findById(mangaId) + .orElseThrow(() -> new RuntimeException("Manga not found")); + } +} diff --git a/src/main/java/com/magamochi/mangamochi/service/UserService.java b/src/main/java/com/magamochi/mangamochi/service/UserService.java new file mode 100644 index 0000000..8c8246f --- /dev/null +++ b/src/main/java/com/magamochi/mangamochi/service/UserService.java @@ -0,0 +1,84 @@ +package com.magamochi.mangamochi.service; + +import static java.util.Objects.isNull; + +import com.magamochi.mangamochi.model.dto.AuthenticationRequestDTO; +import com.magamochi.mangamochi.model.dto.AuthenticationResponseDTO; +import com.magamochi.mangamochi.model.dto.RegistrationRequestDTO; +import com.magamochi.mangamochi.model.entity.User; +import com.magamochi.mangamochi.model.enumeration.UserRole; +import com.magamochi.mangamochi.model.repository.UserRepository; +import com.magamochi.mangamochi.util.JwtUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class UserService { + private final AuthenticationManager authenticationManager; + private final UserDetailsService userDetailsService; + private final JwtUtil jwtUtil; + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + public AuthenticationResponseDTO authenticate(AuthenticationRequestDTO request) { + var token = new UsernamePasswordAuthenticationToken(request.email(), request.password()); + + try { + authenticationManager.authenticate(token); + } catch (Exception e) { + throw new RuntimeException("Authentication failed", e); + } + + var userDetails = userDetailsService.loadUserByUsername(request.email()); + var jwt = jwtUtil.generateToken(userDetails); + + var user = findUserByEmailThrowIfNotFound(userDetails.getUsername()); + + return new AuthenticationResponseDTO( + user.getId(), jwt, userDetails.getUsername(), user.getName(), user.getRole()); + } + + public void register(RegistrationRequestDTO request) { + if (userRepository.existsByEmail(request.email())) { + throw new RuntimeException("Email is already taken"); + } + + userRepository.save( + User.builder() + .name(request.name()) + .email(request.email()) + .password(passwordEncoder.encode(request.password())) + .role(UserRole.USER) + .build()); + } + + public User getLoggedUserThrowIfNotFound() { + var authentication = SecurityContextHolder.getContext().getAuthentication(); + if (isNull(authentication) || authentication.getName().equals("anonymousUser")) { + throw new RuntimeException("No authenticated user found"); + } + + return findUserByEmailThrowIfNotFound(authentication.getName()); + } + + public User getLoggedUser() { + var authentication = SecurityContextHolder.getContext().getAuthentication(); + if (isNull(authentication) || authentication.getName().equals("anonymousUser")) { + return null; + } + + return userRepository.findByEmail(authentication.getName()).orElse(null); + } + + private User findUserByEmailThrowIfNotFound(String email) { + return userRepository + .findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + } +} 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 index 378c0a3..63f184f 100644 --- a/src/main/java/com/magamochi/mangamochi/service/providers/impl/MangaLivreBlogProvider.java +++ b/src/main/java/com/magamochi/mangamochi/service/providers/impl/MangaLivreBlogProvider.java @@ -14,6 +14,7 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; +import org.apache.commons.lang3.StringUtils; import org.jsoup.nodes.Element; import org.springframework.stereotype.Service; @@ -80,10 +81,20 @@ public class MangaLivreBlogProvider implements ContentProvider { chapterImageContainers.stream() .map( chapterImageContainerElement -> { - var pageNumber = chapterImageContainerElement.id(); var imageElement = chapterImageContainerElement.getElementsByTag("img").getFirst(); - return imageElement.attr("data-lazy-src"); + + var dataLazySrc = imageElement.attr("data-lazy-src"); + if (StringUtils.isNoneBlank(dataLazySrc)) { + return dataLazySrc; + } + + var dataSrc = imageElement.attr("src"); + if (StringUtils.isNoneBlank(dataSrc)) { + return dataSrc; + } + + throw new NoSuchElementException("Image URL not found"); }) .toList(); diff --git a/src/main/resources/db/migration/V0010__USERS.sql b/src/main/resources/db/migration/V0010__USERS.sql index ed99c03..ccb4ab3 100644 --- a/src/main/resources/db/migration/V0010__USERS.sql +++ b/src/main/resources/db/migration/V0010__USERS.sql @@ -1,6 +1,7 @@ CREATE TABLE users ( id SERIAL PRIMARY KEY, - username VARCHAR NOT NULL UNIQUE, + email VARCHAR NOT NULL UNIQUE, + name VARCHAR NOT NULL, password VARCHAR NOT NULL, role VARCHAR ); \ No newline at end of file diff --git a/src/main/resources/db/migration/V0011__USER_FAVORITE_MANGA.sql b/src/main/resources/db/migration/V0011__USER_FAVORITE_MANGA.sql new file mode 100644 index 0000000..8309f7c --- /dev/null +++ b/src/main/resources/db/migration/V0011__USER_FAVORITE_MANGA.sql @@ -0,0 +1,8 @@ +CREATE TABLE user_favorite_mangas +( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + manga_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, manga_id) +); \ No newline at end of file -- 2.49.1