Implement library management endpoint (authenticated)

This commit is contained in:
Simon Grimme
2022-07-25 15:00:51 +02:00
parent 206272b50b
commit 6b25fc3548
14 changed files with 241 additions and 29 deletions
+6
View File
@@ -21,6 +21,7 @@
<protobuf-java.version>3.21.2</protobuf-java.version>
<gameyfin-frontend.version>0.0.1-SNAPSHOT</gameyfin-frontend.version>
<commons-compress.version>1.21</commons-compress.version>
<java-jwt.version>4.0.0</java-jwt.version>
</properties>
<dependencies>
@@ -62,6 +63,11 @@
<version>${resilience4j-reactor.version}</version>
</dependency>
<!-- Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Persistence -->
<dependency>
@@ -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);
}
}
}
@@ -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;
}
}
@@ -0,0 +1,9 @@
package de.grimsi.gameyfin.dto;
import lombok.Data;
@Data
public class PathToSlugDto {
private String path;
private String slug;
}
@@ -0,0 +1,9 @@
package de.grimsi.gameyfin.dto;
import lombok.Data;
@Data
public class UsernamePasswordDto {
private String username;
private String password;
}
@@ -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<DetectedGame, String> {
@@ -13,6 +14,8 @@ public interface DetectedGameRepository extends JpaRepository<DetectedGame, Stri
boolean existsBySlug(String slug);
Optional<DetectedGame> findByPath(String path);
List<DetectedGame> getAllByPathNotIn(Collection<String> paths);
default List<DetectedGame> getAllByPathNotIn(List<Path> paths) {
@@ -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<UnmappableFile, Long> {
@@ -13,6 +14,8 @@ public interface UnmappableFileRepository extends JpaRepository<UnmappableFile,
List<UnmappableFile> getAllByPathNotIn(Collection<String> paths);
Optional<UnmappableFile> findByPath(String path);
default List<UnmappableFile> getAllByPathNotIn(List<Path> paths) {
List<String> pathStrings = paths.stream().map(Path::toString).toList();
return getAllByPathNotIn(pathStrings);
@@ -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<UnmappableFile> getUnmappedFiles() {
return gameService.getAllUnmappedFiles();
}
}
@@ -16,14 +16,4 @@ public class UnmappedFileController {
private final GameService gameService;
@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
public List<UnmappableFile> 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);
}
}
@@ -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<GameOverviewDto> 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<UnmappableFile> optionalUnmappableFile = unmappableFileRepository.findByPath(path);
Optional<DetectedGame> 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;
}
}
@@ -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
+2 -11
View File
@@ -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
@@ -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
@@ -2,7 +2,9 @@
<div fxLayout="column" fxFlex="0 1 70" fxLayoutGap="16px" fxFlex.lt-xl="95">
<div fxLayout="row" fxLayout.lt-lg="column" fxLayoutGap="16px">
<img style="max-width: 352px;" src="v1/images/{{game.coverId}}" alt="Game cover">
<div fxLayoutAlign.lt-lg="center">
<img style="max-width: 264px;" src="v1/images/{{game.coverId}}" alt="Game cover">
</div>
<div fxFlex="40" fxLayout="column" id="game-details">
<h1>{{game.title}}</h1>