Compare commits

...

2 Commits

26 changed files with 458 additions and 193 deletions

2
.env
View File

@ -6,4 +6,4 @@ MINIO_ENDPOINT=http://omv.badger-pirarucu.ts.net:9000
MINIO_USER=admin MINIO_USER=admin
MINIO_PASS=!E9v4i0v3 MINIO_PASS=!E9v4i0v3
WEBSCRAPPER_ENDPOINT=http://localhost:8000/url WEBSCRAPPER_ENDPOINT=http://localhost:8090/url

1
.gitignore vendored
View File

@ -8,6 +8,7 @@ target/
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff # User-specific stuff
.idea/**
.idea/**/workspace.xml .idea/**/workspace.xml
.idea/**/tasks.xml .idea/**/tasks.xml
.idea/**/usage.statistics.xml .idea/**/usage.statistics.xml

View File

@ -3,8 +3,8 @@ package com.magamochi.mangamochi.config;
import com.magamochi.mangamochi.security.JwtRequestFilter; import com.magamochi.mangamochi.security.JwtRequestFilter;
import com.magamochi.mangamochi.service.CustomUserDetailsService; import com.magamochi.mangamochi.service.CustomUserDetailsService;
import com.magamochi.mangamochi.util.JwtUtil; import com.magamochi.mangamochi.util.JwtUtil;
import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager; 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.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration @Configuration
@EnableMethodSecurity @EnableMethodSecurity
@RequiredArgsConstructor @RequiredArgsConstructor
public class SecurityConfig { public class SecurityConfig {
private final CustomUserDetailsService userDetailsService; private final CustomUserDetailsService userDetailsService;
private final JwtUtil jwtUtil; private final JwtUtil jwtUtil;
@Bean @Bean
public JwtRequestFilter jwtRequestFilter() { public JwtRequestFilter jwtRequestFilter() {
return new JwtRequestFilter(jwtUtil, userDetailsService); return new JwtRequestFilter(jwtUtil, userDetailsService);
} }
@Bean @Bean
public DaoAuthenticationProvider authenticationProvider() { public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService); authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder()); authProvider.setPasswordEncoder(passwordEncoder());
return authProvider; return authProvider;
} }
@Bean @Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception { public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig)
return authConfig.getAuthenticationManager(); throws Exception {
} return authConfig.getAuthenticationManager();
}
@Bean @Bean
public PasswordEncoder passwordEncoder() { public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); return new BCryptPasswordEncoder();
} }
@Bean @Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http http.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(csrf -> csrf.disable())
.csrf(csrf -> csrf.disable()) .sessionManagement(
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
.requestMatchers("/auth/**").permitAll() .headers(headers -> headers.frameOptions(frame -> frame.disable()))
.requestMatchers("/swagger-ui/**").permitAll() .authenticationProvider(authenticationProvider())
.requestMatchers("/api-docs/**").permitAll() .addFilterBefore(jwtRequestFilter(), UsernamePasswordAuthenticationFilter.class);
.requestMatchers("/h2-console/**").permitAll()
.anyRequest().permitAll()
)
.headers(headers -> headers
.frameOptions(frame -> frame.disable())
)
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtRequestFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build(); return http.build();
} }
@Bean @Bean
public CorsConfigurationSource corsConfigurationSource() { public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration(); CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(List.of("http://localhost:3000")); configuration.setAllowedOriginPatterns(List.of("http://localhost:3000"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With")); configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-Requested-With"));
configuration.setAllowCredentials(true); configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration); source.registerCorsConfiguration("/**", configuration);
return source; return source;
} }
} }

View File

@ -2,15 +2,11 @@ package com.magamochi.mangamochi.controller;
import com.magamochi.mangamochi.model.dto.AuthenticationRequestDTO; import com.magamochi.mangamochi.model.dto.AuthenticationRequestDTO;
import com.magamochi.mangamochi.model.dto.AuthenticationResponseDTO; 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.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 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.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@ -19,46 +15,28 @@ import org.springframework.web.bind.annotation.*;
@CrossOrigin(origins = "*") @CrossOrigin(origins = "*")
@RequiredArgsConstructor @RequiredArgsConstructor
public class AuthenticationController { public class AuthenticationController {
private final AuthenticationManager authenticationManager; private final UserService userService;
private final UserDetailsService userDetailsService;
private final JwtUtil jwtUtil;
private final UserRepository userRepository; private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
@Operation(
summary = "Authenticate user",
description = "Authenticate user with email and password.",
tags = {"Auth"},
operationId = "authenticateUser")
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<?> createAuthenticationToken( public AuthenticationResponseDTO authenticateUser(
@RequestBody AuthenticationRequestDTO authenticationRequestDTO) { @RequestBody AuthenticationRequestDTO authenticationRequestDTO) {
try { return userService.authenticate(authenticationRequestDTO);
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));
} }
@Operation(
summary = "Register user",
description = "Register a new user.",
tags = {"Auth"},
operationId = "registerUser")
@PostMapping("/register") @PostMapping("/register")
public ResponseEntity<?> registerUser( public void registerUser(@RequestBody RegistrationRequestDTO registrationRequestDTO) {
@RequestBody AuthenticationRequestDTO registrationRequestDTO) { userService.register(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");
} }
} }

View File

@ -1,11 +1,7 @@
package com.magamochi.mangamochi.controller; package com.magamochi.mangamochi.controller;
import com.magamochi.mangamochi.model.dto.MangaChapterDTO; import com.magamochi.mangamochi.model.dto.*;
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.enumeration.ArchiveFileType;
import com.magamochi.mangamochi.model.specification.MangaSpecification;
import com.magamochi.mangamochi.service.MangaService; import com.magamochi.mangamochi.service.MangaService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
@ -37,9 +33,9 @@ public class MangaController {
operationId = "getMangas") operationId = "getMangas")
@GetMapping @GetMapping
public Page<MangaListDTO> getMangas( public Page<MangaListDTO> getMangas(
@ParameterObject MangaSpecification specification, @ParameterObject MangaListFilterDTO filterDTO,
@Parameter(hidden = true) @ParameterObject @PageableDefault Pageable pageable) { @Parameter(hidden = true) @ParameterObject @PageableDefault Pageable pageable) {
return mangaService.getMangas(specification, pageable); return mangaService.getMangas(filterDTO, pageable);
} }
@Operation( @Operation(

View File

@ -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);
}
}

View File

@ -1,3 +1,5 @@
package com.magamochi.mangamochi.model.dto; 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) {}

View File

@ -1,3 +1,11 @@
package com.magamochi.mangamochi.model.dto; 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) {}

View File

@ -18,8 +18,9 @@ public record MangaListDTO(
Integer providerCount, Integer providerCount,
@NotNull List<String> genres, @NotNull List<String> genres,
@NotNull List<String> authors, @NotNull List<String> authors,
@NotNull Double score) { @NotNull Double score,
public static MangaListDTO from(Manga manga) { @NotNull Boolean favorite) {
public static MangaListDTO from(Manga manga, boolean favorite) {
return new MangaListDTO( return new MangaListDTO(
manga.getId(), manga.getId(),
manga.getTitle(), manga.getTitle(),
@ -32,6 +33,7 @@ public record MangaListDTO(
manga.getMangaAuthors().stream() manga.getMangaAuthors().stream()
.map(mangaAuthor -> mangaAuthor.getAuthor().getName()) .map(mangaAuthor -> mangaAuthor.getAuthor().getName())
.toList(), .toList(),
0.0); 0.0,
favorite);
} }
} }

View File

@ -0,0 +1,6 @@
package com.magamochi.mangamochi.model.dto;
import java.util.List;
public record MangaListFilterDTO(
String searchQuery, List<Long> genreIds, List<String> statuses, Boolean userFavorites) {}

View File

@ -0,0 +1,3 @@
package com.magamochi.mangamochi.model.dto;
public record RegistrationRequestDTO(String name, String email, String password) {}

View File

@ -57,4 +57,7 @@ public class Manga {
@OneToMany(mappedBy = "manga") @OneToMany(mappedBy = "manga")
private List<MangaGenre> mangaGenres; private List<MangaGenre> mangaGenres;
@OneToMany(mappedBy = "manga")
private List<UserFavoriteManga> userFavoriteMangas;
} }

View File

@ -1,6 +1,8 @@
package com.magamochi.mangamochi.model.entity; package com.magamochi.mangamochi.model.entity;
import com.magamochi.mangamochi.model.enumeration.UserRole;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.util.Set;
import lombok.*; import lombok.*;
@Entity @Entity
@ -16,11 +18,18 @@ public class User {
private Long id; private Long id;
@Column(unique = true, nullable = false) @Column(unique = true, nullable = false)
private String username; private String email;
@Column(nullable = false)
private String name;
@Column(nullable = false) @Column(nullable = false)
private String password; private String password;
@Column(nullable = false) @Column(nullable = false)
private String role; @Enumerated(EnumType.STRING)
private UserRole role;
@OneToMany(mappedBy = "user")
private Set<UserFavoriteManga> favoriteMangas;
} }

View File

@ -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;
}

View File

@ -0,0 +1,5 @@
package com.magamochi.mangamochi.model.enumeration;
public enum UserRole {
USER
}

View File

@ -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<UserFavoriteManga, Long> {
boolean existsByUserAndManga(User user, Manga manga);
Optional<UserFavoriteManga> findByUserAndManga(User user, Manga manga);
Set<UserFavoriteManga> findByUser(User user);
}

View File

@ -5,7 +5,7 @@ import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, Long> { public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username); boolean existsByEmail(String email);
boolean existsByUsername(String username); Optional<User> findByEmail(String email);
} }

View File

@ -2,53 +2,65 @@ package com.magamochi.mangamochi.model.specification;
import static java.util.Objects.nonNull; 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.Author;
import com.magamochi.mangamochi.model.entity.Manga; import com.magamochi.mangamochi.model.entity.Manga;
import com.magamochi.mangamochi.model.entity.User;
import jakarta.persistence.criteria.*; import jakarta.persistence.criteria.*;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import lombok.NonNull; import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.domain.Specification;
public record MangaSpecification(String searchQuery, List<Long> genreIds, List<String> statuses) @NoArgsConstructor(access = AccessLevel.PRIVATE)
implements Specification<Manga> { public class MangaSpecification {
@Override public static Specification<Manga> getMangaListSpecification(
public Predicate toPredicate( MangaListFilterDTO filterDTO, User loggedUser) {
@NonNull Root<Manga> root, CriteriaQuery<?> query, @NonNull CriteriaBuilder criteriaBuilder) { return (root, query, criteriaBuilder) -> {
List<Predicate> predicates = new ArrayList<>(); List<Predicate> predicates = new ArrayList<>();
if (StringUtils.isNotBlank(searchQuery)) { if (StringUtils.isNotBlank(filterDTO.searchQuery())) {
var searchPattern = "%" + searchQuery.toLowerCase() + "%"; var searchPattern = "%" + filterDTO.searchQuery().toLowerCase() + "%";
var titlePredicate = var titlePredicate =
criteriaBuilder.like(criteriaBuilder.lower(root.get("title")), searchPattern); 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()) { if (nonNull(filterDTO.genreIds()) && !filterDTO.genreIds().isEmpty()) {
var genreJoin = root.join("mangaGenres", JoinType.LEFT); var genreJoin = root.join("mangaGenres", JoinType.LEFT);
predicates.add(genreJoin.get("genre").get("id").in(genreIds)); predicates.add(genreJoin.get("genre").get("id").in(filterDTO.genreIds()));
} }
if (nonNull(statuses) && !statuses.isEmpty()) { if (nonNull(filterDTO.statuses()) && !filterDTO.statuses().isEmpty()) {
predicates.add( predicates.add(
criteriaBuilder criteriaBuilder
.lower(root.get("status")) .lower(root.get("status"))
.in(statuses.stream().map(String::toLowerCase).toList())); .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<Manga> root, Root<Manga> root,
CriteriaQuery<?> query, CriteriaQuery<?> query,
CriteriaBuilder criteriaBuilder, CriteriaBuilder criteriaBuilder,

View File

@ -6,8 +6,8 @@ import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails; 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.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class JwtRequestFilter extends OncePerRequestFilter { public class JwtRequestFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil; private final JwtUtil jwtUtil;
private final CustomUserDetailsService userDetailsService; private final CustomUserDetailsService userDetailsService;
@Override @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, protected void doFilterInternal(
FilterChain chain) throws ServletException, IOException { 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 username = null;
String jwt = null; String jwt = null;
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
jwt = authorizationHeader.substring(7); jwt = authorizationHeader.substring(7);
try { try {
username = jwtUtil.extractUsername(jwt); username = jwtUtil.extractUsername(jwt);
} catch (Exception e) { } catch (Exception e) {
logger.warn("JWT token validation failed", 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 (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);
}
}

View File

@ -19,12 +19,12 @@ public class CustomUserDetailsService implements UserDetailsService {
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
var user = var user =
userRepository userRepository
.findByUsername(username) .findByEmail(username)
.orElseThrow( .orElseThrow(
() -> new UsernameNotFoundException("User not found with username: " + username)); () -> new UsernameNotFoundException("User not found with email: " + username));
return new User( return new User(
user.getUsername(), user.getEmail(),
user.getPassword(), user.getPassword(),
Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + user.getRole()))); Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + user.getRole())));
} }

View File

@ -1,15 +1,15 @@
package com.magamochi.mangamochi.service; package com.magamochi.mangamochi.service;
import static java.util.Objects.nonNull;
import com.magamochi.mangamochi.client.JikanClient; import com.magamochi.mangamochi.client.JikanClient;
import com.magamochi.mangamochi.model.dto.*; 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.MangaChapter;
import com.magamochi.mangamochi.model.entity.MangaChapterImage; import com.magamochi.mangamochi.model.entity.MangaChapterImage;
import com.magamochi.mangamochi.model.entity.MangaProvider; import com.magamochi.mangamochi.model.entity.MangaProvider;
import com.magamochi.mangamochi.model.enumeration.ArchiveFileType; import com.magamochi.mangamochi.model.enumeration.ArchiveFileType;
import com.magamochi.mangamochi.model.repository.MangaChapterImageRepository; import com.magamochi.mangamochi.model.repository.*;
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.model.specification.MangaSpecification;
import com.magamochi.mangamochi.service.providers.ContentProviderFactory; import com.magamochi.mangamochi.service.providers.ContentProviderFactory;
import java.io.BufferedInputStream; import java.io.BufferedInputStream;
@ -20,6 +20,8 @@ import java.net.*;
import java.net.URL; import java.net.URL;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream; import java.util.zip.ZipOutputStream;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -31,6 +33,7 @@ import org.springframework.stereotype.Service;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class MangaService { public class MangaService {
private final UserService userService;
private final MangaChapterRepository mangaChapterRepository; private final MangaChapterRepository mangaChapterRepository;
private final MangaRepository mangaRepository; private final MangaRepository mangaRepository;
private final MangaProviderRepository mangaProviderRepository; private final MangaProviderRepository mangaProviderRepository;
@ -40,9 +43,27 @@ public class MangaService {
private final JikanClient jikanClient; private final JikanClient jikanClient;
private final ContentProviderFactory contentProviderFactory; private final ContentProviderFactory contentProviderFactory;
private final UserFavoriteMangaRepository userFavoriteMangaRepository;
public Page<MangaListDTO> getMangas(MangaSpecification specification, Pageable pageable) { public Page<MangaListDTO> getMangas(MangaListFilterDTO filterDTO, Pageable pageable) {
return mangaRepository.findAll(specification, pageable).map(MangaListDTO::from); 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<MangaChapterDTO> getMangaChapters(Long mangaProviderId) { public List<MangaChapterDTO> getMangaChapters(Long mangaProviderId) {
@ -178,10 +199,7 @@ public class MangaService {
} }
public void updateInfo(Long mangaId) { public void updateInfo(Long mangaId) {
var manga = var manga = findMangaByIdThrowIfNotFound(mangaId);
mangaRepository
.findById(mangaId)
.orElseThrow(() -> new RuntimeException("Manga not found"));
var mangaSearchResponse = jikanClient.mangaSearch(manga.getTitle()); var mangaSearchResponse = jikanClient.mangaSearch(manga.getTitle());
if (mangaSearchResponse.data().isEmpty()) { if (mangaSearchResponse.data().isEmpty()) {
@ -197,10 +215,7 @@ public class MangaService {
} }
public MangaDTO getManga(Long mangaId) { public MangaDTO getManga(Long mangaId) {
var manga = var manga = findMangaByIdThrowIfNotFound(mangaId);
mangaRepository
.findById(mangaId)
.orElseThrow(() -> new RuntimeException("Manga not found"));
return MangaDTO.from(manga); return MangaDTO.from(manga);
} }
@ -236,4 +251,10 @@ public class MangaService {
mangaChapterRepository.save(chapter); mangaChapterRepository.save(chapter);
} }
public Manga findMangaByIdThrowIfNotFound(Long mangaId) {
return mangaRepository
.findById(mangaId)
.orElseThrow(() -> new RuntimeException("Manga not found"));
}
} }

View File

@ -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"));
}
}

View File

@ -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"));
}
}

View File

@ -14,6 +14,7 @@ import java.util.stream.Collectors;
import java.util.stream.IntStream; import java.util.stream.IntStream;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2; import lombok.extern.log4j.Log4j2;
import org.apache.commons.lang3.StringUtils;
import org.jsoup.nodes.Element; import org.jsoup.nodes.Element;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -80,10 +81,20 @@ public class MangaLivreBlogProvider implements ContentProvider {
chapterImageContainers.stream() chapterImageContainers.stream()
.map( .map(
chapterImageContainerElement -> { chapterImageContainerElement -> {
var pageNumber = chapterImageContainerElement.id();
var imageElement = var imageElement =
chapterImageContainerElement.getElementsByTag("img").getFirst(); 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(); .toList();

View File

@ -1,6 +1,7 @@
CREATE TABLE users ( CREATE TABLE users (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
username VARCHAR NOT NULL UNIQUE, email VARCHAR NOT NULL UNIQUE,
name VARCHAR NOT NULL,
password VARCHAR NOT NULL, password VARCHAR NOT NULL,
role VARCHAR role VARCHAR
); );

View File

@ -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)
);