v2.0.0.beta5 (#626)

* Fix wrong version property used in release.yml

* Implement "Allow access to Gameyfin without login"

* Implement filter by keyword (closes #613)

* Fix bug where secret fields would be displayed as normal text

* Optimize Gradle build performance

* Fix ant path matchers

* Fix NPE in role authority mapper (fixes #614)
This commit is contained in:
Simon
2025-07-16 22:39:09 +02:00
committed by GitHub
parent 49ff9474fb
commit edf7a569df
25 changed files with 168 additions and 66 deletions
+4 -4
View File
@@ -116,12 +116,12 @@ jobs:
if: ${{ github.event.inputs.update_version }}
uses: stefanzweifel/git-auto-commit-action@v6
with:
commit_message: 'chore: release v${{ needs.setup.outputs.release_version }}'
tagging_message: v${{ needs.setup.outputs.release_version }}
commit_message: 'chore: release v${{ github.event.inputs.version }}'
tagging_message: v${{ github.event.inputs.version }}
- name: Detect prerelease
id: detect_prerelease
run: |
if [[ "${{ needs.setup.outputs.release_version }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
if [[ "${{ github.event.inputs.version }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "IS_PRERELEASE=false" >> $GITHUB_ENV
echo "MAKE_LATEST=true" >> $GITHUB_ENV
else
@@ -132,6 +132,6 @@ jobs:
if: ${{ github.event.inputs.update_version }}
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ needs.setup.outputs.release_version }}
tag_name: v${{ github.event.inputs.version }}
prerelease: ${{ env.IS_PRERELEASE }}
make_latest: ${{ env.MAKE_LATEST }}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gameyfin",
"version": "2.0.0.beta4",
"version": "2.0.0.beta5",
"type": "module",
"dependencies": {
"@heroui/react": "2.7.9",
@@ -13,7 +13,7 @@ export default function ProfileMenu() {
async function logout() {
if (auth.state.user?.managedBySso) {
window.location.href = (await ConfigEndpoint.getLogoutUrl()) || "/";
window.location.href = (await ConfigEndpoint.getSsoLogoutUrl()) || "/";
} else {
await auth.logout();
}
@@ -38,7 +38,7 @@ function LibraryManagementLayout({getConfig, formik}: any) {
return (
<div className="flex flex-col">
<Section title="Permissions"/>
<ConfigFormField configElement={getConfig("library.allow-public-access")} isDisabled/>
<ConfigFormField configElement={getConfig("library.allow-public-access")}/>
<Section title="Scanning"/>
<div className="flex flex-col gap-4">
@@ -87,6 +87,8 @@ export default function EditGameMetadataModal({game, isOpen, onOpenChange}: Edit
<ArrayInput key="features" name="features" label="Features"/>
<ArrayInput key="perspectives" name="perspectives"
label="Perspectives"/>
<ArrayInput key="keywords" name="keywords"
label="Keywords"/>
</AccordionItem>
</Accordion>
</ModalBody>
+3 -1
View File
@@ -23,6 +23,7 @@ import SearchView from "Frontend/views/SearchView";
import RecentlyAddedView from "Frontend/views/RecentlyAddedView";
import LibraryView from "Frontend/views/LibraryView";
import {RouterConfigurationBuilder} from "@vaadin/hilla-file-router/runtime.js";
import {ConfigEndpoint} from "Frontend/generated/endpoints";
export const {router, routes} = new RouterConfigurationBuilder()
.withReactRoutes([
@@ -32,7 +33,7 @@ export const {router, routes} = new RouterConfigurationBuilder()
children: [
{
element: <MainLayout/>,
handle: {requiresLogin: true},
handle: {requiresLogin: !ConfigEndpoint.isPublicAccessEnabled()},
children: [
{
index: true, element: <HomeView/>
@@ -64,6 +65,7 @@ export const {router, routes} = new RouterConfigurationBuilder()
{
path: 'administration',
element: <AdministrationView/>,
handle: {requiresLogin: true},
children: [
{
path: 'libraries',
+19 -4
View File
@@ -6,7 +6,7 @@ import GameyfinLogo from "Frontend/components/theming/GameyfinLogo";
import * as PackageJson from "../../../../package.json";
import {Outlet, useLocation, useNavigate} from "react-router";
import {useAuth} from "Frontend/util/auth";
import {ArrowLeft, DiceSix, Heart, House, ListMagnifyingGlass} from "@phosphor-icons/react";
import {ArrowLeft, DiceSix, Heart, House, ListMagnifyingGlass, SignIn} from "@phosphor-icons/react";
import Confetti, {ConfettiProps} from "react-confetti-boom";
import {useTheme} from "next-themes";
import {UserPreferenceService} from "Frontend/util/user-preference-service";
@@ -103,9 +103,24 @@ export default function MainLayout() {
<ScanProgressPopover/>
</NavbarItem>
}
<NavbarItem>
<ProfileMenu/>
</NavbarItem>
{auth.state.user &&
<NavbarItem>
<ProfileMenu/>
</NavbarItem>
}
{!auth.state.user &&
<NavbarItem>
<Tooltip content="Sign in to your account" placement="bottom">
<Button color="primary"
radius="full"
isIconOnly
className="gradient-primary"
onPress={() => navigate("/login")}>
<SignIn fill="text-background/80"/>
</Button>
</Tooltip>
</NavbarItem>
}
</NavbarContent>
</Navbar>
+41 -9
View File
@@ -18,6 +18,7 @@ export default function SearchView() {
const knownThemes = useSnapshot(gameState).knownThemes;
const knownFeatures = useSnapshot(gameState).knownFeatures;
const knownPerspectives = useSnapshot(gameState).knownPerspectives;
const knownKeywords = useSnapshot(gameState).knownKeywords;
const libraries = useSnapshot(libraryState).libraries as LibraryDto[];
const [searchParams, setSearchParams] = useSearchParams();
@@ -31,6 +32,7 @@ export default function SearchView() {
const [selectedThemes, setSelectedThemes] = useState<Set<string>>(new Set());
const [selectedFeatures, setSelectedFeatures] = useState<Set<string>>(new Set());
const [selectedPerspectives, setSelectedPerspectives] = useState<Set<string>>(new Set());
const [selectedKeywords, setSelectedKeywords] = useState<Set<string>>(new Set());
// Load initial filter values from URL parameters on component mount
useEffect(() => {
@@ -42,6 +44,7 @@ export default function SearchView() {
const themes = searchParams.getAll("theme");
const features = searchParams.getAll("feature");
const perspectives = searchParams.getAll("perspective");
const keywords = searchParams.getAll("keyword");
setSearchTerm(term);
setSelectedLibraries(new Set(libs));
@@ -50,6 +53,7 @@ export default function SearchView() {
setSelectedThemes(new Set(themes));
setSelectedFeatures(new Set(features));
setSelectedPerspectives(new Set(perspectives));
setSelectedKeywords(new Set(keywords));
setInitialLoadComplete(true);
}, []);
@@ -102,15 +106,21 @@ export default function SearchView() {
});
}
if (selectedKeywords.size > 0) {
selectedKeywords.forEach(keyword => {
newParams.append("keyword", keyword);
});
}
setSearchParams(newParams, {replace: true});
}, [searchTerm, selectedLibraries, selectedDevelopers, selectedGenres,
selectedThemes, selectedFeatures, selectedPerspectives]);
selectedThemes, selectedFeatures, selectedPerspectives, selectedKeywords]);
const filteredGames = useMemo(() => filterGames(), [
games, searchTerm,
selectedLibraries, selectedDevelopers,
selectedGenres, selectedThemes,
selectedFeatures, selectedPerspectives
selectedFeatures, selectedPerspectives, selectedKeywords
]);
function filterGames(): GameDto[] {
@@ -164,6 +174,13 @@ export default function SearchView() {
);
}
// Apply keyword filter
if (selectedKeywords.size > 0) {
filtered = filtered.filter(game =>
game.keywords?.some(keyword => selectedKeywords.has(keyword))
);
}
return filtered;
}
@@ -183,10 +200,17 @@ export default function SearchView() {
onChange={(event) => setSearchTerm(event.target.value)}
onClear={() => setSearchTerm("")}
/>
<div className="flex flex-row flex-wrap gap-2 justify-center">
<div
className="w-full justify-center"
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))",
gap: "0.5rem",
margin: "0 auto"
}}
>
<Select
size="sm"
className="max-w-xs"
selectionMode="multiple"
label="Libraries"
placeholder="Filter by library"
@@ -200,7 +224,6 @@ export default function SearchView() {
</Select>
<Select
size="sm"
className="max-w-xs"
selectionMode="multiple"
label="Developers"
placeholder="Filter by developer"
@@ -214,7 +237,6 @@ export default function SearchView() {
</Select>
<Select
size="sm"
className="max-w-xs"
selectionMode="multiple"
label="Genres"
placeholder="Filter by genre"
@@ -228,7 +250,6 @@ export default function SearchView() {
</Select>
<Select
size="sm"
className="max-w-xs"
selectionMode="multiple"
label="Themes"
placeholder="Filter by theme"
@@ -242,7 +263,6 @@ export default function SearchView() {
</Select>
<Select
size="sm"
className="max-w-xs"
selectionMode="multiple"
label="Features"
placeholder="Filter by feature"
@@ -256,7 +276,6 @@ export default function SearchView() {
</Select>
<Select
size="sm"
className="max-w-xs"
selectionMode="multiple"
label="Perspectives"
placeholder="Filter by perspective"
@@ -268,6 +287,19 @@ export default function SearchView() {
<SelectItem key={perspective}>{toTitleCase(perspective)}</SelectItem>
))}
</Select>
<Select
size="sm"
selectionMode="multiple"
label="Keywords"
placeholder="Filter by keyword"
selectedKeys={selectedKeywords}
//@ts-ignore
onSelectionChange={setSelectedKeywords}
>
{Array.from(knownKeywords).map((keyword) => (
<SelectItem key={keyword}>{keyword}</SelectItem>
))}
</Select>
</div>
<div className="mt-4 w-full px-4 select-none">
<CoverGrid games={filteredGames}/>
@@ -1,5 +1,6 @@
package org.gameyfin.app.config
import com.vaadin.flow.server.auth.AnonymousAllowed
import com.vaadin.hilla.Endpoint
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.annotation.security.PermitAll
@@ -7,6 +8,7 @@ import jakarta.annotation.security.RolesAllowed
import org.gameyfin.app.config.dto.ConfigEntryDto
import org.gameyfin.app.config.dto.ConfigUpdateDto
import org.gameyfin.app.core.Role
import org.gameyfin.app.core.annotations.DynamicPublicAccess
import org.gameyfin.app.users.UserService
import org.gameyfin.app.users.util.isAdmin
import reactor.core.publisher.Flux
@@ -36,9 +38,16 @@ class ConfigEndpoint(
/** Specific read-only endpoint for all users **/
@PermitAll
fun isSsoEnabled(): Boolean? = configService.get(ConfigProperties.SSO.OIDC.Enabled)
@DynamicPublicAccess
@AnonymousAllowed
fun isSsoEnabled(): Boolean = configService.get(ConfigProperties.SSO.OIDC.Enabled) == true
@DynamicPublicAccess
@AnonymousAllowed
fun getSsoLogoutUrl(): String? = configService.get(ConfigProperties.SSO.OIDC.LogoutUrl)
@DynamicPublicAccess
@AnonymousAllowed
fun isPublicAccessEnabled(): Boolean = configService.get(ConfigProperties.Libraries.AllowPublicAccess) == true
@PermitAll
fun getLogoutUrl(): String? = configService.get(ConfigProperties.SSO.OIDC.LogoutUrl)
}
@@ -17,7 +17,7 @@ sealed class ConfigProperties<T : Serializable>(
data object AllowPublicAccess : ConfigProperties<Boolean>(
Boolean::class,
"library.allow-public-access",
"Allow access to game libraries without login (coming soon™)",
"Allow access to Gameyfin without login",
false
)
@@ -1,16 +1,16 @@
package org.gameyfin.app.core.annotations
import org.gameyfin.app.config.ConfigService
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.gameyfin.app.config.ConfigProperties
import org.gameyfin.app.config.ConfigService
import org.springframework.stereotype.Component
import org.springframework.web.method.HandlerMethod
import org.springframework.web.servlet.HandlerInterceptor
@Component
class DynamicAccessInterceptor(
private val configService: ConfigService
private val config: ConfigService
) : HandlerInterceptor {
override fun preHandle(
@@ -20,15 +20,16 @@ class DynamicAccessInterceptor(
): Boolean {
val handlerMethod = (handler as? HandlerMethod) ?: return true
val method = handlerMethod.method
val clazz = handlerMethod.beanType
// Check if method is annotated with @DynamicPublicAccess
if (method.isAnnotationPresent(DynamicPublicAccess::class.java)) {
// Check if user is authenticated or public access is enabled
if (request.userPrincipal != null || configService.get(ConfigProperties.Libraries.AllowPublicAccess) == true) {
val hasDynamicPublicAccess =
method.isAnnotationPresent(DynamicPublicAccess::class.java) ||
clazz.isAnnotationPresent(DynamicPublicAccess::class.java)
if (hasDynamicPublicAccess) {
if (request.userPrincipal != null || config.get(ConfigProperties.Libraries.AllowPublicAccess) == true) {
return true
}
// Deny access if user is not logged in and public access is disabled
response.status = HttpServletResponse.SC_UNAUTHORIZED
return false
}
@@ -1,5 +1,6 @@
package org.gameyfin.app.core.download
import com.vaadin.flow.server.auth.AnonymousAllowed
import org.gameyfin.app.core.annotations.DynamicPublicAccess
import org.gameyfin.app.games.GameService
import org.gameyfin.pluginapi.download.FileDownload
@@ -11,6 +12,7 @@ import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBo
@RestController
@RequestMapping("/download")
@DynamicPublicAccess
@AnonymousAllowed
class DownloadEndpoint(
private val downloadService: DownloadService,
private val gameService: GameService
@@ -1,10 +1,12 @@
package org.gameyfin.app.core.download
import com.vaadin.flow.server.auth.AnonymousAllowed
import com.vaadin.hilla.Endpoint
import jakarta.annotation.security.PermitAll
import org.gameyfin.app.core.annotations.DynamicPublicAccess
@Endpoint
@PermitAll
@DynamicPublicAccess
@AnonymousAllowed
class DownloadProviderEndpoint(
private val downloadService: DownloadService
) {
@@ -109,8 +109,8 @@ class PluginService(
label = meta.label,
description = meta.description,
default = meta.default,
isSecret = meta.isSecret,
isRequired = meta.isRequired,
secret = meta.isSecret,
required = meta.isRequired,
allowedValues = meta.allowedValues?.map { it.toString() }
)
}
@@ -10,7 +10,7 @@ class PluginConfigMetadataDto(
val label: String,
val description: String,
val default: Serializable?,
val isSecret: Boolean,
val isRequired: Boolean,
val secret: Boolean,
val required: Boolean,
val allowedValues: List<String>?
)
@@ -0,0 +1,23 @@
package org.gameyfin.app.core.security
import org.gameyfin.app.config.ConfigProperties
import org.gameyfin.app.config.ConfigService
import org.springframework.security.authorization.AuthorizationDecision
import org.springframework.security.authorization.AuthorizationManager
import org.springframework.security.core.Authentication
import org.springframework.security.web.access.intercept.RequestAuthorizationContext
import java.util.function.Supplier
class DynamicPublicAccessAuthorizationManager(
private val config: ConfigService
) : AuthorizationManager<RequestAuthorizationContext> {
@Deprecated("Deprecated in superclass")
override fun check(
authentication: Supplier<Authentication?>?,
`object`: RequestAuthorizationContext?
): AuthorizationDecision? {
val allow = config.get(ConfigProperties.Libraries.AllowPublicAccess) == true
return AuthorizationDecision(allow)
}
}
@@ -39,11 +39,18 @@ class SecurityConfig(
// Configure your static resources with public access before calling super.configure(HttpSecurity) as it adds final anyRequest matcher
http.authorizeHttpRequests { auth: AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry ->
auth.requestMatchers("/setup").permitAll()
auth.requestMatchers("/login").permitAll()
.requestMatchers("/setup").permitAll()
.requestMatchers("/reset-password").permitAll()
.requestMatchers("/accept-invitation").permitAll()
.requestMatchers("/public/**").permitAll()
.requestMatchers("/images/**").permitAll()
// Dynamic public access for certain endpoints
auth.requestMatchers("/game/**").access(DynamicPublicAccessAuthorizationManager(config))
.requestMatchers("/library/**").access(DynamicPublicAccessAuthorizationManager(config))
.requestMatchers("/search/**").access(DynamicPublicAccessAuthorizationManager(config))
.requestMatchers("/download/**").access(DynamicPublicAccessAuthorizationManager(config))
}
http.sessionManagement { sessionManagement ->
@@ -1,20 +1,18 @@
package org.gameyfin.app.games
import com.vaadin.flow.server.auth.AnonymousAllowed
import com.vaadin.hilla.Endpoint
import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed
import org.gameyfin.app.core.Role
import org.gameyfin.app.games.dto.GameDto
import org.gameyfin.app.games.dto.GameEvent
import org.gameyfin.app.games.dto.GameSearchResultDto
import org.gameyfin.app.games.dto.GameUpdateDto
import org.gameyfin.app.games.dto.OriginalIdDto
import org.gameyfin.app.core.annotations.DynamicPublicAccess
import org.gameyfin.app.games.dto.*
import org.gameyfin.app.libraries.LibraryService
import reactor.core.publisher.Flux
import java.nio.file.Path
@Endpoint
@PermitAll
@DynamicPublicAccess
@AnonymousAllowed
class GameEndpoint(
private val gameService: GameService,
private val libraryService: LibraryService
@@ -1,9 +1,10 @@
package org.gameyfin.app.libraries
import com.vaadin.flow.server.auth.AnonymousAllowed
import com.vaadin.hilla.Endpoint
import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed
import org.gameyfin.app.core.Role
import org.gameyfin.app.core.annotations.DynamicPublicAccess
import org.gameyfin.app.libraries.dto.LibraryDto
import org.gameyfin.app.libraries.dto.LibraryEvent
import org.gameyfin.app.libraries.dto.LibraryScanProgress
@@ -14,7 +15,8 @@ import org.gameyfin.app.users.util.isAdmin
import reactor.core.publisher.Flux
@Endpoint
@PermitAll
@DynamicPublicAccess
@AnonymousAllowed
class LibraryEndpoint(
private val libraryService: LibraryService,
private val userService: UserService,
@@ -1,13 +1,15 @@
package org.gameyfin.app.media
import org.gameyfin.app.core.plugins.PluginService
import org.gameyfin.app.games.entities.Image
import org.gameyfin.app.games.entities.ImageType
import org.gameyfin.app.users.UserService
import com.vaadin.flow.server.auth.AnonymousAllowed
import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed
import org.gameyfin.app.core.Role
import org.gameyfin.app.core.Utils
import org.gameyfin.app.core.annotations.DynamicPublicAccess
import org.gameyfin.app.core.plugins.PluginService
import org.gameyfin.app.games.entities.Image
import org.gameyfin.app.games.entities.ImageType
import org.gameyfin.app.users.UserService
import org.springframework.core.io.ByteArrayResource
import org.springframework.core.io.InputStreamResource
import org.springframework.http.HttpHeaders
@@ -18,9 +20,10 @@ import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
@DynamicPublicAccess
@RestController
@RequestMapping("/images")
@DynamicPublicAccess
@AnonymousAllowed
class ImageEndpoint(
private val imageService: ImageService,
private val userService: UserService,
@@ -36,6 +39,7 @@ class ImageEndpoint(
fun getCover(@PathVariable("id") id: Long): ResponseEntity<InputStreamResource>? {
return getImageContent(id)
}
@GetMapping("/header/{id}")
fun getHeader(@PathVariable("id") id: Long): ResponseEntity<InputStreamResource>? {
return getImageContent(id)
@@ -54,6 +58,7 @@ class ImageEndpoint(
return getImageContent(avatar.id!!)
}
@PermitAll
@PostMapping("/avatar/upload")
fun uploadAvatar(@RequestParam("file") file: MultipartFile) {
val auth: Authentication = SecurityContextHolder.getContext().authentication
@@ -68,6 +73,7 @@ class ImageEndpoint(
userService.updateAvatar(auth.name, image)
}
@PermitAll
@PostMapping("/avatar/delete")
fun deleteAvatar() {
val auth: Authentication = SecurityContextHolder.getContext().authentication
@@ -5,11 +5,10 @@ import jakarta.annotation.security.RolesAllowed
import org.gameyfin.app.core.Role
@Endpoint
@RolesAllowed(Role.Names.ADMIN)
class SystemEndpoint(
private val systemService: SystemService
) {
@RolesAllowed(Role.Names.ADMIN)
fun restart() {
systemService.restart()
}
@@ -1,8 +1,8 @@
package org.gameyfin.app.users
import org.gameyfin.app.users.persistence.UserRepository
import org.gameyfin.app.core.Role
import org.gameyfin.app.users.entities.User
import org.gameyfin.app.users.persistence.UserRepository
import org.springframework.security.core.Authentication
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
@@ -66,7 +66,7 @@ class RoleService(
.filterIsInstance<OidcUserAuthority>()
.flatMap { oidcUserAuthority ->
val userInfo = oidcUserAuthority.userInfo
val roles = userInfo.getClaim<List<String>>("roles")
val roles = userInfo.getClaim<List<String>>("roles") ?: return@flatMap emptySequence()
roles.asSequence().mapNotNull {
if (it.startsWith(SSO_ROLE_PREFIX)) SimpleGrantedAuthority(
it.replace(SSO_ROLE_PREFIX, INTERNAL_ROLE_PREFIX)
@@ -16,11 +16,6 @@ class UserEndpoint(
private val userService: UserService,
private val roleService: RoleService
) {
@PermitAll
fun existsByMail(email: String): Boolean {
return userService.existsByEmail(email)
}
@PermitAll
fun getUserInfo(): UserInfoDto {
return userService.getUserInfo()
@@ -32,6 +27,11 @@ class UserEndpoint(
userService.updateUser(auth.name, updates)
}
@RolesAllowed(Role.Names.ADMIN)
fun existsByMail(email: String): Boolean {
return userService.existsByEmail(email)
}
@RolesAllowed(Role.Names.ADMIN)
fun getAllUsers(): List<UserInfoDto> {
return userService.getAllUsers()
+1 -1
View File
@@ -6,7 +6,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
import java.nio.file.Files
group = "org.gameyfin"
version = "2.0.0.beta4"
version = "2.0.0.beta5"
allprojects {
repositories {
+3 -1
View File
@@ -1,5 +1,7 @@
# Increase Gradle metaspace size
# Gradle properties
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m
org.gradle.parallel=true
org.gradle.caching=true
# Plugin versions
kotlinVersion=2.2.0
kspVersion=2.2.0-2.0.2