mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +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>
|
||||
<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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user