mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-16 16:20:04 +00:00
Implement library management endpoint (authenticated)
This commit is contained in:
@@ -21,6 +21,7 @@
|
|||||||
<protobuf-java.version>3.21.2</protobuf-java.version>
|
<protobuf-java.version>3.21.2</protobuf-java.version>
|
||||||
<gameyfin-frontend.version>0.0.1-SNAPSHOT</gameyfin-frontend.version>
|
<gameyfin-frontend.version>0.0.1-SNAPSHOT</gameyfin-frontend.version>
|
||||||
<commons-compress.version>1.21</commons-compress.version>
|
<commons-compress.version>1.21</commons-compress.version>
|
||||||
|
<java-jwt.version>4.0.0</java-jwt.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
@@ -62,6 +63,11 @@
|
|||||||
<version>${resilience4j-reactor.version}</version>
|
<version>${resilience4j-reactor.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Security -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Persistence -->
|
<!-- Persistence -->
|
||||||
<dependency>
|
<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.nio.file.Path;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface DetectedGameRepository extends JpaRepository<DetectedGame, String> {
|
public interface DetectedGameRepository extends JpaRepository<DetectedGame, String> {
|
||||||
|
|
||||||
@@ -13,6 +14,8 @@ public interface DetectedGameRepository extends JpaRepository<DetectedGame, Stri
|
|||||||
|
|
||||||
boolean existsBySlug(String slug);
|
boolean existsBySlug(String slug);
|
||||||
|
|
||||||
|
Optional<DetectedGame> findByPath(String path);
|
||||||
|
|
||||||
List<DetectedGame> getAllByPathNotIn(Collection<String> paths);
|
List<DetectedGame> getAllByPathNotIn(Collection<String> paths);
|
||||||
|
|
||||||
default List<DetectedGame> getAllByPathNotIn(List<Path> 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.nio.file.Path;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface UnmappableFileRepository extends JpaRepository<UnmappableFile, Long> {
|
public interface UnmappableFileRepository extends JpaRepository<UnmappableFile, Long> {
|
||||||
|
|
||||||
@@ -13,6 +14,8 @@ public interface UnmappableFileRepository extends JpaRepository<UnmappableFile,
|
|||||||
|
|
||||||
List<UnmappableFile> getAllByPathNotIn(Collection<String> paths);
|
List<UnmappableFile> getAllByPathNotIn(Collection<String> paths);
|
||||||
|
|
||||||
|
Optional<UnmappableFile> findByPath(String path);
|
||||||
|
|
||||||
default List<UnmappableFile> getAllByPathNotIn(List<Path> paths) {
|
default List<UnmappableFile> getAllByPathNotIn(List<Path> paths) {
|
||||||
List<String> pathStrings = paths.stream().map(Path::toString).toList();
|
List<String> pathStrings = paths.stream().map(Path::toString).toList();
|
||||||
return getAllByPathNotIn(pathStrings);
|
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;
|
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.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -49,17 +50,39 @@ public class GameService {
|
|||||||
public List<GameOverviewDto> getGameOverviews() {
|
public List<GameOverviewDto> getGameOverviews() {
|
||||||
return detectedGameRepository.findAll().stream().map(GameMapper::toGameOverviewDto).toList();
|
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)
|
public DetectedGame mapPathToGame(String path, String slug) {
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Unmapped file with id '%d' does not exist.".formatted(unmappedGameId)));
|
|
||||||
|
|
||||||
if(detectedGameRepository.existsBySlug(igdbSlug))
|
if(detectedGameRepository.existsBySlug(slug))
|
||||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Game with slug '%s' already exists in database.".formatted(igdbSlug));
|
throw new ResponseStatusException(HttpStatus.CONFLICT, "Game with slug '%s' already exists in database.".formatted(slug));
|
||||||
|
|
||||||
Igdb.Game igdbGame = igdbWrapper.getGameBySlug(igdbSlug)
|
Optional<UnmappableFile> optionalUnmappableFile = unmappableFileRepository.findByPath(path);
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Game with slug '%s' does not exist on IGDB.".formatted(igdbSlug)));
|
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()));
|
DetectedGame game = GameMapper.toDetectedGame(igdbGame, Path.of(unmappableFile.getPath()));
|
||||||
game = detectedGameRepository.save(game);
|
game = detectedGameRepository.save(game);
|
||||||
@@ -68,4 +91,16 @@ public class GameService {
|
|||||||
|
|
||||||
return game;
|
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:
|
gameyfin:
|
||||||
|
user: admin
|
||||||
|
password: 112
|
||||||
root: C:\Projects\privat\gameyfin-library
|
root: C:\Projects\privat\gameyfin-library
|
||||||
#root: \\NAS-Simon\Öffentlich\Spiele
|
#root: \\NAS-Simon\Öffentlich\Spiele
|
||||||
cache: ${gameyfin.root}\.gameyfin\cache
|
cache: ${gameyfin.root}\.gameyfin\cache
|
||||||
|
|||||||
@@ -1,25 +1,16 @@
|
|||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
error.include-stacktrace: never
|
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
mvc:
|
|
||||||
async.request-timeout: -1
|
|
||||||
jackson.default-property-inclusion: non_null
|
|
||||||
datasource.db-name: gameyfin_db
|
datasource.db-name: gameyfin_db
|
||||||
datasource.url: jdbc:h2:file:${gameyfin.db}/${spring.datasource.db-name};AUTO_SERVER=TRUE
|
datasource.url: jdbc:h2:file:${gameyfin.db}/${spring.datasource.db-name};AUTO_SERVER=TRUE
|
||||||
datasource.username: gfadmin
|
datasource.username: gfadmin
|
||||||
datasource.password: gameyfin
|
datasource.password: gameyfin
|
||||||
datasource.driverClassName: org.h2.Driver
|
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:
|
gameyfin:
|
||||||
|
user: ""
|
||||||
|
password: ""
|
||||||
root: ""
|
root: ""
|
||||||
cache: ${gameyfin.root}\.gameyfin\cache
|
cache: ${gameyfin.root}\.gameyfin\cache
|
||||||
db: ${gameyfin.root}\.gameyfin\db # Currently unused
|
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="column" fxFlex="0 1 70" fxLayoutGap="16px" fxFlex.lt-xl="95">
|
||||||
<div fxLayout="row" fxLayout.lt-lg="column" fxLayoutGap="16px">
|
<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">
|
<div fxFlex="40" fxLayout="column" id="game-details">
|
||||||
<h1>{{game.title}}</h1>
|
<h1>{{game.title}}</h1>
|
||||||
|
|||||||
Reference in New Issue
Block a user