diff --git a/backend/pom.xml b/backend/pom.xml index 600daa3..69d8c28 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -21,6 +21,7 @@ 3.21.2 0.0.1-SNAPSHOT 1.21 + 4.0.0 @@ -62,6 +63,11 @@ ${resilience4j-reactor.version} + + + org.springframework.boot + spring-boot-starter-security + diff --git a/backend/src/main/java/de/grimsi/gameyfin/config/SecureProperties.java b/backend/src/main/java/de/grimsi/gameyfin/config/SecureProperties.java new file mode 100644 index 0000000..3140b37 --- /dev/null +++ b/backend/src/main/java/de/grimsi/gameyfin/config/SecureProperties.java @@ -0,0 +1,25 @@ +package de.grimsi.gameyfin.config; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.PropertiesPropertySource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PropertiesLoaderUtils; + +import java.util.Objects; + +@Configuration +public class SecureProperties { + + @Autowired + public void setConfigurableEnvironment(ConfigurableEnvironment env) { + try { + Resource resource = new ClassPathResource("/config/secure.yml"); + env.getPropertySources().addFirst(new PropertiesPropertySource(Objects.requireNonNull(resource.getFilename()), PropertiesLoaderUtils.loadProperties(resource))); + } catch (Exception ex) { + throw new RuntimeException(ex.getMessage(), ex); + } + } +} diff --git a/backend/src/main/java/de/grimsi/gameyfin/config/SecurityConfiguration.java b/backend/src/main/java/de/grimsi/gameyfin/config/SecurityConfiguration.java new file mode 100644 index 0000000..69a99b5 --- /dev/null +++ b/backend/src/main/java/de/grimsi/gameyfin/config/SecurityConfiguration.java @@ -0,0 +1,72 @@ +package de.grimsi.gameyfin.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpStatus; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import org.springframework.security.web.firewall.HttpFirewall; +import org.springframework.security.web.firewall.StrictHttpFirewall; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@EnableWebSecurity +@EnableGlobalMethodSecurity(prePostEnabled = true) +@RequiredArgsConstructor +public class SecurityConfiguration { + + @Value("${gameyfin.user}") + private String username; + + @Value("${gameyfin.password}") + private String password; + + @Bean + protected SecurityFilterChain httpSecurity(HttpSecurity http) throws Exception { + + // TODO: Try to enable CSRF + http.csrf().disable(); + + // Allow GET-Requests on *all* URLs (Frontend will handle 404 and permission) + // except paths under "/v1/library-management" + http.authorizeRequests() + .antMatchers("**").permitAll() + .antMatchers("/v1/library-management").authenticated() + .anyRequest().denyAll(); + + http.httpBasic(Customizer.withDefaults()); + + http.exceptionHandling() + .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)); + + return http.build(); + } + + @Bean + public InMemoryUserDetailsManager userDetailsService() { + UserDetails user = User + .withDefaultPasswordEncoder() + .username(username) + .password(password) + .authorities("ADMIN_API_ACCESS") + .build(); + return new InMemoryUserDetailsManager(user); + } + + @Bean + CorsConfigurationSource corsConfigurationSource() { + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", new CorsConfiguration().applyPermitDefaultValues()); + return source; + } + +} diff --git a/backend/src/main/java/de/grimsi/gameyfin/dto/PathToSlugDto.java b/backend/src/main/java/de/grimsi/gameyfin/dto/PathToSlugDto.java new file mode 100644 index 0000000..3612724 --- /dev/null +++ b/backend/src/main/java/de/grimsi/gameyfin/dto/PathToSlugDto.java @@ -0,0 +1,9 @@ +package de.grimsi.gameyfin.dto; + +import lombok.Data; + +@Data +public class PathToSlugDto { + private String path; + private String slug; +} diff --git a/backend/src/main/java/de/grimsi/gameyfin/dto/UsernamePasswordDto.java b/backend/src/main/java/de/grimsi/gameyfin/dto/UsernamePasswordDto.java new file mode 100644 index 0000000..2f16123 --- /dev/null +++ b/backend/src/main/java/de/grimsi/gameyfin/dto/UsernamePasswordDto.java @@ -0,0 +1,9 @@ +package de.grimsi.gameyfin.dto; + +import lombok.Data; + +@Data +public class UsernamePasswordDto { + private String username; + private String password; +} diff --git a/backend/src/main/java/de/grimsi/gameyfin/repositories/DetectedGameRepository.java b/backend/src/main/java/de/grimsi/gameyfin/repositories/DetectedGameRepository.java index 146c317..433044a 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/repositories/DetectedGameRepository.java +++ b/backend/src/main/java/de/grimsi/gameyfin/repositories/DetectedGameRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.nio.file.Path; import java.util.Collection; import java.util.List; +import java.util.Optional; public interface DetectedGameRepository extends JpaRepository { @@ -13,6 +14,8 @@ public interface DetectedGameRepository extends JpaRepository findByPath(String path); + List getAllByPathNotIn(Collection paths); default List getAllByPathNotIn(List paths) { diff --git a/backend/src/main/java/de/grimsi/gameyfin/repositories/UnmappableFileRepository.java b/backend/src/main/java/de/grimsi/gameyfin/repositories/UnmappableFileRepository.java index 3c01af3..bcdb1c0 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/repositories/UnmappableFileRepository.java +++ b/backend/src/main/java/de/grimsi/gameyfin/repositories/UnmappableFileRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.nio.file.Path; import java.util.Collection; import java.util.List; +import java.util.Optional; public interface UnmappableFileRepository extends JpaRepository { @@ -13,6 +14,8 @@ public interface UnmappableFileRepository extends JpaRepository getAllByPathNotIn(Collection paths); + Optional findByPath(String path); + default List getAllByPathNotIn(List paths) { List pathStrings = paths.stream().map(Path::toString).toList(); return getAllByPathNotIn(pathStrings); diff --git a/backend/src/main/java/de/grimsi/gameyfin/rest/LibraryManagementController.java b/backend/src/main/java/de/grimsi/gameyfin/rest/LibraryManagementController.java new file mode 100644 index 0000000..b55a125 --- /dev/null +++ b/backend/src/main/java/de/grimsi/gameyfin/rest/LibraryManagementController.java @@ -0,0 +1,51 @@ +package de.grimsi.gameyfin.rest; + + +import de.grimsi.gameyfin.dto.PathToSlugDto; +import de.grimsi.gameyfin.entities.DetectedGame; +import de.grimsi.gameyfin.entities.UnmappableFile; +import de.grimsi.gameyfin.repositories.DetectedGameRepository; +import de.grimsi.gameyfin.service.DownloadService; +import de.grimsi.gameyfin.service.GameService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/v1/library-management") +@PreAuthorize("hasAuthority('ADMIN_API_ACCESS')") +@RequiredArgsConstructor +public class LibraryManagementController { + + private final GameService gameService; + private final DownloadService downloadService; + + @DeleteMapping(value = "/delete-game/{slug}", produces = MediaType.APPLICATION_JSON_VALUE) + public void deleteGame(@PathVariable String slug) { + gameService.deleteGame(slug); + } + + @GetMapping(value = "/confirm-game/{slug}", produces = MediaType.APPLICATION_JSON_VALUE) + public DetectedGame confirmMatch(@PathVariable String slug, @RequestParam(required = false, defaultValue = "true") boolean confirm) { + return gameService.confirmGame(slug, confirm); + } + + @PostMapping(value = "/map-path", produces = MediaType.APPLICATION_JSON_VALUE) + public DetectedGame manuallyMapPathToSlug(@RequestBody PathToSlugDto pathToSlugDto) { + DetectedGame game = gameService.mapPathToGame(pathToSlugDto.getPath(), pathToSlugDto.getSlug()); + + downloadService.downloadGameCoversFromIgdb(); + downloadService.downloadGameScreenshotsFromIgdb(); + downloadService.downloadCompanyLogosFromIgdb(); + + return game; + } + + @GetMapping(value = "/unmapped-files", produces = MediaType.APPLICATION_JSON_VALUE) + public List getUnmappedFiles() { + return gameService.getAllUnmappedFiles(); + } +} diff --git a/backend/src/main/java/de/grimsi/gameyfin/rest/UnmappedFileController.java b/backend/src/main/java/de/grimsi/gameyfin/rest/UnmappedFileController.java index 74daeef..d2c6ea3 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/rest/UnmappedFileController.java +++ b/backend/src/main/java/de/grimsi/gameyfin/rest/UnmappedFileController.java @@ -16,14 +16,4 @@ public class UnmappedFileController { private final GameService gameService; - @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) - public List getUnmappedFiles() { - return gameService.getAllUnmappedFiles(); - } - - @PostMapping(value = "/{unmappedFileId}/map-to/{igdbSlug}", produces = MediaType.APPLICATION_JSON_VALUE) - public DetectedGame mapGameManually(@PathVariable Long unmappedFileId, @PathVariable String igdbSlug) { - return gameService.mapUnmappedFile(unmappedFileId, igdbSlug); - } - } diff --git a/backend/src/main/java/de/grimsi/gameyfin/service/GameService.java b/backend/src/main/java/de/grimsi/gameyfin/service/GameService.java index 916c85e..36f2537 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/service/GameService.java +++ b/backend/src/main/java/de/grimsi/gameyfin/service/GameService.java @@ -16,6 +16,7 @@ import org.springframework.web.server.ResponseStatusException; import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.stream.Collectors; @Service @@ -49,17 +50,39 @@ public class GameService { public List getGameOverviews() { return detectedGameRepository.findAll().stream().map(GameMapper::toGameOverviewDto).toList(); } + + public void deleteGame(String slug) { + detectedGameRepository.deleteById(slug); + } - public DetectedGame mapUnmappedFile(Long unmappedGameId, String igdbSlug) { + public DetectedGame confirmGame(String slug, boolean confirm) { + DetectedGame g = getDetectedGame(slug); + g.setConfirmedMatch(confirm); + return detectedGameRepository.save(g); + } - UnmappableFile unmappableFile = unmappableFileRepository.findById(unmappedGameId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Unmapped file with id '%d' does not exist.".formatted(unmappedGameId))); + public DetectedGame mapPathToGame(String path, String slug) { - if(detectedGameRepository.existsBySlug(igdbSlug)) - throw new ResponseStatusException(HttpStatus.CONFLICT, "Game with slug '%s' already exists in database.".formatted(igdbSlug)); + if(detectedGameRepository.existsBySlug(slug)) + throw new ResponseStatusException(HttpStatus.CONFLICT, "Game with slug '%s' already exists in database.".formatted(slug)); - Igdb.Game igdbGame = igdbWrapper.getGameBySlug(igdbSlug) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Game with slug '%s' does not exist on IGDB.".formatted(igdbSlug))); + Optional optionalUnmappableFile = unmappableFileRepository.findByPath(path); + Optional optionalDetectedGame = detectedGameRepository.findByPath(path); + + if(optionalUnmappableFile.isPresent()) { + return mapUnmappableFile(optionalUnmappableFile.get(), slug); + } + + if(optionalDetectedGame.isPresent()) { + return mapDetectedGame(optionalDetectedGame.get(), slug); + } + + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Path '%s' not in database".formatted(path)); + } + + private DetectedGame mapUnmappableFile(UnmappableFile unmappableFile, String slug) { + Igdb.Game igdbGame = igdbWrapper.getGameBySlug(slug) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Game with slug '%s' does not exist on IGDB.".formatted(slug))); DetectedGame game = GameMapper.toDetectedGame(igdbGame, Path.of(unmappableFile.getPath())); game = detectedGameRepository.save(game); @@ -68,4 +91,16 @@ public class GameService { return game; } + + private DetectedGame mapDetectedGame(DetectedGame existingGame, String slug) { + Igdb.Game igdbGame = igdbWrapper.getGameBySlug(slug) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Game with slug '%s' does not exist on IGDB.".formatted(slug))); + + DetectedGame game = GameMapper.toDetectedGame(igdbGame, Path.of(existingGame.getPath())); + game = detectedGameRepository.save(game); + + detectedGameRepository.deleteById(existingGame.getSlug()); + + return game; + } } diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml index 45d710a..ec7b922 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev.yml @@ -1,4 +1,6 @@ gameyfin: + user: admin + password: 112 root: C:\Projects\privat\gameyfin-library #root: \\NAS-Simon\Öffentlich\Spiele cache: ${gameyfin.root}\.gameyfin\cache diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 66f9135..850a4f0 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -1,25 +1,16 @@ server: port: 8080 - error.include-stacktrace: never spring: - mvc: - async.request-timeout: -1 - jackson.default-property-inclusion: non_null datasource.db-name: gameyfin_db datasource.url: jdbc:h2:file:${gameyfin.db}/${spring.datasource.db-name};AUTO_SERVER=TRUE datasource.username: gfadmin datasource.password: gameyfin datasource.driverClassName: org.h2.Driver - jpa: - database-platform: org.hibernate.dialect.H2Dialect - hibernate.ddl-auto: update - open-in-view: true - properties: - hibernate: - event.merge.entity_copy_observer: allow gameyfin: + user: "" + password: "" root: "" cache: ${gameyfin.root}\.gameyfin\cache db: ${gameyfin.root}\.gameyfin\db # Currently unused diff --git a/backend/src/main/resources/config/secure.yml b/backend/src/main/resources/config/secure.yml new file mode 100644 index 0000000..4445e02 --- /dev/null +++ b/backend/src/main/resources/config/secure.yml @@ -0,0 +1,14 @@ +server: + error.include-stacktrace: never + +spring: + mvc: + async.request-timeout: -1 + jackson.default-property-inclusion: non_null + jpa: + database-platform: org.hibernate.dialect.H2Dialect + hibernate.ddl-auto: update + open-in-view: true + properties: + hibernate: + event.merge.entity_copy_observer: allow \ No newline at end of file diff --git a/frontend/src/app/components/game-detail-view/game-detail-view.component.html b/frontend/src/app/components/game-detail-view/game-detail-view.component.html index 936732b..da8e016 100644 --- a/frontend/src/app/components/game-detail-view/game-detail-view.component.html +++ b/frontend/src/app/components/game-detail-view/game-detail-view.component.html @@ -2,7 +2,9 @@
- Game cover +
+ Game cover +

{{game.title}}