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.title}}