Release 2.1

This commit is contained in:
Simon
2025-09-29 17:43:57 +02:00
committed by GitHub
94 changed files with 3498 additions and 1633 deletions
@@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="GameyfinApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot" nameIsGenerated="true"> <configuration default="false" name="Gameyfin" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot">
<option name="ACTIVE_PROFILES" value="dev" /> <option name="ACTIVE_PROFILES" value="dev" />
<option name="ALTERNATIVE_JRE_PATH" value="BUNDLED" /> <option name="ALTERNATIVE_JRE_PATH" value="BUNDLED" />
<envs> <envs>
+1
View File
@@ -54,6 +54,7 @@ dependencies {
// Persistence & I/O // Persistence & I/O
implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("com.github.paulcwarren:spring-content-fs-boot-starter:3.0.17") implementation("com.github.paulcwarren:spring-content-fs-boot-starter:3.0.17")
implementation("org.flywaydb:flyway-core")
implementation("commons-io:commons-io:2.18.0") implementation("commons-io:commons-io:2.18.0")
// SSO // SSO
+802 -1128
View File
File diff suppressed because it is too large Load Diff
+113 -113
View File
@@ -1,6 +1,6 @@
{ {
"name": "gameyfin", "name": "gameyfin",
"version": "2.0.1", "version": "2.1.0-preview",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@heroui/react": "2.7.9", "@heroui/react": "2.7.9",
@@ -9,22 +9,22 @@
"@polymer/polymer": "3.5.2", "@polymer/polymer": "3.5.2",
"@react-stately/data": "^3.12.2", "@react-stately/data": "^3.12.2",
"@react-types/shared": "^3.28.0", "@react-types/shared": "^3.28.0",
"@vaadin/bundles": "24.8.3", "@vaadin/bundles": "24.9.0",
"@vaadin/common-frontend": "0.0.19", "@vaadin/common-frontend": "0.0.19",
"@vaadin/hilla-file-router": "24.8.2", "@vaadin/hilla-file-router": "24.9.0",
"@vaadin/hilla-frontend": "24.8.2", "@vaadin/hilla-frontend": "24.9.0",
"@vaadin/hilla-lit-form": "24.8.2", "@vaadin/hilla-lit-form": "24.9.0",
"@vaadin/hilla-react-auth": "24.8.2", "@vaadin/hilla-react-auth": "24.9.0",
"@vaadin/hilla-react-crud": "24.8.2", "@vaadin/hilla-react-crud": "24.9.0",
"@vaadin/hilla-react-form": "24.8.2", "@vaadin/hilla-react-form": "24.9.0",
"@vaadin/hilla-react-i18n": "24.8.2", "@vaadin/hilla-react-i18n": "24.9.0",
"@vaadin/hilla-react-signals": "24.8.2", "@vaadin/hilla-react-signals": "24.9.0",
"@vaadin/polymer-legacy-adapter": "24.8.3", "@vaadin/polymer-legacy-adapter": "24.9.0",
"@vaadin/react-components": "24.8.3", "@vaadin/react-components": "24.9.0",
"@vaadin/vaadin-development-mode-detector": "2.0.7", "@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.8.3", "@vaadin/vaadin-lumo-styles": "24.9.0",
"@vaadin/vaadin-material-styles": "24.8.3", "@vaadin/vaadin-material-styles": "24.9.0",
"@vaadin/vaadin-themable-mixin": "24.8.3", "@vaadin/vaadin-themable-mixin": "24.9.0",
"@vaadin/vaadin-usage-statistics": "2.1.3", "@vaadin/vaadin-usage-statistics": "2.1.3",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"construct-style-sheets-polyfill": "3.1.0", "construct-style-sheets-polyfill": "3.1.0",
@@ -61,17 +61,17 @@
"@types/node": "^22.4.0", "@types/node": "^22.4.0",
"@types/react": "18.3.23", "@types/react": "18.3.23",
"@types/react-dom": "18.3.7", "@types/react-dom": "18.3.7",
"@vaadin/hilla-generator-cli": "24.8.2", "@vaadin/hilla-generator-cli": "24.9.0",
"@vaadin/hilla-generator-core": "24.8.2", "@vaadin/hilla-generator-core": "24.9.0",
"@vaadin/hilla-generator-plugin-backbone": "24.8.2", "@vaadin/hilla-generator-plugin-backbone": "24.9.0",
"@vaadin/hilla-generator-plugin-barrel": "24.8.2", "@vaadin/hilla-generator-plugin-barrel": "24.9.0",
"@vaadin/hilla-generator-plugin-client": "24.8.2", "@vaadin/hilla-generator-plugin-client": "24.9.0",
"@vaadin/hilla-generator-plugin-model": "24.8.2", "@vaadin/hilla-generator-plugin-model": "24.9.0",
"@vaadin/hilla-generator-plugin-push": "24.8.2", "@vaadin/hilla-generator-plugin-push": "24.9.0",
"@vaadin/hilla-generator-plugin-signals": "24.8.2", "@vaadin/hilla-generator-plugin-signals": "24.9.0",
"@vaadin/hilla-generator-plugin-subtypes": "24.8.2", "@vaadin/hilla-generator-plugin-subtypes": "24.9.0",
"@vaadin/hilla-generator-plugin-transfertypes": "24.8.2", "@vaadin/hilla-generator-plugin-transfertypes": "24.9.0",
"@vaadin/hilla-generator-utils": "24.8.2", "@vaadin/hilla-generator-utils": "24.9.0",
"@vitejs/plugin-react": "4.5.0", "@vitejs/plugin-react": "4.5.0",
"@vitejs/plugin-react-swc": "^3.7.0", "@vitejs/plugin-react-swc": "^3.7.0",
"async": "3.2.6", "async": "3.2.6",
@@ -86,7 +86,7 @@
"tailwindcss": "^3.4.13", "tailwindcss": "^3.4.13",
"transform-ast": "2.4.4", "transform-ast": "2.4.4",
"typescript": "5.8.3", "typescript": "5.8.3",
"vite": "6.3.5", "vite": "6.3.6",
"vite-plugin-checker": "0.9.3", "vite-plugin-checker": "0.9.3",
"workbox-build": "7.3.0", "workbox-build": "7.3.0",
"workbox-core": "7.3.0", "workbox-core": "7.3.0",
@@ -142,85 +142,85 @@
"valtio": "$valtio", "valtio": "$valtio",
"valtio-reactive": "$valtio-reactive", "valtio-reactive": "$valtio-reactive",
"fzf": "$fzf", "fzf": "$fzf",
"@vaadin/a11y-base": "24.8.3", "@vaadin/a11y-base": "24.9.0",
"@vaadin/accordion": "24.8.3", "@vaadin/accordion": "24.9.0",
"@vaadin/app-layout": "24.8.3", "@vaadin/app-layout": "24.9.0",
"@vaadin/avatar": "24.8.3", "@vaadin/avatar": "24.9.0",
"@vaadin/avatar-group": "24.8.3", "@vaadin/avatar-group": "24.9.0",
"@vaadin/button": "24.8.3", "@vaadin/button": "24.9.0",
"@vaadin/card": "24.8.3", "@vaadin/card": "24.9.0",
"@vaadin/checkbox": "24.8.3", "@vaadin/checkbox": "24.9.0",
"@vaadin/checkbox-group": "24.8.3", "@vaadin/checkbox-group": "24.9.0",
"@vaadin/combo-box": "24.8.3", "@vaadin/combo-box": "24.9.0",
"@vaadin/component-base": "24.8.3", "@vaadin/component-base": "24.9.0",
"@vaadin/confirm-dialog": "24.8.3", "@vaadin/confirm-dialog": "24.9.0",
"@vaadin/context-menu": "24.8.3", "@vaadin/context-menu": "24.9.0",
"@vaadin/custom-field": "24.8.3", "@vaadin/custom-field": "24.9.0",
"@vaadin/date-picker": "24.8.3", "@vaadin/date-picker": "24.9.0",
"@vaadin/date-time-picker": "24.8.3", "@vaadin/date-time-picker": "24.9.0",
"@vaadin/details": "24.8.3", "@vaadin/details": "24.9.0",
"@vaadin/dialog": "24.8.3", "@vaadin/dialog": "24.9.0",
"@vaadin/email-field": "24.8.3", "@vaadin/email-field": "24.9.0",
"@vaadin/field-base": "24.8.3", "@vaadin/field-base": "24.9.0",
"@vaadin/field-highlighter": "24.8.3", "@vaadin/field-highlighter": "24.9.0",
"@vaadin/form-layout": "24.8.3", "@vaadin/form-layout": "24.9.0",
"@vaadin/grid": "24.8.3", "@vaadin/grid": "24.9.0",
"@vaadin/horizontal-layout": "24.8.3", "@vaadin/horizontal-layout": "24.9.0",
"@vaadin/icon": "24.8.3", "@vaadin/icon": "24.9.0",
"@vaadin/icons": "24.8.3", "@vaadin/icons": "24.9.0",
"@vaadin/input-container": "24.8.3", "@vaadin/input-container": "24.9.0",
"@vaadin/integer-field": "24.8.3", "@vaadin/integer-field": "24.9.0",
"@vaadin/item": "24.8.3", "@vaadin/item": "24.9.0",
"@vaadin/list-box": "24.8.3", "@vaadin/list-box": "24.9.0",
"@vaadin/lit-renderer": "24.8.3", "@vaadin/lit-renderer": "24.9.0",
"@vaadin/login": "24.8.3", "@vaadin/login": "24.9.0",
"@vaadin/markdown": "24.8.3", "@vaadin/markdown": "24.9.0",
"@vaadin/master-detail-layout": "24.8.3", "@vaadin/master-detail-layout": "24.9.0",
"@vaadin/menu-bar": "24.8.3", "@vaadin/menu-bar": "24.9.0",
"@vaadin/message-input": "24.8.3", "@vaadin/message-input": "24.9.0",
"@vaadin/message-list": "24.8.3", "@vaadin/message-list": "24.9.0",
"@vaadin/multi-select-combo-box": "24.8.3", "@vaadin/multi-select-combo-box": "24.9.0",
"@vaadin/notification": "24.8.3", "@vaadin/notification": "24.9.0",
"@vaadin/number-field": "24.8.3", "@vaadin/number-field": "24.9.0",
"@vaadin/overlay": "24.8.3", "@vaadin/overlay": "24.9.0",
"@vaadin/password-field": "24.8.3", "@vaadin/password-field": "24.9.0",
"@vaadin/popover": "24.8.3", "@vaadin/popover": "24.9.0",
"@vaadin/progress-bar": "24.8.3", "@vaadin/progress-bar": "24.9.0",
"@vaadin/radio-group": "24.8.3", "@vaadin/radio-group": "24.9.0",
"@vaadin/scroller": "24.8.3", "@vaadin/scroller": "24.9.0",
"@vaadin/select": "24.8.3", "@vaadin/select": "24.9.0",
"@vaadin/side-nav": "24.8.3", "@vaadin/side-nav": "24.9.0",
"@vaadin/split-layout": "24.8.3", "@vaadin/split-layout": "24.9.0",
"@vaadin/tabs": "24.8.3", "@vaadin/tabs": "24.9.0",
"@vaadin/tabsheet": "24.8.3", "@vaadin/tabsheet": "24.9.0",
"@vaadin/text-area": "24.8.3", "@vaadin/text-area": "24.9.0",
"@vaadin/text-field": "24.8.3", "@vaadin/text-field": "24.9.0",
"@vaadin/time-picker": "24.8.3", "@vaadin/time-picker": "24.9.0",
"@vaadin/tooltip": "24.8.3", "@vaadin/tooltip": "24.9.0",
"@vaadin/upload": "24.8.3", "@vaadin/upload": "24.9.0",
"@vaadin/router": "2.0.0", "@vaadin/router": "2.0.0",
"@vaadin/vertical-layout": "24.8.3", "@vaadin/vertical-layout": "24.9.0",
"@vaadin/virtual-list": "24.8.3" "@vaadin/virtual-list": "24.9.0"
}, },
"vaadin": { "vaadin": {
"dependencies": { "dependencies": {
"@polymer/polymer": "3.5.2", "@polymer/polymer": "3.5.2",
"@vaadin/bundles": "24.8.3", "@vaadin/bundles": "24.9.0",
"@vaadin/common-frontend": "0.0.19", "@vaadin/common-frontend": "0.0.19",
"@vaadin/hilla-file-router": "24.8.2", "@vaadin/hilla-file-router": "24.9.0",
"@vaadin/hilla-frontend": "24.8.2", "@vaadin/hilla-frontend": "24.9.0",
"@vaadin/hilla-lit-form": "24.8.2", "@vaadin/hilla-lit-form": "24.9.0",
"@vaadin/hilla-react-auth": "24.8.2", "@vaadin/hilla-react-auth": "24.9.0",
"@vaadin/hilla-react-crud": "24.8.2", "@vaadin/hilla-react-crud": "24.9.0",
"@vaadin/hilla-react-form": "24.8.2", "@vaadin/hilla-react-form": "24.9.0",
"@vaadin/hilla-react-i18n": "24.8.2", "@vaadin/hilla-react-i18n": "24.9.0",
"@vaadin/hilla-react-signals": "24.8.2", "@vaadin/hilla-react-signals": "24.9.0",
"@vaadin/polymer-legacy-adapter": "24.8.3", "@vaadin/polymer-legacy-adapter": "24.9.0",
"@vaadin/react-components": "24.8.3", "@vaadin/react-components": "24.9.0",
"@vaadin/vaadin-development-mode-detector": "2.0.7", "@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.8.3", "@vaadin/vaadin-lumo-styles": "24.9.0",
"@vaadin/vaadin-material-styles": "24.8.3", "@vaadin/vaadin-material-styles": "24.9.0",
"@vaadin/vaadin-themable-mixin": "24.8.3", "@vaadin/vaadin-themable-mixin": "24.9.0",
"@vaadin/vaadin-usage-statistics": "2.1.3", "@vaadin/vaadin-usage-statistics": "2.1.3",
"construct-style-sheets-polyfill": "3.1.0", "construct-style-sheets-polyfill": "3.1.0",
"date-fns": "2.29.3", "date-fns": "2.29.3",
@@ -236,17 +236,17 @@
"@rollup/pluginutils": "5.1.4", "@rollup/pluginutils": "5.1.4",
"@types/react": "18.3.23", "@types/react": "18.3.23",
"@types/react-dom": "18.3.7", "@types/react-dom": "18.3.7",
"@vaadin/hilla-generator-cli": "24.8.2", "@vaadin/hilla-generator-cli": "24.9.0",
"@vaadin/hilla-generator-core": "24.8.2", "@vaadin/hilla-generator-core": "24.9.0",
"@vaadin/hilla-generator-plugin-backbone": "24.8.2", "@vaadin/hilla-generator-plugin-backbone": "24.9.0",
"@vaadin/hilla-generator-plugin-barrel": "24.8.2", "@vaadin/hilla-generator-plugin-barrel": "24.9.0",
"@vaadin/hilla-generator-plugin-client": "24.8.2", "@vaadin/hilla-generator-plugin-client": "24.9.0",
"@vaadin/hilla-generator-plugin-model": "24.8.2", "@vaadin/hilla-generator-plugin-model": "24.9.0",
"@vaadin/hilla-generator-plugin-push": "24.8.2", "@vaadin/hilla-generator-plugin-push": "24.9.0",
"@vaadin/hilla-generator-plugin-signals": "24.8.2", "@vaadin/hilla-generator-plugin-signals": "24.9.0",
"@vaadin/hilla-generator-plugin-subtypes": "24.8.2", "@vaadin/hilla-generator-plugin-subtypes": "24.9.0",
"@vaadin/hilla-generator-plugin-transfertypes": "24.8.2", "@vaadin/hilla-generator-plugin-transfertypes": "24.9.0",
"@vaadin/hilla-generator-utils": "24.8.2", "@vaadin/hilla-generator-utils": "24.9.0",
"@vitejs/plugin-react": "4.5.0", "@vitejs/plugin-react": "4.5.0",
"async": "3.2.6", "async": "3.2.6",
"glob": "11.0.2", "glob": "11.0.2",
@@ -256,13 +256,13 @@
"strip-css-comments": "5.0.0", "strip-css-comments": "5.0.0",
"transform-ast": "2.4.4", "transform-ast": "2.4.4",
"typescript": "5.8.3", "typescript": "5.8.3",
"vite": "6.3.5", "vite": "6.3.6",
"vite-plugin-checker": "0.9.3", "vite-plugin-checker": "0.9.3",
"workbox-build": "7.3.0", "workbox-build": "7.3.0",
"workbox-core": "7.3.0", "workbox-core": "7.3.0",
"workbox-precaching": "7.3.0" "workbox-precaching": "7.3.0"
}, },
"disableUsageStatistics": true, "disableUsageStatistics": true,
"hash": "962eccc3fa0735d5234901be4f9e384096113c45bec22564a53688096d62aef4" "hash": "dba97848bdace60924f9cee496353baae70cfa4fccc7bacaf827807c51908866"
} }
} }
+3 -1
View File
@@ -15,6 +15,7 @@ import {initializePluginState} from "Frontend/state/PluginState";
import {isAdmin} from "Frontend/util/utils"; import {isAdmin} from "Frontend/util/utils";
import {useRouteMetadata} from "Frontend/util/routing"; import {useRouteMetadata} from "Frontend/util/routing";
import {useEffect} from "react"; import {useEffect} from "react";
import {initializeGameRequestState} from "Frontend/state/GameRequestState";
export default function App() { export default function App() {
client.middlewares = [ErrorHandlingMiddleware]; client.middlewares = [ErrorHandlingMiddleware];
@@ -45,10 +46,11 @@ function ViewWithAuth() {
initializeLibraryState(); initializeLibraryState();
initializeGameState(); initializeGameState();
initializeGameRequestState();
initializePluginState();
if (isAdmin(auth)) { if (isAdmin(auth)) {
initializeScanState(); initializeScanState();
initializePluginState();
} }
}, [auth]); }, [auth]);
@@ -2,7 +2,6 @@ import {useAuth} from "Frontend/util/auth";
import {GearFine, Question, SignOut, User} from "@phosphor-icons/react"; import {GearFine, Question, SignOut, User} from "@phosphor-icons/react";
import {Dropdown, DropdownItem, DropdownMenu, DropdownTrigger} from "@heroui/react"; import {Dropdown, DropdownItem, DropdownMenu, DropdownTrigger} from "@heroui/react";
import {useNavigate} from "react-router"; import {useNavigate} from "react-router";
import {ConfigEndpoint} from "Frontend/generated/endpoints";
import Avatar from "Frontend/components/general/Avatar"; import Avatar from "Frontend/components/general/Avatar";
import {CollectionElement} from "@react-types/shared"; import {CollectionElement} from "@react-types/shared";
import {isAdmin} from "Frontend/util/utils"; import {isAdmin} from "Frontend/util/utils";
@@ -11,14 +10,6 @@ export default function ProfileMenu() {
const auth = useAuth(); const auth = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
async function logout() {
if (auth.state.user?.managedBySso) {
window.location.href = (await ConfigEndpoint.getSsoLogoutUrl()) || "/";
} else {
await auth.logout();
}
}
const profileMenuItems = [ const profileMenuItems = [
{ {
label: "My Profile", label: "My Profile",
@@ -39,7 +30,7 @@ export default function ProfileMenu() {
{ {
label: "Sign Out", label: "Sign Out",
icon: <SignOut/>, icon: <SignOut/>,
onClick: logout, onClick: auth.logout,
color: "primary" color: "primary"
}, },
]; ];
@@ -0,0 +1,49 @@
import React from "react";
import withConfigPage from "Frontend/components/administration/withConfigPage";
import * as Yup from 'yup';
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
import Section from "Frontend/components/general/Section";
import {Button} from "@heroui/react";
import {useNavigate} from "react-router";
function GameRequestManagementLayout({getConfig, formik}: any) {
const navigate = useNavigate();
return (
<div className="flex flex-col">
<div className="flex flex-row">
<div className="flex flex-col flex-1">
<Section title="Game requests configuration"/>
<ConfigFormField configElement={getConfig("requests.games.enabled")}/>
<Section title="Permissions"/>
<div className="flex flex-row items-center gap-4">
<ConfigFormField
configElement={getConfig("requests.games.allow-guests-to-request-games")}
isDisabled={!formik.values.library["allow-public-access"]}/>
<ConfigFormField configElement={getConfig("requests.games.max-open-requests-per-user")}/>
</div>
<Button onPress={() => navigate("/requests")}>
Manage game requests
</Button>
</div>
</div>
</div>
);
}
const validationSchema = Yup.object({
requests: Yup.object({
games: Yup.object({
enabled: Yup.boolean().required("Required"),
"allow-guests-to-request-games": Yup.boolean().required("Required"),
"max-open-requests-per-user": Yup.number()
.min(0, "Must be at least 0")
.max(Number.MAX_SAFE_INTEGER, `Must be lower than ${Number.MAX_SAFE_INTEGER}`)
.required("Required"),
}).required("Required"),
}).required("Required"),
});
export const GameRequestManagement = withConfigPage(GameRequestManagementLayout, "Game Requests", validationSchema);
@@ -91,7 +91,7 @@ export default function ProfileManagement() {
{formik.values.newPassword.length > 0 && {formik.values.newPassword.length > 0 &&
<SmallInfoField icon={Info} <SmallInfoField icon={Info}
message="You will be logged out of all current sessions" message="You will be logged out of all current sessions"
className="text-foreground/70" className="text-default-500"
/> />
} }
<Button <Button
@@ -1,14 +1,25 @@
import React from "react"; import React from "react";
import {SystemEndpoint} from "Frontend/generated/endpoints"; import {SystemEndpoint} from "Frontend/generated/endpoints";
import withConfigPage from "Frontend/components/administration/withConfigPage"; import withConfigPage from "Frontend/components/administration/withConfigPage";
import {Button} from "@heroui/react"; import {addToast, Button} from "@heroui/react";
import Section from "Frontend/components/general/Section"; import Section from "Frontend/components/general/Section";
function SystemManagementLayout() { function SystemManagementLayout() {
function restart() {
SystemEndpoint.restart().then(() =>
addToast({
title: "Restarting",
description: "Gameyfin is restarting. This may take a few moments.",
color: "success"
})
);
}
return ( return (
<div className="flex flex-col mt-4"> <div className="flex flex-col mt-4">
<Section title="Restart Gameyfin"/> <Section title="Restart Gameyfin"/>
<Button onPress={() => SystemEndpoint.restart()}>Restart</Button> <Button onPress={restart}>Restart</Button>
</div> </div>
); );
} }
@@ -3,16 +3,16 @@ import ConfigFormField from "Frontend/components/administration/ConfigFormField"
import withConfigPage from "Frontend/components/administration/withConfigPage"; import withConfigPage from "Frontend/components/administration/withConfigPage";
import Section from "Frontend/components/general/Section"; import Section from "Frontend/components/general/Section";
import {UserEndpoint} from "Frontend/generated/endpoints"; import {UserEndpoint} from "Frontend/generated/endpoints";
import UserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/UserInfoDto";
import {UserManagementCard} from "Frontend/components/general/cards/UserManagementCard"; import {UserManagementCard} from "Frontend/components/general/cards/UserManagementCard";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField"; import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import {Info, UserPlus} from "@phosphor-icons/react"; import {Info, UserPlus} from "@phosphor-icons/react";
import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react"; import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
import InviteUserModal from "Frontend/components/general/modals/InviteUserModal"; import InviteUserModal from "Frontend/components/general/modals/InviteUserModal";
import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto";
function UserManagementLayout({getConfig, formik}: any) { function UserManagementLayout({getConfig, formik}: any) {
const inviteUserModal = useDisclosure(); const inviteUserModal = useDisclosure();
const [users, setUsers] = useState<UserInfoDto[]>([]); const [users, setUsers] = useState<ExtendedUserInfoDto[]>([]);
useEffect(() => { useEffect(() => {
UserEndpoint.getAllUsers().then( UserEndpoint.getAllUsers().then(
@@ -15,13 +15,22 @@ const Avatar = ({...props}) => {
} }
// TODO: Check if avatar can be loaded from SSO // TODO: Check if avatar can be loaded from SSO
return ( if (auth.state.user?.hasAvatar) {
<NextUiAvatar return (
showFallback <NextUiAvatar
src={`/images/avatar?username=${username}`} showFallback
{...props} src={`/images/avatar?username=${username}`}
/> {...props}
); />
);
} else {
return (
<NextUiAvatar
showFallback
{...props}
/>
);
}
} }
export default Avatar; export default Avatar;
@@ -0,0 +1,27 @@
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import {useMemo} from "react";
import {CircularProgress} from "@heroui/react";
import {metadataCompleteness} from "Frontend/util/utils";
interface MetadataCompletenessIndicatorProps {
game: GameDto;
}
export default function MetadataCompletenessIndicator({game}: MetadataCompletenessIndicatorProps) {
const completeness = useMemo(() => metadataCompleteness(game), [game]);
const color = useMemo(() => {
return completeness > 80 ? "success" : completeness > 50 ? "warning" : "danger";
}, [completeness]);
return <div className="flex flex-row items-center gap-1">
<CircularProgress
color={color}
value={completeness}
disableAnimation
size="sm"
strokeWidth={5}
/>
<p>{completeness}% </p>
</div>;
}
@@ -7,11 +7,11 @@ import Avatar from "Frontend/components/general/Avatar";
import ConfirmUserDeletionModal from "Frontend/components/general/modals/ConfirmUserDeletionModal"; import ConfirmUserDeletionModal from "Frontend/components/general/modals/ConfirmUserDeletionModal";
import PasswordResetTokenModal from "Frontend/components/general/modals/PasswortResetTokenModal"; import PasswordResetTokenModal from "Frontend/components/general/modals/PasswortResetTokenModal";
import TokenDto from "Frontend/generated/org/gameyfin/app/shared/token/TokenDto"; import TokenDto from "Frontend/generated/org/gameyfin/app/shared/token/TokenDto";
import UserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/UserInfoDto";
import RoleChip from "Frontend/components/general/RoleChip"; import RoleChip from "Frontend/components/general/RoleChip";
import AssignRolesModal from "Frontend/components/general/modals/AssignRolesModal"; import AssignRolesModal from "Frontend/components/general/modals/AssignRolesModal";
import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto";
export function UserManagementCard({user}: { user: UserInfoDto }) { export function UserManagementCard({user}: { user: ExtendedUserInfoDto }) {
const userDeletionConfirmationModal = useDisclosure(); const userDeletionConfirmationModal = useDisclosure();
const passwordResetTokenModal = useDisclosure(); const passwordResetTokenModal = useDisclosure();
const roleAssignmentModal = useDisclosure(); const roleAssignmentModal = useDisclosure();
@@ -141,7 +141,7 @@ export function UserManagementCard({user}: { user: UserInfoDto }) {
<p className="font-semibold">{user.username}</p> <p className="font-semibold">{user.username}</p>
<p className="text-sm max-w-44 truncate" title={user.email}>{user.email}</p> <p className="text-sm max-w-44 truncate" title={user.email}>{user.email}</p>
{user.roles?.map((role) => ( {user.roles?.map((role) => (
<RoleChip role={role as string}/> <RoleChip key={role} role={role as string}/>
))} ))}
</div> </div>
</div> </div>
@@ -26,6 +26,8 @@ import {useMemo, useState} from "react";
import EditGameMetadataModal from "Frontend/components/general/modals/EditGameMetadataModal"; import EditGameMetadataModal from "Frontend/components/general/modals/EditGameMetadataModal";
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal"; import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
import {GameAdminDto} from "Frontend/dtos/GameDtos"; import {GameAdminDto} from "Frontend/dtos/GameDtos";
import MetadataCompletenessIndicator from "Frontend/components/general/MetadataCompletenessIndicator";
import {metadataCompleteness} from "Frontend/util/utils";
interface LibraryManagementGamesProps { interface LibraryManagementGamesProps {
library: LibraryDto; library: LibraryDto;
@@ -67,6 +69,9 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
case "downloadCount": case "downloadCount":
cmp = a.metadata.downloadCount - b.metadata.downloadCount; cmp = a.metadata.downloadCount - b.metadata.downloadCount;
break; break;
case "completeness":
cmp = metadataCompleteness(a) - metadataCompleteness(b);
break;
default: default:
return 0; // No sorting if the column is not recognized return 0; // No sorting if the column is not recognized
} }
@@ -160,6 +165,7 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
<TableColumn key="addedToLibrary" allowsSorting>Added to library</TableColumn> <TableColumn key="addedToLibrary" allowsSorting>Added to library</TableColumn>
<TableColumn key="downloadCount" allowsSorting>Download count</TableColumn> <TableColumn key="downloadCount" allowsSorting>Download count</TableColumn>
<TableColumn>Path</TableColumn> <TableColumn>Path</TableColumn>
<TableColumn key="completeness" allowsSorting>Completeness</TableColumn>
{/* width={1} keeps the column as far to the right as possible*/} {/* width={1} keeps the column as far to the right as possible*/}
<TableColumn width={1}>Actions</TableColumn> <TableColumn width={1}>Actions</TableColumn>
</TableHeader> </TableHeader>
@@ -182,6 +188,9 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
<TableCell> <TableCell>
{item.metadata.path} {item.metadata.path}
</TableCell> </TableCell>
<TableCell>
<MetadataCompletenessIndicator game={item}/>
</TableCell>
<TableCell> <TableCell>
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
<Button isIconOnly size="sm" onPress={() => toggleMatchConfirmed(item)}> <Button isIconOnly size="sm" onPress={() => toggleMatchConfirmed(item)}>
@@ -12,14 +12,14 @@ import {
SelectItem SelectItem
} from "@heroui/react"; } from "@heroui/react";
import {UserEndpoint} from "Frontend/generated/endpoints"; import {UserEndpoint} from "Frontend/generated/endpoints";
import UserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/UserInfoDto";
import RoleChip from "Frontend/components/general/RoleChip"; import RoleChip from "Frontend/components/general/RoleChip";
import RoleAssignmentResult from "Frontend/generated/org/gameyfin/app/users/enums/RoleAssignmentResult"; import RoleAssignmentResult from "Frontend/generated/org/gameyfin/app/users/enums/RoleAssignmentResult";
import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto";
interface AssignRolesModalProps { interface AssignRolesModalProps {
isOpen: boolean; isOpen: boolean;
onOpenChange: () => void; onOpenChange: () => void;
user: UserInfoDto; user: ExtendedUserInfoDto;
} }
interface Role { interface Role {
@@ -81,7 +81,7 @@ export function GameCoverPickerModal({game, isOpen, onOpenChange, setCoverUrl}:
<p className="text-center">No results found.</p> <p className="text-center">No results found.</p>
} }
{searchResults.length === 0 && isSearching && {searchResults.length === 0 && isSearching &&
<p className="text-center text-foreground/70">Searching...</p> <p className="text-center text-default-500">Searching...</p>
} }
<ScrollShadow <ScrollShadow
className="grid grid-cols-auto-fill gap-4 h-96 overflow-y-scroll justify-evenly"> className="grid grid-cols-auto-fill gap-4 h-96 overflow-y-scroll justify-evenly">
@@ -81,7 +81,7 @@ export function GameHeaderPickerModal({game, isOpen, onOpenChange, setHeaderUrl}
<p className="text-center">No results found.</p> <p className="text-center">No results found.</p>
} }
{searchResults.length === 0 && isSearching && {searchResults.length === 0 && isSearching &&
<p className="text-center text-foreground/70">Searching...</p> <p className="text-center text-default-500">Searching...</p>
} }
<ScrollShadow <ScrollShadow
className="flex flex-col items-center gap-4 h-96 overflow-y-scroll"> className="flex flex-col items-center gap-4 h-96 overflow-y-scroll">
@@ -21,7 +21,7 @@ import {useSnapshot} from "valtio/react";
import {pluginState} from "Frontend/state/PluginState"; import {pluginState} from "Frontend/state/PluginState";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto"; import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
interface EditGameMetadataModalProps { interface MatchGameModalProps {
path: string; path: string;
libraryId: number; libraryId: number;
replaceGameId?: number; replaceGameId?: number;
@@ -37,7 +37,7 @@ export default function MatchGameModal({
initialSearchTerm, initialSearchTerm,
isOpen, isOpen,
onOpenChange onOpenChange
}: EditGameMetadataModalProps) { }: MatchGameModalProps) {
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]); const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]);
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
@@ -0,0 +1,168 @@
import {
addToast,
Button,
Input,
Modal,
ModalBody,
ModalContent,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
Tooltip
} from "@heroui/react";
import React, {useEffect, useState} from "react";
import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react";
import {GameEndpoint, GameRequestEndpoint} from "Frontend/generated/endpoints";
import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto";
import PluginIcon from "../plugin/PluginIcon";
import {useSnapshot} from "valtio/react";
import {pluginState} from "Frontend/state/PluginState";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
import GameRequestCreationDto from "Frontend/generated/org/gameyfin/app/requests/dto/GameRequestCreationDto";
interface RequestGameModalProps {
isOpen: boolean;
onOpenChange: () => void;
}
export default function RequestGameModal({
isOpen,
onOpenChange
}: RequestGameModalProps) {
const [searchTerm, setSearchTerm] = useState("");
const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [isRequesting, setIsRequesting] = useState<string | null>(null);
const plugins = useSnapshot(pluginState).state;
useEffect(() => {
setSearchTerm("");
setSearchResults([]);
}, [isOpen]);
async function requestGame(game: GameSearchResultDto) {
const request: GameRequestCreationDto = {
title: game.title,
release: game.release
}
try {
await GameRequestEndpoint.create(request);
addToast({
title: "Request submitted",
description: `Your request for "${game.title}" has been submitted.`,
color: "success"
});
} catch (e) {
setIsSearching(false);
setIsRequesting(null);
}
}
async function search() {
setIsSearching(true);
const results = await GameEndpoint.getPotentialMatches(searchTerm);
setSearchResults(results);
setIsSearching(false);
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}
hideCloseButton
isDismissable={!isSearching && !isRequesting}
isKeyboardDismissDisabled={!isSearching && !isRequesting}
backdrop="opaque" size="5xl">
<ModalContent>
{(onClose) => (
<ModalBody className="my-4">
<div className="flex flex-col items-center">
<h2 className="text-xl font-semibold">Request a game</h2>
</div>
<div className="flex flex-row gap-2 mb-4">
<Input value={searchTerm}
onValueChange={setSearchTerm}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await search();
}
}}
/>
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
<MagnifyingGlass/>
</Button>
</div>
<div>
<Table removeWrapper isStriped isHeaderSticky
classNames={{
base: "h-80 overflow-y-auto",
}}
>
<TableHeader>
<TableColumn>Title & Release</TableColumn>
<TableColumn>Developer(s)</TableColumn>
<TableColumn>Publisher(s)</TableColumn>
{/* width={1} keeps the column as far to the right as possible*/}
<TableColumn>Sources</TableColumn>
<TableColumn width={1}> </TableColumn>
</TableHeader>
<TableBody emptyContent="Your search did not match any games." items={searchResults}>
{(item) => (
<TableRow key={item.id}>
<TableCell>
{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
</TableCell>
<TableCell>
<div className="flex flex-col">
{item.developers ? item.developers.map(
developer => <p>{developer}</p>
) : "unknown"}
</div>
</TableCell>
<TableCell>
<div className="flex flex-col">
{item.publishers ? item.publishers.map(
publisher => <p>{publisher}</p>
) : "unknown"}
</div>
</TableCell>
<TableCell>
<div className="flex flex-row gap-2">
{Object.values(item.originalIds).map(
originalId => <PluginIcon
plugin={plugins[originalId.pluginId] as PluginDto}/>
)}
</div>
</TableCell>
<TableCell>
<Tooltip content="Pick this result">
<Button isIconOnly size="sm"
isDisabled={isRequesting !== null}
isLoading={isRequesting === item.id}
onPress={async () => {
setIsRequesting(item.id);
await requestGame(item);
setIsRequesting(null);
onClose();
}}>
<ArrowRight/>
</Button>
</Tooltip>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</ModalBody>
)}
</ModalContent>
</Modal>
);
}
+12
View File
@@ -24,6 +24,8 @@ import RecentlyAddedView from "Frontend/views/RecentlyAddedView";
import LibraryView from "Frontend/views/LibraryView"; import LibraryView from "Frontend/views/LibraryView";
import {RouterConfigurationBuilder} from "@vaadin/hilla-file-router/runtime.js"; import {RouterConfigurationBuilder} from "@vaadin/hilla-file-router/runtime.js";
import ErrorView from "Frontend/views/ErrorView"; import ErrorView from "Frontend/views/ErrorView";
import GameRequestView from "Frontend/views/GameRequestView";
import {GameRequestManagement} from "Frontend/components/administration/GameRequestManagement";
export const {router, routes} = new RouterConfigurationBuilder() export const {router, routes} = new RouterConfigurationBuilder()
.withReactRoutes([ .withReactRoutes([
@@ -47,6 +49,11 @@ export const {router, routes} = new RouterConfigurationBuilder()
element: <RecentlyAddedView/>, element: <RecentlyAddedView/>,
handle: {title: 'Recently Added'} handle: {title: 'Recently Added'}
}, },
{
path: '/requests',
element: <GameRequestView/>,
handle: {title: 'Game requests'}
},
{ {
path: 'library/:libraryId', path: 'library/:libraryId',
element: <LibraryView/> element: <LibraryView/>
@@ -87,6 +94,11 @@ export const {router, routes} = new RouterConfigurationBuilder()
element: <LibraryManagementView/>, element: <LibraryManagementView/>,
handle: {title: 'Administration - Library'} handle: {title: 'Administration - Library'}
}, },
{
path: 'requests',
element: <GameRequestManagement/>,
handle: {title: 'Administration - Game Requests'}
},
{ {
path: 'users', path: 'users',
element: <UserManagement/>, element: <UserManagement/>,
@@ -0,0 +1,56 @@
import {Subscription} from "@vaadin/hilla-frontend";
import {proxy} from "valtio/index";
import {GameRequestEndpoint} from "Frontend/generated/endpoints";
import GameRequestEvent from "Frontend/generated/org/gameyfin/app/requests/dto/GameRequestEvent";
import GameRequestDto from "Frontend/generated/org/gameyfin/app/requests/dto/GameRequestDto";
type GameRequestState = {
subscription?: Subscription<GameRequestEvent[]>;
isLoaded: boolean;
state: Record<number, GameRequestDto>;
gameRequests: GameRequestDto[];
};
export const gameRequestState = proxy<GameRequestState>({
get isLoaded() {
return this.subscription != null;
},
state: {},
get gameRequests() {
return Object.values<GameRequestDto>(this.state);
}
});
/** Subscribe to and process state updates from backend **/
export async function initializeGameRequestState() {
if (gameRequestState.isLoaded) return;
// Fetch initial game request list
const initialEntries = await GameRequestEndpoint.getAll();
initialEntries.forEach((gameRequest: GameRequestDto) => {
gameRequestState.state[gameRequest.id] = gameRequest;
});
// Subscribe to real-time updates
gameRequestState.subscription = GameRequestEndpoint.subscribe().onNext((gameRequestEvents: GameRequestEvent[]) => {
gameRequestEvents.forEach((gameRequestEvent: GameRequestEvent) => {
switch (gameRequestEvent.type) {
case "created":
case "updated":
//@ts-ignore
gameRequestState.state[gameRequestEvent.gameRequest.id] = gameRequestEvent.gameRequest;
break;
case "deleted":
//@ts-ignore
delete gameRequestState.state[gameRequestEvent.gameRequestId];
break;
}
})
});
}
+76
View File
@@ -1,5 +1,6 @@
import {getCsrfToken} from "Frontend/util/auth"; import {getCsrfToken} from "Frontend/util/auth";
import moment from 'moment-timezone'; import moment from 'moment-timezone';
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
export function isAdmin(auth: any): boolean { export function isAdmin(auth: any): boolean {
return auth.state.user?.roles?.some((a: string) => a?.includes("ADMIN")); return auth.state.user?.roles?.some((a: string) => a?.includes("ADMIN"));
@@ -207,4 +208,79 @@ export function fileNameFromPath(path: string, includeExtension: boolean = true)
} }
const dotIndex = fileName.lastIndexOf('.'); const dotIndex = fileName.lastIndexOf('.');
return dotIndex < 0 ? fileName : fileName.substring(0, dotIndex); return dotIndex < 0 ? fileName : fileName.substring(0, dotIndex);
}
/**
* Calculate the completeness of a GameDto
* @param game
* @returns completeness percentage (0-100)
*/
export function metadataCompleteness(game: GameDto) {
// Total number of fields considered for completeness
// Includes all fields except "comment"
const totalFields = 21;
const filledFields = Object.values(game).filter(value => {
if (value === null || value === undefined) return false;
if (Array.isArray(value)) return value.length > 0;
if (typeof value === "string") return value.trim().length > 0;
return true;
}).length;
return Math.round((filledFields / totalFields) * 100);
}
/**
* Scale a number from one range to another
* @param value The number to scale
* @param originalRange The original range [min, max]
* @param targetRange The target range [min, max]
* @returns The scaled number
*/
function convertRange(value: number, originalRange: number[], targetRange: number[]): number {
if (originalRange[0] === targetRange[0] && originalRange[1] === targetRange[1]) return value;
return (value - originalRange[0]) * (targetRange[1] - targetRange[0]) / (originalRange[1] - originalRange[0]) + targetRange[0];
}
/**
* Calculate a compound rating for a GameDto based on its criticRating and userRating.
* If both ratings are present, a weighted average is calculated (40% critic, 60% user).
* If only one rating is present, that rating is returned.
* If neither rating is present, 0 is returned.
* @param game The GameDto object containing the ratings.
* @param scale The scale to convert the rating to (default is [0, 100]).
* @returns The compound rating.
*/
export function compoundRating(game: GameDto, scale = [0, 100]): number {
const weights = {
critic: 0.4,
user: 0.6
};
const originalRange = [0, 100];
const criticRating = game.criticRating ?? 0;
const userRating = game.userRating ?? 0;
if (criticRating === 0 && userRating === 0) return 0;
if (criticRating === 0) return convertRange(userRating, originalRange, scale);
if (userRating === 0) return convertRange(criticRating, originalRange, scale);
const avgRating = Math.round((criticRating * weights.critic + userRating * weights.user) * 10) / 10;
return convertRange(avgRating, originalRange, scale);
}
/**
* Convert a GameDto's ratings to a star rating out of 5.
* If both criticRating and userRating are present, their average is taken.
* If neither is present, "N/A" is returned.
* @param game The GameDto object containing the ratings.
* @returns A string representing the star rating out of 5, or "N/A" if no ratings are available.
*/
export function starRatingAsString(game: GameDto) {
const starRange = [1, 5];
const rating = compoundRating(game, starRange);
if (rating === 0) return "N/A";
return rating.toFixed(1);
} }
@@ -1,4 +1,4 @@
import {Envelope, GameController, LockKey, Log, Plug, Users, Wrench} from "@phosphor-icons/react"; import {Disc, Envelope, GameController, LockKey, Log, Plug, Users, Wrench} from "@phosphor-icons/react";
import withSideMenu, {MenuItem} from "Frontend/components/general/withSideMenu"; import withSideMenu, {MenuItem} from "Frontend/components/general/withSideMenu";
const menuItems: MenuItem[] = [ const menuItems: MenuItem[] = [
@@ -7,6 +7,11 @@ const menuItems: MenuItem[] = [
url: "libraries", url: "libraries",
icon: <GameController/> icon: <GameController/>
}, },
{
title: "Game Requests",
url: "requests",
icon: <Disc/>
},
{ {
title: "Users", title: "Users",
url: "users", url: "users",
@@ -0,0 +1,307 @@
import {
Button,
Chip,
Input,
Pagination,
Select,
SelectItem,
SortDescriptor,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
Tooltip,
useDisclosure
} from "@heroui/react";
import RequestGameModal from "Frontend/components/general/modals/RequestGameModal";
import {ArrowUp, Check, Info, PlusCircle, Trash, X} from "@phosphor-icons/react";
import React, {useEffect, useMemo, useState} from "react";
import {useAuth} from "Frontend/util/auth";
import {ConfigEndpoint, GameRequestEndpoint} from "Frontend/generated/endpoints";
import {gameRequestState} from "Frontend/state/GameRequestState";
import {useSnapshot} from "valtio/react";
import GameRequestDto from "Frontend/generated/org/gameyfin/app/requests/dto/GameRequestDto";
import GameRequestStatus from "Frontend/generated/org/gameyfin/app/requests/status/GameRequestStatus";
import {isAdmin} from "Frontend/util/utils";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
export default function GameRequestView() {
const rowsPerPage = 25;
const auth = useAuth();
const requestGameModal = useDisclosure();
const gameRequests = useSnapshot(gameRequestState).gameRequests;
const [areGameRequestsEnabled, setAreGameRequestsEnabled] = useState(false);
const [areGuestsAllowedToRequestGames, setAreGuestsAllowedToRequestGames] = useState(false);
useEffect(() => {
ConfigEndpoint.areGameRequestsEnabled().then(setAreGameRequestsEnabled);
ConfigEndpoint.areGuestsAllowedToRequestGames().then(setAreGuestsAllowedToRequestGames);
}, []);
const [searchTerm, setSearchTerm] = useState("");
const [filters, setFilters] = useState<"all" | GameRequestStatus[]>([GameRequestStatus.PENDING, GameRequestStatus.APPROVED, GameRequestStatus.REJECTED]);
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({column: "votes", direction: "descending"});
const [page, setPage] = useState(1);
const pages = useMemo(() => {
return Math.ceil(getFilteredRequests().length / rowsPerPage);
}, [gameRequests, filters]);
const filteredItems = useMemo(() => {
return getFilteredRequests();
}, [gameRequests, filters, searchTerm]);
const sortedItems = useMemo(() => {
return (filteredItems as GameRequestDto[]).slice().sort((a, b) => {
let cmp: number;
switch (sortDescriptor.column) {
case "title":
cmp = a.title.localeCompare(b.title);
break;
case "votes":
cmp = a.voters.length - b.voters.length;
if (cmp === 0) {
// If votes are equal, sort by creation date (newest first)
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
}
break;
case "status":
const statusOrder = {
[GameRequestStatus.PENDING]: 1,
[GameRequestStatus.APPROVED]: 2,
[GameRequestStatus.REJECTED]: 3,
[GameRequestStatus.FULFILLED]: 4
};
cmp = (statusOrder[a.status] || 99) - (statusOrder[b.status] || 99);
break;
case "createdAt":
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
break;
case "updatedAt":
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
break;
default:
return 0; // No sorting if the column is not recognized
}
if (sortDescriptor.direction === "descending") {
cmp *= -1; // Reverse the comparison if sorting in descending order
}
return cmp;
});
}, [filteredItems, sortDescriptor]);
const pagedItems = useMemo(() => {
const start = (page - 1) * rowsPerPage;
const end = start + rowsPerPage;
return sortedItems.slice(start, end);
}, [page, sortedItems]);
function getFilteredRequests() {
let filteredRequests = (gameRequests as GameRequestDto[]).filter((gameRequest) => {
return gameRequest.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
(gameRequest.requester && gameRequest.requester.username.toLowerCase().includes(searchTerm.toLowerCase()));
});
filteredRequests = filteredRequests.filter((gameRequest) => {
return filters.includes(gameRequest.status);
});
return filteredRequests;
}
async function toggleVote(gameRequestId: number) {
await GameRequestEndpoint.toggleVote(gameRequestId);
}
async function toggleApprove(gameRequest: GameRequestDto) {
if (gameRequest.status == GameRequestStatus.FULFILLED) return;
const newStatus = gameRequest.status === GameRequestStatus.APPROVED ? GameRequestStatus.PENDING : GameRequestStatus.APPROVED;
await GameRequestEndpoint.changeStatus(gameRequest.id, newStatus);
}
async function toggleReject(gameRequest: GameRequestDto) {
if (gameRequest.status == GameRequestStatus.FULFILLED) return;
const newStatus = gameRequest.status === GameRequestStatus.REJECTED ? GameRequestStatus.PENDING : GameRequestStatus.REJECTED;
await GameRequestEndpoint.changeStatus(gameRequest.id, newStatus);
}
async function deleteRequest(gameRequestId: number) {
await GameRequestEndpoint.delete(gameRequestId);
}
function hasUserVotedForRequest(gameRequest: GameRequestDto): boolean {
if (!auth.state.user) return false;
return gameRequest.voters.map(v => v.id).includes(auth.state.user.id);
}
function statusToBadge(status: GameRequestStatus) {
switch (status) {
case GameRequestStatus.APPROVED:
return <Chip size="sm" radius="sm"
className="text-xs bg-success-300 text-success-foreground">Approved</Chip>;
case GameRequestStatus.FULFILLED:
return <Chip size="sm" radius="sm" className="text-xs bg-success">Fulfilled</Chip>;
case GameRequestStatus.REJECTED:
return <Chip size="sm" radius="sm"
className="text-xs bg-danger-300 text-danger-foreground">Rejected</Chip>;
case GameRequestStatus.PENDING:
default:
return <Chip size="sm" radius="sm" className="text-xs">Pending</Chip>;
}
}
return (<>
<div className="flex flex-row justify-between mb-8">
<h1 className="text-2xl font-bold">Game Requests</h1>
<div className="flex flex-row items-center gap-4">
{!areGameRequestsEnabled &&
<SmallInfoField icon={Info}
message="Request submission is disabled"
className="text-default-500"/>
}
<Button className="w-fit"
color="primary"
startContent={<PlusCircle weight="fill"/>}
onPress={requestGameModal.onOpen}
isDisabled={!areGameRequestsEnabled || (!auth.state.user && !areGuestsAllowedToRequestGames)}>
Request a Game
</Button>
</div>
</div>
<div className="flex flex-row gap-2 justify-between mb-4">
<Input
className="w-96"
isClearable
placeholder="Search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onClear={() => setSearchTerm("")}
/>
<Select
selectedKeys={filters}
onSelectionChange={keys => setFilters(Array.from(keys) as any)}
selectionMode="multiple"
className="w-64"
>
<SelectItem key={GameRequestStatus.PENDING}>Pending</SelectItem>
<SelectItem key={GameRequestStatus.APPROVED}>Approved</SelectItem>
<SelectItem key={GameRequestStatus.REJECTED}>Rejected</SelectItem>
<SelectItem key={GameRequestStatus.FULFILLED}>Fulfilled</SelectItem>
</Select>
</div>
<Table removeWrapper isStriped
sortDescriptor={sortDescriptor}
onSortChange={setSortDescriptor}
bottomContent={
<div className="flex w-full justify-center sticky">
{pagedItems.length > 0 &&
<Pagination
isCompact
showControls
showShadow
color="primary"
page={page}
total={pages}
onChange={(page) => setPage(page)}
/>}
</div>
}
>
<TableHeader>
<TableColumn key="title" allowsSorting>Title & Release</TableColumn>
<TableColumn>Submitted by</TableColumn>
<TableColumn key="createdAt" allowsSorting>Submitted</TableColumn>
<TableColumn key="updatedAt" allowsSorting>Updated</TableColumn>
<TableColumn key="status" allowsSorting>Status</TableColumn>
{/* width={1} keeps the column as far to the right as possible*/}
<TableColumn key="votes" allowsSorting width={1}>Votes</TableColumn>
</TableHeader>
<TableBody emptyContent="Your search did not match any requests." items={pagedItems}>
{(item) => (
<TableRow key={item.id}>
<TableCell>
{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
</TableCell>
<TableCell>
<p className="text-default-500">
{item.requester ?
item.requester.username :
"Guest"
}
</p>
</TableCell>
<TableCell>
{new Date(item.createdAt).toLocaleDateString()}
</TableCell>
<TableCell>
{new Date(item.updatedAt).toLocaleDateString()}
</TableCell>
<TableCell className="min-w-24">
{statusToBadge(item.status)}
</TableCell>
<TableCell>
<div className="flex flex-row gap-2">
<Tooltip
content={auth.state.user ? (item.status === GameRequestStatus.FULFILLED ? "You cannot vote on closed requests" : "Vote for this request") : "You must be logged in to vote"}
placement="left">
<div>
<Button size="sm"
variant={hasUserVotedForRequest(item as GameRequestDto) ? "solid" : "bordered"}
color={hasUserVotedForRequest(item as GameRequestDto) ? "primary" : "default"}
isDisabled={!auth.state.user || item.status === GameRequestStatus.FULFILLED}
startContent={<ArrowUp/>}
onPress={async () => await toggleVote(item.id)}>
{item.voters.length}
</Button>
</div>
</Tooltip>
{isAdmin(auth) && <div className="flex flex-row gap-2">
<Tooltip content="Approve this request">
<Button size="sm" isIconOnly
variant={item.status === GameRequestStatus.APPROVED ? "solid" : "bordered"}
color={item.status === GameRequestStatus.APPROVED ? "primary" : "default"}
isDisabled={item.status === GameRequestStatus.FULFILLED}
onPress={async () => await toggleApprove(item as GameRequestDto)}>
<Check/>
</Button>
</Tooltip>
<Tooltip content="Reject this request">
<Button size="sm" isIconOnly
variant={item.status === GameRequestStatus.REJECTED ? "solid" : "bordered"}
color={item.status === GameRequestStatus.REJECTED ? "primary" : "default"}
isDisabled={item.status === GameRequestStatus.FULFILLED}
onPress={async () => await toggleReject(item as GameRequestDto)}>
<X/>
</Button>
</Tooltip>
</div>}
{(isAdmin(auth) || (auth.state.user && item.requester && auth.state.user.id === item.requester.id)) &&
<Tooltip content="Delete this request">
<Button size="sm" isIconOnly
color="danger"
onPress={async () => await deleteRequest(item.id)}>
<Trash/>
</Button>
</Tooltip>
}
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<RequestGameModal isOpen={requestGameModal.isOpen}
onOpenChange={requestGameModal.onOpenChange}/>
</>)
}
+14 -5
View File
@@ -5,11 +5,11 @@ import {GameCover} from "Frontend/components/general/covers/GameCover";
import ComboButton, {ComboButtonOption} from "Frontend/components/general/input/ComboButton"; import ComboButton, {ComboButtonOption} from "Frontend/components/general/input/ComboButton";
import ImageCarousel from "Frontend/components/general/covers/ImageCarousel"; import ImageCarousel from "Frontend/components/general/covers/ImageCarousel";
import {Accordion, AccordionItem, addToast, Button, Chip, Link, Tooltip, useDisclosure} from "@heroui/react"; import {Accordion, AccordionItem, addToast, Button, Chip, Link, Tooltip, useDisclosure} from "@heroui/react";
import {humanFileSize, isAdmin, toTitleCase} from "Frontend/util/utils"; import {humanFileSize, isAdmin, starRatingAsString, toTitleCase} from "Frontend/util/utils";
import {DownloadEndpoint} from "Frontend/endpoints/endpoints"; import {DownloadEndpoint} from "Frontend/endpoints/endpoints";
import {gameState} from "Frontend/state/GameState"; import {gameState} from "Frontend/state/GameState";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
import {CheckCircle, Info, MagnifyingGlass, Pencil, Trash, TriangleDashed} from "@phosphor-icons/react"; import {CheckCircle, Info, MagnifyingGlass, Pencil, Star, Trash, TriangleDashed} from "@phosphor-icons/react";
import {useAuth} from "Frontend/util/auth"; import {useAuth} from "Frontend/util/auth";
import MatchGameModal from "Frontend/components/general/modals/MatchGameModal"; import MatchGameModal from "Frontend/components/general/modals/MatchGameModal";
import EditGameMetadataModal from "Frontend/components/general/modals/EditGameMetadataModal"; import EditGameMetadataModal from "Frontend/components/general/modals/EditGameMetadataModal";
@@ -102,14 +102,23 @@ export default function GameView() {
<GameCover game={game} size={320} radius="none"/> <GameCover game={game} size={320} radius="none"/>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<p className="font-semibold text-3xl">{game.title}</p> <div className="flex flex-row gap-4 items-end">
<p className="font-semibold text-3xl">
{game.title}
</p>
<div className="flex flex-row gap-1 mb-0.5 text-default-500">
<Star weight="fill"/>
{starRatingAsString(game)}
</div>
</div>
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
<p className="text-default-500"> <p className="text-default-500">
{game.release !== undefined ? new Date(game.release).getFullYear() : {game.release !== undefined ? new Date(game.release).getFullYear() :
<p className="text-default-500">no data</p>} <p className="text-default-500">no data</p>}
</p> </p>
<Tooltip content={`Last update: ${new Date(game.updatedAt).toLocaleString()}`} <Tooltip
placement="right"> content={`Last update: ${new Date(game.updatedAt).toLocaleString()}`}
placement="right">
<Info/> <Info/>
</Tooltip> </Tooltip>
</div> </div>
+17 -3
View File
@@ -5,7 +5,7 @@ import GameyfinLogo from "Frontend/components/theming/GameyfinLogo";
import * as PackageJson from "../../../../package.json"; import * as PackageJson from "../../../../package.json";
import {Outlet, useLocation, useNavigate} from "react-router"; import {Outlet, useLocation, useNavigate} from "react-router";
import {useAuth} from "Frontend/util/auth"; import {useAuth} from "Frontend/util/auth";
import {ArrowLeft, DiceSix, Heart, House, ListMagnifyingGlass, SignIn} from "@phosphor-icons/react"; import {ArrowLeft, DiceSix, Disc, Heart, House, ListMagnifyingGlass, SignIn} from "@phosphor-icons/react";
import Confetti, {ConfettiProps} from "react-confetti-boom"; import Confetti, {ConfettiProps} from "react-confetti-boom";
import {useTheme} from "next-themes"; import {useTheme} from "next-themes";
import {useUserPreferenceService} from "Frontend/util/user-preference-service"; import {useUserPreferenceService} from "Frontend/util/user-preference-service";
@@ -93,10 +93,24 @@ export default function MainLayout() {
</Button> </Button>
</Tooltip> </Tooltip>
</NavbarContent>} </NavbarContent>}
<NavbarContent justify="end"> <NavbarContent justify="end" className="items-center">
<NavbarItem>
<Tooltip content="Request a game" placement="bottom">
<Button color="primary"
isDisabled={window.location.pathname.startsWith("/requests")}
onPress={() => navigate("/requests")}
startContent={<Disc weight="fill"/>}>
Requests
</Button>
</Tooltip>
</NavbarItem>
{isAdmin(auth) && {isAdmin(auth) &&
<NavbarItem> <NavbarItem>
<ScanProgressPopover/> <Tooltip content="View library scan results" placement="bottom">
<div>
<ScanProgressPopover/>
</div>
</Tooltip>
</NavbarItem> </NavbarItem>
} }
{auth.state.user && {auth.state.user &&
+176 -34
View File
@@ -1,15 +1,15 @@
import {Input, Select, SelectItem} from "@heroui/react"; import {Button, Input, Select, SelectedItems, SelectItem, Tooltip} from "@heroui/react";
import {MagnifyingGlass} from "@phosphor-icons/react"; import {FunnelSimple, FunnelSimpleX, MagnifyingGlass, SortAscending, Star} from "@phosphor-icons/react";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState"; import {gameState} from "Frontend/state/GameState";
import {libraryState} from "Frontend/state/LibraryState"; import {libraryState} from "Frontend/state/LibraryState";
import {useSearchParams} from "react-router"; import {useSearchParams} from "react-router";
import {useEffect, useMemo, useState} from "react"; import React, {useEffect, useMemo, useState} from "react";
import {Fzf} from "fzf"; import {Fzf} from "fzf";
import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto"; import GameDto from "Frontend/generated/org/gameyfin/app/games/dto/GameDto";
import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto"; import LibraryDto from "Frontend/generated/org/gameyfin/app/libraries/dto/LibraryDto";
import CoverGrid from "Frontend/components/general/covers/CoverGrid"; import CoverGrid from "Frontend/components/general/covers/CoverGrid";
import {toTitleCase} from "Frontend/util/utils"; import {compoundRating, toTitleCase} from "Frontend/util/utils";
export default function SearchView() { export default function SearchView() {
const games = useSnapshot(gameState).sortedAlphabetically as GameDto[]; const games = useSnapshot(gameState).sortedAlphabetically as GameDto[];
@@ -24,6 +24,9 @@ export default function SearchView() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [initialLoadComplete, setInitialLoadComplete] = useState(false); const [initialLoadComplete, setInitialLoadComplete] = useState(false);
const [showFilters, setShowFilters] = useState(false);
const [sortBy, setSortBy] = useState("title_asc");
// State to track selected filter values // State to track selected filter values
const [searchTerm, setSearchTerm] = useState<string>(""); const [searchTerm, setSearchTerm] = useState<string>("");
const [selectedLibraries, setSelectedLibraries] = useState<Set<string>>(new Set()); const [selectedLibraries, setSelectedLibraries] = useState<Set<string>>(new Set());
@@ -33,6 +36,7 @@ export default function SearchView() {
const [selectedFeatures, setSelectedFeatures] = useState<Set<string>>(new Set()); const [selectedFeatures, setSelectedFeatures] = useState<Set<string>>(new Set());
const [selectedPerspectives, setSelectedPerspectives] = useState<Set<string>>(new Set()); const [selectedPerspectives, setSelectedPerspectives] = useState<Set<string>>(new Set());
const [selectedKeywords, setSelectedKeywords] = useState<Set<string>>(new Set()); const [selectedKeywords, setSelectedKeywords] = useState<Set<string>>(new Set());
const [minRating, setMinRating] = useState<number>(1); // Minimum rating filter
// Load initial filter values from URL parameters on component mount // Load initial filter values from URL parameters on component mount
useEffect(() => { useEffect(() => {
@@ -45,6 +49,9 @@ export default function SearchView() {
const features = searchParams.getAll("feature"); const features = searchParams.getAll("feature");
const perspectives = searchParams.getAll("perspective"); const perspectives = searchParams.getAll("perspective");
const keywords = searchParams.getAll("keyword"); const keywords = searchParams.getAll("keyword");
const sort = searchParams.get("sort") || "title_asc";
const minRatingParam = parseInt(searchParams.get("minRating") || "1", 10);
const filtersParam = searchParams.get("filters");
setSearchTerm(term); setSearchTerm(term);
setSelectedLibraries(new Set(libs)); setSelectedLibraries(new Set(libs));
@@ -54,11 +61,14 @@ export default function SearchView() {
setSelectedFeatures(new Set(features)); setSelectedFeatures(new Set(features));
setSelectedPerspectives(new Set(perspectives)); setSelectedPerspectives(new Set(perspectives));
setSelectedKeywords(new Set(keywords)); setSelectedKeywords(new Set(keywords));
setSortBy(sort);
setMinRating(isNaN(minRatingParam) ? 1 : minRatingParam);
setShowFilters(filtersParam === "1");
setInitialLoadComplete(true); setInitialLoadComplete(true);
}, []); }, []);
// Update search parameters whenever the filters change // Update search parameters whenever the filters or sort change
useEffect(() => { useEffect(() => {
if (!initialLoadComplete) return; if (!initialLoadComplete) return;
@@ -75,52 +85,93 @@ export default function SearchView() {
newParams.append("lib", lib.toString()); newParams.append("lib", lib.toString());
}); });
} }
if (selectedDevelopers.size > 0) { if (selectedDevelopers.size > 0) {
selectedDevelopers.forEach(dev => { selectedDevelopers.forEach(dev => {
newParams.append("dev", dev); newParams.append("dev", dev);
}); });
} }
if (selectedGenres.size > 0) { if (selectedGenres.size > 0) {
selectedGenres.forEach(genre => { selectedGenres.forEach(genre => {
newParams.append("genre", genre); newParams.append("genre", genre);
}); });
} }
if (selectedThemes.size > 0) { if (selectedThemes.size > 0) {
selectedThemes.forEach(theme => { selectedThemes.forEach(theme => {
newParams.append("theme", theme); newParams.append("theme", theme);
}); });
} }
if (selectedFeatures.size > 0) { if (selectedFeatures.size > 0) {
selectedFeatures.forEach(feature => { selectedFeatures.forEach(feature => {
newParams.append("feature", feature); newParams.append("feature", feature);
}); });
} }
if (selectedPerspectives.size > 0) { if (selectedPerspectives.size > 0) {
selectedPerspectives.forEach(perspective => { selectedPerspectives.forEach(perspective => {
newParams.append("perspective", perspective); newParams.append("perspective", perspective);
}); });
} }
if (selectedKeywords.size > 0) { if (selectedKeywords.size > 0) {
selectedKeywords.forEach(keyword => { selectedKeywords.forEach(keyword => {
newParams.append("keyword", keyword); newParams.append("keyword", keyword);
}); });
} }
// Add minRating param if not default
if (minRating > 1) {
newParams.set("minRating", minRating.toString());
}
// Add sort param
if (sortBy && sortBy !== "title_asc") {
newParams.set("sort", sortBy);
}
// Add showFilters param
if (showFilters) {
newParams.set("filters", "1");
}
setSearchParams(newParams, {replace: true}); setSearchParams(newParams, {replace: true});
}, [searchTerm, selectedLibraries, selectedDevelopers, selectedGenres, }, [searchTerm, selectedLibraries, selectedDevelopers, selectedGenres,
selectedThemes, selectedFeatures, selectedPerspectives, selectedKeywords]); selectedThemes, selectedFeatures, selectedPerspectives, selectedKeywords, sortBy, minRating, showFilters]);
const filteredGames = useMemo(() => filterGames(), [ // Sorting function (refactored to use sortKey and sortDirection)
function sortGames(games: GameDto[]): GameDto[] {
if (!sortBy) return games;
const [sortKey, sortDirection] = sortBy.split("_");
return games.slice().sort((a, b) => {
let cmp: number;
switch (sortKey) {
case "title":
cmp = a.title.localeCompare(b.title);
break;
case "release":
cmp = (a.release || "").localeCompare(b.release || "");
break;
case "rating":
cmp = compoundRating(a) - compoundRating(b);
break;
case "added":
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
break;
case "updated":
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
break;
default:
cmp = 0;
}
if (sortDirection === "desc") {
cmp *= -1; // Reverse the comparison if sorting in descending order
}
return cmp;
});
}
const filteredAndSortedGames = useMemo(() => sortGames(filterGames()), [
games, searchTerm, games, searchTerm,
selectedLibraries, selectedDevelopers, selectedLibraries, selectedDevelopers,
selectedGenres, selectedThemes, selectedGenres, selectedThemes,
selectedFeatures, selectedPerspectives, selectedKeywords selectedFeatures, selectedPerspectives, selectedKeywords, sortBy, minRating
]); ]);
function filterGames(): GameDto[] { function filterGames(): GameDto[] {
@@ -181,27 +232,99 @@ export default function SearchView() {
); );
} }
// Apply minimum rating filter
if (minRating > 1) {
filtered = filtered.filter(game => {
const starRating = compoundRating(game, [1, 5]);
if (minRating === 5) {
return starRating > 4.5;
}
return starRating >= minRating;
});
}
return filtered; return filtered;
} }
function stars(filled: number, total: number = 5) {
const stars = [];
for (let i = 0; i < total; i++) {
stars.push(
<Star key={i} weight={i < filled ? "fill" : "regular"} className="inline-block"/>
);
}
return <div className="flex flex-row">
{stars}
</div>;
}
return <div className="flex flex-col gap-4 items-center w-full"> return <div className="flex flex-col gap-4 items-center w-full">
<Input <div className="flex w-full justify-between px-12 gap-4 flex-col lg:flex-row">
classNames={{ <Input
base: "w-1/3", classNames={{
mainWrapper: "h-full", base: "w-full lg:w-96 flex-shrink-0",
inputWrapper: mainWrapper: "h-full",
"h-full font-normal text-default-500 bg-default-400/20 dark:bg-default-500/20", inputWrapper:
}} "h-full font-normal text-default-500 bg-default-400/20 dark:bg-default-500/20",
placeholder="Type to search..." }}
startContent={<MagnifyingGlass/>} placeholder="Type to search..."
type="search" startContent={<MagnifyingGlass/>}
value={searchTerm} type="search"
isClearable value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)} isClearable
onClear={() => setSearchTerm("")} onChange={(event) => setSearchTerm(event.target.value)}
/> onClear={() => setSearchTerm("")}
<div />
className="w-full justify-center" <div className="flex flex-row gap-2">
<Select
startContent={<SortAscending/>}
selectedKeys={[sortBy]}
disallowEmptySelection
selectionMode="single"
onSelectionChange={keys => setSortBy(Array.from(keys)[0] as any)}
className="w-full lg:w-64"
>
<SelectItem key="title_asc">Title (A-Z)</SelectItem>
<SelectItem key="title_desc">Title (Z-A)</SelectItem>
<SelectItem key="release_desc">Release Date (Newest)</SelectItem>
<SelectItem key="release_asc">Release Date (Oldest)</SelectItem>
<SelectItem key="rating_desc">Rating (Highest)</SelectItem>
<SelectItem key="rating_asc">Rating (Lowest)</SelectItem>
<SelectItem key="added_desc">Date Added (Newest)</SelectItem>
<SelectItem key="added_asc">Date Added (Oldest)</SelectItem>
<SelectItem key="updated_desc">Last Updated (Newest)</SelectItem>
<SelectItem key="updated_asc">Last Updated (Oldest)</SelectItem>
</Select>
<Tooltip content={showFilters ? "Hide Filters" : "Show Filters"}>
<Button isIconOnly
variant={showFilters ? "solid" : "bordered"}
color={showFilters ? "primary" : "default"}
onPress={() => setShowFilters(!showFilters)}
aria-label="Toggle Filters"
>
<FunnelSimple/>
</Button>
</Tooltip>
<Tooltip content="Clear All Filters">
<Button isIconOnly
onPress={() => {
setSelectedLibraries(new Set());
setSelectedDevelopers(new Set());
setSelectedGenres(new Set());
setSelectedThemes(new Set());
setSelectedFeatures(new Set());
setSelectedPerspectives(new Set());
setSelectedKeywords(new Set());
setMinRating(1);
}}
aria-label="Clear All Filters"
>
<FunnelSimpleX/>
</Button>
</Tooltip>
</div>
</div>
{showFilters && <div
className="w-full justify-center px-12"
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))", gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))",
@@ -222,6 +345,24 @@ export default function SearchView() {
<SelectItem key={library.id}>{library.name}</SelectItem> <SelectItem key={library.id}>{library.name}</SelectItem>
))} ))}
</Select> </Select>
<Select
size="sm"
selectionMode="single"
label="Minimum Rating"
placeholder="Minimum rating"
disallowEmptySelection
selectedKeys={[minRating.toString()]}
onSelectionChange={keys => setMinRating(parseInt(Array.from(keys)[0] as string, 10))}
renderValue={(items: SelectedItems<any>) => {
return items.map((item) => stars(parseInt(item.key as string)));
}}
>
<SelectItem key="1">{stars(1)}</SelectItem>
<SelectItem key="2">{stars(2)}</SelectItem>
<SelectItem key="3">{stars(3)}</SelectItem>
<SelectItem key="4">{stars(4)}</SelectItem>
<SelectItem key="5">{stars(5)}</SelectItem>
</Select>
<Select <Select
size="sm" size="sm"
selectionMode="multiple" selectionMode="multiple"
@@ -301,9 +442,10 @@ export default function SearchView() {
))} ))}
</Select> </Select>
</div> </div>
<div className="mt-4 w-full px-4 select-none"> }
<CoverGrid games={filteredGames}/> <div className="mt-4 w-full select-none">
{filteredGames.length === 0 && ( <CoverGrid games={filteredAndSortedGames}/>
{filteredAndSortedGames.length === 0 && (
<div className="text-center mt-8 text-default-500"> <div className="text-center mt-8 text-default-500">
No games found matching your filters No games found matching your filters
</div> </div>
@@ -43,16 +43,12 @@ class ConfigEndpoint(
} }
/** Specific read-only endpoints for all users **/ /** Specific read-only endpoints for all users **/
@DynamicPublicAccess
@AnonymousAllowed
fun areGameRequestsEnabled(): Boolean = configService.get(ConfigProperties.Requests.Games.Enabled) == true
@DynamicPublicAccess @DynamicPublicAccess
@AnonymousAllowed @AnonymousAllowed
fun isSsoEnabled(): Boolean = configService.get(ConfigProperties.SSO.OIDC.Enabled) == true fun areGuestsAllowedToRequestGames(): Boolean =
configService.get(ConfigProperties.Requests.Games.AllowGuestsToRequestGames) == true
@DynamicPublicAccess
@AnonymousAllowed
fun getSsoLogoutUrl(): String? = configService.get(ConfigProperties.SSO.OIDC.LogoutUrl)
@DynamicPublicAccess
@AnonymousAllowed
fun isPublicAccessEnabled(): Boolean = configService.get(ConfigProperties.Libraries.AllowPublicAccess) == true
} }
@@ -103,6 +103,32 @@ sealed class ConfigProperties<T : Serializable>(
} }
} }
/** Requests */
sealed class Requests {
sealed class Games {
data object Enabled : ConfigProperties<Boolean>(
Boolean::class,
"requests.games.enabled",
"Enable submission of game requests",
true
)
data object AllowGuestsToRequestGames : ConfigProperties<Boolean>(
Boolean::class,
"requests.games.allow-guests-to-request-games",
"Allow guests (not logged in) to create game requests",
false
)
data object MaxOpenRequestsPerUser : ConfigProperties<Int>(
Int::class,
"requests.games.max-open-requests-per-user",
"Maximum number of pending requests per user. Set to 0 for unlimited.",
10
)
}
}
/** User management */ /** User management */
sealed class Users { sealed class Users {
sealed class SignUps { sealed class SignUps {
@@ -4,6 +4,8 @@ import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonValue import com.fasterxml.jackson.annotation.JsonValue
import org.gameyfin.app.users.RoleService import org.gameyfin.app.users.RoleService
import java.lang.Enum import java.lang.Enum
import kotlin.Int
import kotlin.String
enum class Role(val roleName: String, val powerLevel: Int) { enum class Role(val roleName: String, val powerLevel: Int) {
@@ -21,12 +23,12 @@ enum class Role(val roleName: String, val powerLevel: Int) {
@JsonCreator @JsonCreator
@JvmStatic @JvmStatic
fun fromValue(value: String): Role? { fun fromValue(value: String): Role? {
val enumString = value.removePrefix(RoleService.Companion.INTERNAL_ROLE_PREFIX) val enumString = value.removePrefix(RoleService.INTERNAL_ROLE_PREFIX)
return entries.find { it.roleName == enumString } return entries.find { it.roleName == enumString }
} }
fun safeValueOf(type: String): Role? { fun safeValueOf(type: String): Role? {
val enumString = type.removePrefix(RoleService.Companion.INTERNAL_ROLE_PREFIX) val enumString = type.removePrefix(RoleService.INTERNAL_ROLE_PREFIX)
return Enum.valueOf(Role::class.java, enumString) return Enum.valueOf(Role::class.java, enumString)
} }
} }
@@ -34,9 +36,9 @@ enum class Role(val roleName: String, val powerLevel: Int) {
// necessary for the ability to use the Roles class in the @RolesAllowed annotation // necessary for the ability to use the Roles class in the @RolesAllowed annotation
class Names { class Names {
companion object { companion object {
const val SUPERADMIN = "${RoleService.Companion.INTERNAL_ROLE_PREFIX}SUPERADMIN" const val SUPERADMIN = "${RoleService.INTERNAL_ROLE_PREFIX}SUPERADMIN"
const val ADMIN = "${RoleService.Companion.INTERNAL_ROLE_PREFIX}ADMIN" const val ADMIN = "${RoleService.INTERNAL_ROLE_PREFIX}ADMIN"
const val USER = "${RoleService.Companion.INTERNAL_ROLE_PREFIX}USER" const val USER = "${RoleService.INTERNAL_ROLE_PREFIX}USER"
} }
} }
} }
@@ -0,0 +1,18 @@
package org.gameyfin.app.core.config
import org.gameyfin.app.core.interceptors.EntityUpdateInterceptor
import org.hibernate.cfg.AvailableSettings
import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class JpaConfiguration {
@Bean
fun hibernatePropertiesCustomizer(entityUpdateInterceptor: EntityUpdateInterceptor): HibernatePropertiesCustomizer {
return HibernatePropertiesCustomizer { hibernateProperties ->
hibernateProperties[AvailableSettings.INTERCEPTOR] = entityUpdateInterceptor
}
}
}
@@ -1,5 +1,6 @@
package org.gameyfin.app.core.events package org.gameyfin.app.core.events
import org.gameyfin.app.games.entities.Game
import org.gameyfin.app.shared.token.Token import org.gameyfin.app.shared.token.Token
import org.gameyfin.app.shared.token.TokenType import org.gameyfin.app.shared.token.TokenType
import org.gameyfin.app.users.entities.User import org.gameyfin.app.users.entities.User
@@ -21,6 +22,12 @@ class RegistrationAttemptWithExistingEmailEvent(source: Any, val existingUser: U
class PasswordResetRequestEvent(source: Any, val token: Token<TokenType.PasswordReset>, val baseUrl: String) : class PasswordResetRequestEvent(source: Any, val token: Token<TokenType.PasswordReset>, val baseUrl: String) :
ApplicationEvent(source) ApplicationEvent(source)
class AccountDeletedEvent(source: Any, val user: User, val baseUrl: String) : ApplicationEvent(source) class LibraryScanScheduleUpdatedEvent(source: Any) : ApplicationEvent(source)
class UserDeletedEvent(source: Any, val user: User, val baseUrl: String) : ApplicationEvent(source)
class UserUpdatedEvent(source: Any, val previousState: User, val currentState: User) : ApplicationEvent(source)
class GameCreatedEvent(source: Any, val game: Game) : ApplicationEvent(source)
class GameUpdatedEvent(source: Any, val previousState: Game, val currentState: Game) : ApplicationEvent(source)
class GameDeletedEvent(source: Any, val game: Game) : ApplicationEvent(source)
class LibraryScanScheduleUpdatedEvent(source: Any) : ApplicationEvent(source)
@@ -1,10 +1,10 @@
package org.gameyfin.app.core.filesystem package org.gameyfin.app.core.filesystem
import org.gameyfin.app.config.ConfigService
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.apache.commons.io.FilenameUtils import org.apache.commons.io.FilenameUtils
import org.gameyfin.app.config.ConfigProperties import org.gameyfin.app.config.ConfigProperties
import org.gameyfin.app.libraries.Library import org.gameyfin.app.config.ConfigService
import org.gameyfin.app.libraries.entities.Library
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.io.File import java.io.File
import java.nio.file.FileSystems import java.nio.file.FileSystems
@@ -0,0 +1,97 @@
package org.gameyfin.app.core.interceptors
import org.gameyfin.app.core.events.GameUpdatedEvent
import org.gameyfin.app.core.events.UserUpdatedEvent
import org.gameyfin.app.games.entities.Game
import org.gameyfin.app.games.entities.Image
import org.gameyfin.app.users.entities.User
import org.gameyfin.app.util.EventPublisherHolder
import org.hibernate.Interceptor
import org.hibernate.type.Type
import org.springframework.stereotype.Component
@Component
class EntityUpdateInterceptor() : Interceptor {
override fun onFlushDirty(
entity: Any?,
id: Any?,
currentState: Array<out Any?>?,
previousState: Array<out Any?>?,
propertyNames: Array<out String>?,
types: Array<out Type>?
): Boolean {
if (entity == null || currentState == null || previousState == null || propertyNames == null) {
return false
}
when (entity) {
is Game -> {
val previousGame = reconstructGame(entity, previousState, propertyNames)
val currentGame = reconstructGame(entity, currentState, propertyNames)
EventPublisherHolder.publish(GameUpdatedEvent(this, previousGame, currentGame))
}
is User -> {
val previousUser = reconstructUser(entity, previousState, propertyNames)
val currentUser = reconstructUser(entity, currentState, propertyNames)
EventPublisherHolder.publish(UserUpdatedEvent(this, previousUser, currentUser))
}
}
return false
}
private fun reconstructGame(originalGame: Game, state: Array<out Any?>, propertyNames: Array<out String>): Game {
val reconstructed = Game(
library = originalGame.library,
metadata = originalGame.metadata
)
for (i in propertyNames.indices) {
when (propertyNames[i]) {
"id" -> reconstructed.id = state[i] as? Long
"createdAt" -> reconstructed.createdAt = state[i] as? java.time.Instant
"updatedAt" -> reconstructed.updatedAt = state[i] as? java.time.Instant
"title" -> reconstructed.title = state[i] as? String
"coverImage" -> reconstructed.coverImage = state[i] as? Image
"headerImage" -> reconstructed.headerImage = state[i] as? Image
"comment" -> reconstructed.comment = state[i] as? String
"summary" -> reconstructed.summary = state[i] as? String
"release" -> reconstructed.release = state[i] as? java.time.Instant
"userRating" -> reconstructed.userRating = state[i] as? Int
"criticRating" -> reconstructed.criticRating = state[i] as? Int
"images" -> {
@Suppress("UNCHECKED_CAST")
(state[i] as? MutableList<Image>)?.let { reconstructed.images = it }
}
}
}
return reconstructed
}
private fun reconstructUser(originalUser: User, state: Array<out Any?>, propertyNames: Array<out String>): User {
val reconstructed = User(
username = originalUser.username,
email = originalUser.email
)
for (i in propertyNames.indices) {
when (propertyNames[i]) {
"id" -> reconstructed.id = state[i] as? Long
"password" -> reconstructed.password = state[i] as? String
"oidcProviderId" -> reconstructed.oidcProviderId = state[i] as? String
"emailConfirmed" -> reconstructed.emailConfirmed = state[i] as? Boolean ?: false
"enabled" -> reconstructed.enabled = state[i] as? Boolean ?: false
"avatar" -> reconstructed.avatar = state[i] as? Image
"roles" -> {
@Suppress("UNCHECKED_CAST")
(state[i] as? List<org.gameyfin.app.core.Role>)?.let { reconstructed.roles = it }
}
}
}
return reconstructed
}
}
@@ -1,41 +1,46 @@
package org.gameyfin.app.core.plugins package org.gameyfin.app.core.plugins
import com.vaadin.flow.server.auth.AnonymousAllowed
import com.vaadin.hilla.Endpoint import com.vaadin.hilla.Endpoint
import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
import org.gameyfin.app.core.Role import org.gameyfin.app.core.Role
import org.gameyfin.app.core.annotations.DynamicPublicAccess
import org.gameyfin.app.core.plugins.dto.PluginUpdateDto import org.gameyfin.app.core.plugins.dto.PluginUpdateDto
import org.gameyfin.app.core.security.isCurrentUserAdmin
import org.gameyfin.pluginapi.core.config.PluginConfigValidationResult import org.gameyfin.pluginapi.core.config.PluginConfigValidationResult
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
@Endpoint @Endpoint
@RolesAllowed(Role.Names.ADMIN) @DynamicPublicAccess
@AnonymousAllowed
class PluginEndpoint( class PluginEndpoint(
private val pluginService: PluginService, private val pluginService: PluginService,
) { ) {
@PermitAll
fun subscribe(): Flux<List<PluginUpdateDto>> { fun subscribe(): Flux<List<PluginUpdateDto>> {
return if (isCurrentUserAdmin()) PluginService.subscribe() return PluginService.subscribe()
else Flux.empty()
} }
fun getAll() = pluginService.getAll().sortedByDescending { it.priority } fun getAll() = pluginService.getAll().sortedByDescending { it.priority }
@RolesAllowed(Role.Names.ADMIN)
fun enablePlugin(pluginId: String) = pluginService.enablePlugin(pluginId) fun enablePlugin(pluginId: String) = pluginService.enablePlugin(pluginId)
@RolesAllowed(Role.Names.ADMIN)
fun disablePlugin(pluginId: String) = pluginService.disablePlugin(pluginId) fun disablePlugin(pluginId: String) = pluginService.disablePlugin(pluginId)
@RolesAllowed(Role.Names.ADMIN)
fun setPluginPriorities(pluginPriorities: Map<String, Int>) = fun setPluginPriorities(pluginPriorities: Map<String, Int>) =
pluginService.setPluginPriorities(pluginPriorities) pluginService.setPluginPriorities(pluginPriorities)
@RolesAllowed(Role.Names.ADMIN)
fun validatePluginConfig(pluginId: String): PluginConfigValidationResult = fun validatePluginConfig(pluginId: String): PluginConfigValidationResult =
pluginService.validatePluginConfig(pluginId, true) pluginService.validatePluginConfig(pluginId, true)
@RolesAllowed(Role.Names.ADMIN)
fun validateNewConfig(pluginId: String, config: Map<String, String>): PluginConfigValidationResult = fun validateNewConfig(pluginId: String, config: Map<String, String>): PluginConfigValidationResult =
pluginService.validatePluginConfig(pluginId, config) pluginService.validatePluginConfig(pluginId, config)
@RolesAllowed(Role.Names.ADMIN)
fun updateConfig(pluginId: String, updatedConfig: Map<String, String>) = fun updateConfig(pluginId: String, updatedConfig: Map<String, String>) =
pluginService.updateConfig(pluginId, updatedConfig) pluginService.updateConfig(pluginId, updatedConfig)
} }
@@ -0,0 +1,10 @@
package org.gameyfin.app.core.plugins.dto
class ExternalProviderIdDto(
val pluginId: String,
val externalProviderId: String,
) {
override fun toString(): String {
return "$pluginId:$externalProviderId"
}
}
@@ -1,59 +1,69 @@
package org.gameyfin.app.core.security package org.gameyfin.app.core.security
import com.vaadin.flow.spring.security.VaadinWebSecurity import com.vaadin.flow.spring.security.VaadinAwareSecurityContextHolderStrategyConfiguration
import com.vaadin.flow.spring.security.VaadinSecurityConfigurer
import com.vaadin.hilla.route.RouteUtil
import org.gameyfin.app.config.ConfigProperties import org.gameyfin.app.config.ConfigProperties
import org.gameyfin.app.config.ConfigService import org.gameyfin.app.config.ConfigService
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Conditional import org.springframework.context.annotation.Conditional
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Import
import org.springframework.core.env.Environment import org.springframework.core.env.Environment
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.builders.WebSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer
import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.session.SessionRegistry import org.springframework.security.core.session.SessionRegistry
import org.springframework.security.oauth2.client.registration.ClientRegistration import org.springframework.security.oauth2.client.registration.ClientRegistration
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository
import org.springframework.security.oauth2.core.AuthorizationGrantType import org.springframework.security.oauth2.core.AuthorizationGrantType
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@Import(
VaadinAwareSecurityContextHolderStrategyConfiguration::class
)
class SecurityConfig( class SecurityConfig(
private val environment: Environment, private val environment: Environment,
private val config: ConfigService, private val config: ConfigService,
private val ssoAuthenticationSuccessHandler: SsoAuthenticationSuccessHandler, private val ssoAuthenticationSuccessHandler: SsoAuthenticationSuccessHandler,
private val sessionRegistry: SessionRegistry private val sessionRegistry: SessionRegistry
) : VaadinWebSecurity() { ) {
companion object { companion object {
const val SSO_PROVIDER_KEY = "oidc" const val SSO_PROVIDER_KEY = "oidc"
} }
@Throws(Exception::class) @Bean
override fun configure(http: HttpSecurity) { fun filterChain(http: HttpSecurity, routeUtil: RouteUtil): SecurityFilterChain {
http.authorizeHttpRequests { auth ->
// Configure your static resources with public access before calling super.configure(HttpSecurity) as it adds final anyRequest matcher // Set default security policy that permits Hilla internal requests and denies all other
http.authorizeHttpRequests { auth: AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry -> auth.requestMatchers(routeUtil::isRouteAllowed).permitAll()
auth.requestMatchers("/login").permitAll() // Gameyfin static resources and public endpoints
.requestMatchers("/setup").permitAll() .requestMatchers(
.requestMatchers("/reset-password").permitAll() "/login",
.requestMatchers("/accept-invitation").permitAll() "/setup",
.requestMatchers("/public/**").permitAll() "/reset-password",
.requestMatchers("/images/**").permitAll() "/accept-invitation",
.requestMatchers("/favicon.ico").permitAll() "/public/**",
.requestMatchers("/favicon.svg").permitAll() "/images/**",
"/favicon.ico",
// Dynamic public access for certain endpoints "/favicon.svg"
auth.requestMatchers("/").access(DynamicPublicAccessAuthorizationManager(config)) ).permitAll()
.requestMatchers("/game/**").access(DynamicPublicAccessAuthorizationManager(config)) // Dynamic public access for certain endpoints
.requestMatchers("/library/**").access(DynamicPublicAccessAuthorizationManager(config)) .requestMatchers(
.requestMatchers("/search/**").access(DynamicPublicAccessAuthorizationManager(config)) "/",
.requestMatchers("/download/**").access(DynamicPublicAccessAuthorizationManager(config)) "/game/**",
"/library/**",
"/search/**",
"/requests/**",
"/download/**"
).access(DynamicPublicAccessAuthorizationManager(config))
} }
http.sessionManagement { sessionManagement -> http.sessionManagement { sessionManagement ->
@@ -66,11 +76,14 @@ class SecurityConfig(
// Not needed since the frontend is served by the backend // Not needed since the frontend is served by the backend
http.cors { cors -> cors.disable() } http.cors { cors -> cors.disable() }
super.configure(http)
setLoginView(http, "/login", "/")
if (config.get(ConfigProperties.SSO.OIDC.Enabled) == true) { if (config.get(ConfigProperties.SSO.OIDC.Enabled) == true) {
http.with(VaadinSecurityConfigurer.vaadin()) { configurer ->
// Redirect to SSO provider on logout
configurer.loginView("/login", config.get(ConfigProperties.SSO.OIDC.LogoutUrl))
}
// Use custom success handler to handle user registration // Use custom success handler to handle user registration
http.oauth2Login { oauth2Login -> oauth2Login.successHandler(ssoAuthenticationSuccessHandler) } http.oauth2Login { oauth2Login -> oauth2Login.successHandler(ssoAuthenticationSuccessHandler) }
// Prevent unnecessary redirects // Prevent unnecessary redirects
@@ -80,16 +93,18 @@ class SecurityConfig(
http.exceptionHandling { exceptionHandling -> http.exceptionHandling { exceptionHandling ->
exceptionHandling.authenticationEntryPoint(CustomAuthenticationEntryPoint()) exceptionHandling.authenticationEntryPoint(CustomAuthenticationEntryPoint())
} }
} else {
// Use default Vaadin login URLs
http.with(VaadinSecurityConfigurer.vaadin()) { configurer ->
configurer.loginView("/login")
}
} }
}
@Throws(Exception::class)
public override fun configure(web: WebSecurity) {
super.configure(web)
if ("dev" in environment.activeProfiles) { if ("dev" in environment.activeProfiles) {
web.ignoring().requestMatchers("/h2-console/**") http.authorizeHttpRequests { auth -> auth.requestMatchers("/h2-console/**").permitAll() }
} }
return http.build()
} }
@Bean @Bean
@@ -1,9 +1,17 @@
package org.gameyfin.app.core.security package org.gameyfin.app.core.security
import org.gameyfin.app.core.Role import org.gameyfin.app.core.Role
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.context.SecurityContextHolder
fun getCurrentAuth(): Authentication? {
return SecurityContextHolder.getContext().authentication
}
fun isCurrentUserAdmin(): Boolean { fun isCurrentUserAdmin(): Boolean {
return SecurityContextHolder.getContext().authentication?.authorities?.any { it.authority == Role.Names.ADMIN || it.authority == Role.Names.SUPERADMIN } return getCurrentAuth()?.isAdmin() ?: false
?: false }
fun Authentication.isAdmin(): Boolean {
return this.authorities?.any { it.authority == Role.Names.ADMIN || it.authority == Role.Names.SUPERADMIN } ?: false
} }
@@ -5,8 +5,12 @@ import com.vaadin.hilla.Endpoint
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
import org.gameyfin.app.core.Role import org.gameyfin.app.core.Role
import org.gameyfin.app.core.annotations.DynamicPublicAccess import org.gameyfin.app.core.annotations.DynamicPublicAccess
import org.gameyfin.app.core.plugins.dto.ExternalProviderIdDto
import org.gameyfin.app.core.security.isCurrentUserAdmin import org.gameyfin.app.core.security.isCurrentUserAdmin
import org.gameyfin.app.games.dto.* 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.libraries.LibraryCoreService import org.gameyfin.app.libraries.LibraryCoreService
import org.gameyfin.app.libraries.LibraryService import org.gameyfin.app.libraries.LibraryService
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
@@ -30,6 +34,10 @@ class GameEndpoint(
fun getAll(): List<GameDto> = gameService.getAll() fun getAll(): List<GameDto> = gameService.getAll()
fun getPotentialMatches(searchTerm: String): List<GameSearchResultDto> {
return gameService.getPotentialMatches(searchTerm)
}
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun updateGame(game: GameUpdateDto) = gameService.edit(game) fun updateGame(game: GameUpdateDto) = gameService.edit(game)
@@ -40,12 +48,12 @@ class GameEndpoint(
} }
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun getPotentialMatches(searchTerm: String): List<GameSearchResultDto> { fun matchManually(
return gameService.getPotentialMatches(searchTerm) originalIds: Map<String, ExternalProviderIdDto>,
} path: String,
libraryId: Long,
@RolesAllowed(Role.Names.ADMIN) replaceGameId: Long?
fun matchManually(originalIds: Map<String, OriginalIdDto>, path: String, libraryId: Long, replaceGameId: Long?) { ) {
val library = libraryService.getById(libraryId) val library = libraryService.getById(libraryId)
val game = gameService.matchManually(originalIds, Path.of(path), library, replaceGameId) val game = gameService.matchManually(originalIds, Path.of(path), library, replaceGameId)
if (game != null) { if (game != null) {
@@ -12,27 +12,27 @@ import org.gameyfin.app.core.alphaNumeric
import org.gameyfin.app.core.filesystem.FilesystemService import org.gameyfin.app.core.filesystem.FilesystemService
import org.gameyfin.app.core.filterValuesNotNull import org.gameyfin.app.core.filterValuesNotNull
import org.gameyfin.app.core.plugins.PluginService import org.gameyfin.app.core.plugins.PluginService
import org.gameyfin.app.core.plugins.dto.ExternalProviderIdDto
import org.gameyfin.app.core.plugins.management.GameyfinPluginDescriptor import org.gameyfin.app.core.plugins.management.GameyfinPluginDescriptor
import org.gameyfin.app.core.plugins.management.GameyfinPluginManager import org.gameyfin.app.core.plugins.management.GameyfinPluginManager
import org.gameyfin.app.core.plugins.management.PluginManagementEntry import org.gameyfin.app.core.plugins.management.PluginManagementEntry
import org.gameyfin.app.core.replaceRomanNumerals import org.gameyfin.app.core.replaceRomanNumerals
import org.gameyfin.app.core.security.getCurrentAuth
import org.gameyfin.app.games.dto.* import org.gameyfin.app.games.dto.*
import org.gameyfin.app.games.entities.* import org.gameyfin.app.games.entities.*
import org.gameyfin.app.games.extensions.toDtos import org.gameyfin.app.games.extensions.toDtos
import org.gameyfin.app.games.repositories.GameRepository import org.gameyfin.app.games.repositories.GameRepository
import org.gameyfin.app.libraries.Library import org.gameyfin.app.libraries.entities.Library
import org.gameyfin.app.media.ImageService import org.gameyfin.app.media.ImageService
import org.gameyfin.app.users.UserService import org.gameyfin.app.users.UserService
import org.gameyfin.pluginapi.gamemetadata.* import org.gameyfin.pluginapi.gamemetadata.*
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.oauth2.core.oidc.user.OidcUser import org.springframework.security.oauth2.core.oidc.user.OidcUser
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks import reactor.core.publisher.Sinks
import java.net.URI
import java.nio.file.Path import java.nio.file.Path
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset import java.time.ZoneOffset
@@ -104,10 +104,9 @@ class GameService(
return entities.toDtos() return entities.toDtos()
} }
@Transactional private fun create(game: Game): Game {
fun create(game: Game): Game? { game.publishers = game.publishers.map { companyService.createOrGet(it) }.toMutableList()
game.publishers = game.publishers.map { companyService.createOrGet(it) } game.developers = game.developers.map { companyService.createOrGet(it) }.toMutableList()
game.developers = game.developers.map { companyService.createOrGet(it) }
try { try {
game.coverImage?.let { game.coverImage?.let {
@@ -124,7 +123,6 @@ class GameService(
} catch (e: Exception) { } catch (e: Exception) {
log.error { "Error downloading images for game '${game.title}' (${game.id}): ${e.message}" } log.error { "Error downloading images for game '${game.title}' (${game.id}): ${e.message}" }
log.debug(e) {} log.debug(e) {}
null
} }
game.metadata.fileSize = filesystemService.calculateFileSize(game.metadata.path) game.metadata.fileSize = filesystemService.calculateFileSize(game.metadata.path)
@@ -137,9 +135,11 @@ class GameService(
val gamesToBePersisted = games.filter { it.id == null } val gamesToBePersisted = games.filter { it.id == null }
gamesToBePersisted.forEach { game -> gamesToBePersisted.forEach { game ->
game.publishers = game.publishers.map { companyService.createOrGet(it) } game.publishers = game.publishers.map { companyService.createOrGet(it) }.toMutableList()
game.developers = game.developers.map { companyService.createOrGet(it) } game.developers = game.developers.map { companyService.createOrGet(it) }.toMutableList()
game game.coverImage?.let { game.coverImage = imageService.createOrGet(it) }
game.headerImage?.let { game.headerImage = imageService.createOrGet(it) }
game.images = game.images.map { imageService.createOrGet(it) }.toMutableList()
} }
return gameRepository.saveAll(gamesToBePersisted) return gameRepository.saveAll(gamesToBePersisted)
@@ -149,11 +149,10 @@ class GameService(
val existingGame = gameRepository.findByIdOrNull(gameUpdateDto.id) val existingGame = gameRepository.findByIdOrNull(gameUpdateDto.id)
?: throw IllegalArgumentException("Game with ID $gameUpdateDto.id not found") ?: throw IllegalArgumentException("Game with ID $gameUpdateDto.id not found")
val userDetails = SecurityContextHolder.getContext().authentication.principal val user = when (val userDetails = getCurrentAuth()?.principal) {
val user = when (userDetails) {
is UserDetails -> userService.getByUsernameNonNull(userDetails.username) is UserDetails -> userService.getByUsernameNonNull(userDetails.username)
is OidcUser -> userService.getByUsernameNonNull(userDetails.preferredUsername) is OidcUser -> userService.getByUsernameNonNull(userDetails.preferredUsername)
else -> throw IllegalStateException("Unkown user type: ${userDetails::class.java.name}") else -> throw IllegalStateException("Unkown user type: ${userDetails?.javaClass?.name}")
} }
// Update only non-null fields // Update only non-null fields
@@ -166,14 +165,18 @@ class GameService(
existingGame.metadata.fields["release"]?.source = GameFieldUserSource(user = user) existingGame.metadata.fields["release"]?.source = GameFieldUserSource(user = user)
} }
gameUpdateDto.coverUrl?.let { gameUpdateDto.coverUrl?.let {
val newCoverImage = Image(originalUrl = URI.create(it).toURL(), type = ImageType.COVER) val newCoverImage = imageService.createOrGet(
Image(originalUrl = it, type = ImageType.COVER)
)
imageService.downloadIfNew(newCoverImage) imageService.downloadIfNew(newCoverImage)
existingGame.coverImage = newCoverImage existingGame.coverImage = newCoverImage
existingGame.metadata.fields["coverImage"]?.source = GameFieldUserSource(user = user) existingGame.metadata.fields["coverImage"]?.source = GameFieldUserSource(user = user)
} }
gameUpdateDto.headerUrl?.let { gameUpdateDto.headerUrl?.let {
val newHeaderImage = Image(originalUrl = URI.create(it).toURL(), type = ImageType.HEADER) val newHeaderImage = imageService.createOrGet(
Image(originalUrl = it, type = ImageType.HEADER)
)
imageService.downloadIfNew(newHeaderImage) imageService.downloadIfNew(newHeaderImage)
existingGame.headerImage = newHeaderImage existingGame.headerImage = newHeaderImage
@@ -190,11 +193,13 @@ class GameService(
gameUpdateDto.developers?.let { gameUpdateDto.developers?.let {
existingGame.developers = existingGame.developers =
it.map { name -> companyService.createOrGet(Company(name = name, type = CompanyType.DEVELOPER)) } it.map { name -> companyService.createOrGet(Company(name = name, type = CompanyType.DEVELOPER)) }
.toMutableList()
existingGame.metadata.fields["developers"]?.source = GameFieldUserSource(user = user) existingGame.metadata.fields["developers"]?.source = GameFieldUserSource(user = user)
} }
gameUpdateDto.publishers?.let { gameUpdateDto.publishers?.let {
existingGame.publishers = existingGame.publishers =
it.map { name -> companyService.createOrGet(Company(name = name, type = CompanyType.PUBLISHER)) } it.map { name -> companyService.createOrGet(Company(name = name, type = CompanyType.PUBLISHER)) }
.toMutableList()
existingGame.metadata.fields["publishers"]?.source = GameFieldUserSource(user = user) existingGame.metadata.fields["publishers"]?.source = GameFieldUserSource(user = user)
} }
gameUpdateDto.genres?.let { gameUpdateDto.genres?.let {
@@ -260,12 +265,12 @@ class GameService(
val game = getById(game.id!!) val game = getById(game.id!!)
val originalIds: Map<String, OriginalIdDto> = game.metadata.originalIds val originalIds: Map<String, ExternalProviderIdDto> = game.metadata.originalIds
.map { (provider, originalId) -> .map { (provider, originalId) ->
val providerId = pluginManager.getExtensions(provider.pluginId).first()?.javaClass?.name ?: return null val providerId = pluginManager.getExtensions(provider.pluginId).first()?.javaClass?.name ?: return null
val pluginId = provider.pluginId val pluginId = provider.pluginId
val originalId = originalId val originalId = originalId
providerId to OriginalIdDto(pluginId, originalId) providerId to ExternalProviderIdDto(pluginId, originalId)
} }
.toMap() .toMap()
@@ -378,7 +383,7 @@ class GameService(
"publishers", "publishers",
game.publishers, game.publishers,
updatedGame.publishers, updatedGame.publishers,
{ game.publishers = it ?: emptyList() }, { game.publishers = it ?: mutableListOf() },
updatedGame.metadata.fields["publishers"] updatedGame.metadata.fields["publishers"]
) )
@@ -387,7 +392,7 @@ class GameService(
"developers", "developers",
game.developers, game.developers,
updatedGame.developers, updatedGame.developers,
{ game.developers = it ?: emptyList() }, { game.developers = it ?: mutableListOf() },
updatedGame.metadata.fields["developers"] updatedGame.metadata.fields["developers"]
) )
@@ -441,7 +446,7 @@ class GameService(
"images", "images",
game.images, game.images,
updatedGame.images, updatedGame.images,
{ game.images = it ?: emptyList() }, { game.images = it ?: mutableListOf() },
updatedGame.metadata.fields["images"] updatedGame.metadata.fields["images"]
) )
@@ -504,12 +509,12 @@ class GameService(
sorted.mapNotNull { selector(it.second) }.firstOrNull { it.isNotEmpty() } sorted.mapNotNull { selector(it.second) }.firstOrNull { it.isNotEmpty() }
// Collect originalIds for this group // Collect originalIds for this group
val originalIds: Map<String, OriginalIdDto> = group val originalIds: Map<String, ExternalProviderIdDto> = group
.mapNotNull { (provider, metadata) -> .mapNotNull { (provider, metadata) ->
val providerId = provider.javaClass.name val providerId = provider.javaClass.name
val pluginId = providerToManagementEntry[provider]?.pluginId ?: return@mapNotNull null val pluginId = providerToManagementEntry[provider]?.pluginId ?: return@mapNotNull null
val originalId = metadata.originalId val originalId = metadata.originalId
if (providerId != null) providerId to OriginalIdDto(pluginId, originalId) else null if (providerId != null) providerId to ExternalProviderIdDto(pluginId, originalId) else null
} }
.toMap() .toMap()
@@ -567,7 +572,7 @@ class GameService(
} }
fun matchManually( fun matchManually(
originalIds: Map<String, OriginalIdDto>, originalIds: Map<String, ExternalProviderIdDto>,
path: Path, path: Path,
library: Library, library: Library,
replaceGameId: Long? = null, replaceGameId: Long? = null,
@@ -578,7 +583,7 @@ class GameService(
coroutineScope { coroutineScope {
metadataPlugins.associateWith { plugin -> metadataPlugins.associateWith { plugin ->
async { async {
val originalId = originalIds[plugin.javaClass.name]?.originalId ?: return@async null val originalId = originalIds[plugin.javaClass.name]?.externalProviderId ?: return@async null
try { try {
return@async plugin.fetchById(originalId) return@async plugin.fetchById(originalId)
} catch (e: Exception) { } catch (e: Exception) {
@@ -758,14 +763,18 @@ class GameService(
} }
metadata.coverUrls?.firstOrNull()?.let { coverUrl -> metadata.coverUrls?.firstOrNull()?.let { coverUrl ->
if (!metadataMap.containsKey("coverImage")) { if (!metadataMap.containsKey("coverImage")) {
mergedGame.coverImage = Image(originalUrl = coverUrl.toURL(), type = ImageType.COVER) mergedGame.coverImage = imageService.createOrGet(
Image(originalUrl = coverUrl.toString(), type = ImageType.COVER)
)
metadataMap["coverImage"] = metadataMap["coverImage"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin)) GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
} }
metadata.headerUrls?.firstOrNull()?.let { headerUrl -> metadata.headerUrls?.firstOrNull()?.let { headerUrl ->
if (!metadataMap.containsKey("headerImage")) { if (!metadataMap.containsKey("headerImage")) {
mergedGame.headerImage = Image(originalUrl = headerUrl.toURL(), type = ImageType.HEADER) mergedGame.headerImage = imageService.createOrGet(
Image(originalUrl = headerUrl.toString(), type = ImageType.HEADER)
)
metadataMap["headerImage"] = metadataMap["headerImage"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin)) GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
@@ -794,7 +803,7 @@ class GameService(
metadata.publishedBy?.takeIf { it.isNotEmpty() }?.let { publishedBy -> metadata.publishedBy?.takeIf { it.isNotEmpty() }?.let { publishedBy ->
if (!metadataMap.containsKey("publishers")) { if (!metadataMap.containsKey("publishers")) {
mergedGame.publishers = mergedGame.publishers =
publishedBy.map { Company(name = it, type = CompanyType.PUBLISHER) } publishedBy.map { Company(name = it, type = CompanyType.PUBLISHER) }.toMutableList()
metadataMap["publishers"] = metadataMap["publishers"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin)) GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
@@ -802,7 +811,7 @@ class GameService(
metadata.developedBy?.takeIf { it.isNotEmpty() }?.let { developedBy -> metadata.developedBy?.takeIf { it.isNotEmpty() }?.let { developedBy ->
if (!metadataMap.containsKey("developers")) { if (!metadataMap.containsKey("developers")) {
mergedGame.developers = mergedGame.developers =
developedBy.map { Company(name = it, type = CompanyType.DEVELOPER) } developedBy.map { Company(name = it, type = CompanyType.DEVELOPER) }.toMutableList()
metadataMap["developers"] = metadataMap["developers"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin)) GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
@@ -843,7 +852,11 @@ class GameService(
metadata.screenshotUrls?.takeIf { it.isNotEmpty() }?.let { screenshotUrls -> metadata.screenshotUrls?.takeIf { it.isNotEmpty() }?.let { screenshotUrls ->
if (!metadataMap.containsKey("images")) { if (!metadataMap.containsKey("images")) {
mergedGame.images = runBlocking { mergedGame.images = runBlocking {
screenshotUrls.map { Image(originalUrl = it.toURL(), type = ImageType.SCREENSHOT) } screenshotUrls.map {
imageService.createOrGet(
Image(originalUrl = it.toString(), type = ImageType.SCREENSHOT)
)
}.toMutableList()
} }
metadataMap["images"] = GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin)) metadataMap["images"] = GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
@@ -29,7 +29,7 @@ sealed interface GameDto {
val metadata: GameMetadataDto val metadata: GameMetadataDto
} }
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.ALWAYS)
data class GameUserDto( data class GameUserDto(
override val id: Long, override val id: Long,
override val createdAt: Instant, override val createdAt: Instant,
@@ -55,7 +55,7 @@ data class GameUserDto(
override val metadata: GameMetadataUserDto override val metadata: GameMetadataUserDto
) : GameDto ) : GameDto
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.ALWAYS)
data class GameAdminDto( data class GameAdminDto(
override val id: Long, override val id: Long,
override val createdAt: Instant, override val createdAt: Instant,
@@ -1,5 +1,6 @@
package org.gameyfin.app.games.dto package org.gameyfin.app.games.dto
import org.gameyfin.app.core.plugins.dto.ExternalProviderIdDto
import java.time.Instant import java.time.Instant
import java.util.* import java.util.*
@@ -11,18 +12,9 @@ class GameSearchResultDto(
val release: Instant?, val release: Instant?,
val publishers: Collection<String>?, val publishers: Collection<String>?,
val developers: Collection<String>?, val developers: Collection<String>?,
val originalIds: Map<String, OriginalIdDto> val originalIds: Map<String, ExternalProviderIdDto>
) )
class OriginalIdDto(
val pluginId: String,
val originalId: String,
) {
override fun toString(): String {
return "$pluginId:$originalId"
}
}
class UrlWithSourceDto( class UrlWithSourceDto(
val url: String, val url: String,
val pluginId: String val pluginId: String
@@ -1,7 +1,8 @@
package org.gameyfin.app.games.entities package org.gameyfin.app.games.entities
import jakarta.persistence.* import jakarta.persistence.*
import org.gameyfin.app.libraries.Library import jakarta.persistence.CascadeType.*
import org.gameyfin.app.libraries.entities.Library
import org.gameyfin.pluginapi.gamemetadata.GameFeature import org.gameyfin.pluginapi.gamemetadata.GameFeature
import org.gameyfin.pluginapi.gamemetadata.Genre import org.gameyfin.pluginapi.gamemetadata.Genre
import org.gameyfin.pluginapi.gamemetadata.PlayerPerspective import org.gameyfin.pluginapi.gamemetadata.PlayerPerspective
@@ -28,15 +29,14 @@ class Game(
var updatedAt: Instant? = null, var updatedAt: Instant? = null,
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "library_id")
val library: Library, val library: Library,
var title: String? = null, var title: String? = null,
@OneToOne(cascade = [CascadeType.ALL], orphanRemoval = true) @ManyToOne(cascade = [PERSIST, MERGE, REFRESH], fetch = FetchType.EAGER)
var coverImage: Image? = null, var coverImage: Image? = null,
@OneToOne(cascade = [CascadeType.ALL], orphanRemoval = true) @ManyToOne(cascade = [PERSIST, MERGE, REFRESH], fetch = FetchType.EAGER)
var headerImage: Image? = null, var headerImage: Image? = null,
@Lob @Lob
@@ -53,11 +53,11 @@ class Game(
var criticRating: Int? = null, var criticRating: Int? = null,
@ManyToMany(cascade = [CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REFRESH]) @ManyToMany(cascade = [PERSIST, MERGE, REFRESH], fetch = FetchType.EAGER)
var publishers: List<Company> = emptyList(), var publishers: MutableList<Company> = mutableListOf(),
@ManyToMany(cascade = [CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REFRESH]) @ManyToMany(cascade = [PERSIST, MERGE, REFRESH], fetch = FetchType.EAGER)
var developers: List<Company> = emptyList(), var developers: MutableList<Company> = mutableListOf(),
@ElementCollection(targetClass = Genre::class) @ElementCollection(targetClass = Genre::class)
var genres: List<Genre> = emptyList(), var genres: List<Genre> = emptyList(),
@@ -74,16 +74,14 @@ class Game(
@ElementCollection(targetClass = PlayerPerspective::class) @ElementCollection(targetClass = PlayerPerspective::class)
var perspectives: List<PlayerPerspective> = emptyList(), var perspectives: List<PlayerPerspective> = emptyList(),
@OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true) @ManyToMany(cascade = [PERSIST, MERGE, REFRESH], fetch = FetchType.EAGER)
var images: List<Image> = emptyList(), var images: MutableList<Image> = mutableListOf(),
@ElementCollection @ElementCollection
var videoUrls: List<URI> = emptyList(), var videoUrls: List<URI> = emptyList(),
@Embedded @Embedded
var metadata: GameMetadata var metadata: GameMetadata
) { ) {
constructor(path: Path, library: Library) : this(library = library, metadata = GameMetadata(path = path.toString())) constructor(path: Path, library: Library) : this(library = library, metadata = GameMetadata(path = path.toString()))
} }
@@ -3,28 +3,35 @@ package org.gameyfin.app.games.entities
import jakarta.persistence.PostPersist import jakarta.persistence.PostPersist
import jakarta.persistence.PostRemove import jakarta.persistence.PostRemove
import jakarta.persistence.PostUpdate import jakarta.persistence.PostUpdate
import org.gameyfin.app.core.events.GameCreatedEvent
import org.gameyfin.app.core.events.GameDeletedEvent
import org.gameyfin.app.games.GameService import org.gameyfin.app.games.GameService
import org.gameyfin.app.games.dto.GameAdminEvent import org.gameyfin.app.games.dto.GameAdminEvent
import org.gameyfin.app.games.dto.GameUserEvent import org.gameyfin.app.games.dto.GameUserEvent
import org.gameyfin.app.games.extensions.toAdminDto import org.gameyfin.app.games.extensions.toAdminDto
import org.gameyfin.app.games.extensions.toUserDto import org.gameyfin.app.games.extensions.toUserDto
import org.gameyfin.app.util.EventPublisherHolder
class GameEntityListener { class GameEntityListener {
@PostPersist @PostPersist
fun created(game: Game) { fun created(game: Game) {
GameService.emitUser(GameUserEvent.Created(game.toUserDto())) GameService.emitUser(GameUserEvent.Created(game.toUserDto()))
GameService.emitAdmin(GameAdminEvent.Created(game.toAdminDto())) GameService.emitAdmin(GameAdminEvent.Created(game.toAdminDto()))
EventPublisherHolder.publish(GameCreatedEvent(this, game))
} }
@PostUpdate @PostUpdate
fun updated(game: Game) { fun updated(game: Game) {
GameService.emitUser(GameUserEvent.Updated(game.toUserDto())) GameService.emitUser(GameUserEvent.Updated(game.toUserDto()))
GameService.emitAdmin(GameAdminEvent.Updated(game.toAdminDto())) GameService.emitAdmin(GameAdminEvent.Updated(game.toAdminDto()))
// GameUpdateEvent triggered via {@link org.gameyfin.app.core.interceptors.EntityUpdateInterceptor#onFlushDirty}
} }
@PostRemove @PostRemove
fun deleted(game: Game) { fun deleted(game: Game) {
GameService.emitUser(GameUserEvent.Deleted(game.id!!)) GameService.emitUser(GameUserEvent.Deleted(game.id!!))
GameService.emitAdmin(GameAdminEvent.Deleted(game.id!!)) GameService.emitAdmin(GameAdminEvent.Deleted(game.id!!))
EventPublisherHolder.publish(GameDeletedEvent(this, game))
} }
} }
@@ -1,19 +1,20 @@
package org.gameyfin.app.games.entities package org.gameyfin.app.games.entities
import jakarta.persistence.* import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import org.springframework.content.commons.annotations.ContentId import org.springframework.content.commons.annotations.ContentId
import org.springframework.content.commons.annotations.ContentLength import org.springframework.content.commons.annotations.ContentLength
import org.springframework.content.commons.annotations.MimeType import org.springframework.content.commons.annotations.MimeType
import java.net.URL
@Entity @Entity
@EntityListeners(ImageEntityListener::class)
class Image( class Image(
@Id @Id
@GeneratedValue(strategy = GenerationType.AUTO) @GeneratedValue(strategy = GenerationType.AUTO)
var id: Long? = null, var id: Long? = null,
val originalUrl: URL? = null, val originalUrl: String? = null,
val type: ImageType, val type: ImageType,
@@ -25,19 +26,7 @@ class Image(
@MimeType @MimeType
var mimeType: String? = null var mimeType: String? = null
) { )
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Image) return false
return originalUrl.toString() == other.originalUrl.toString()
}
override fun hashCode(): Int {
var result = id?.hashCode() ?: 0
result = 31 * result + originalUrl?.toString().hashCode()
return result
}
}
enum class ImageType { enum class ImageType {
COVER, COVER,
@@ -1,27 +0,0 @@
package org.gameyfin.app.games.entities
import jakarta.persistence.PostRemove
import org.gameyfin.app.media.ImageService
import org.springframework.context.ApplicationContext
import org.springframework.context.ApplicationContextAware
import org.springframework.stereotype.Component
@Component
class ImageEntityListener : ApplicationContextAware {
companion object {
private lateinit var applicationContext: ApplicationContext
}
override fun setApplicationContext(context: ApplicationContext) {
applicationContext = context
}
private fun getImageService(): ImageService {
return applicationContext.getBean(ImageService::class.java)
}
@PostRemove
fun deleted(image: Image) {
getImageService().deleteFile(image)
}
}
@@ -2,5 +2,17 @@ package org.gameyfin.app.games.repositories
import org.gameyfin.app.games.entities.Game import org.gameyfin.app.games.entities.Game
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import java.time.Instant
interface GameRepository : JpaRepository<Game, Long> interface GameRepository : JpaRepository<Game, Long> {
@Query("SELECT g FROM Game g WHERE g.title = :title AND YEAR(g.release) = YEAR(:release)")
fun findByTitleAndReleaseYear(
@Param("title") title: String,
@Param("release") release: Instant?
): List<Game>
@Query("SELECT CASE WHEN COUNT(g) > 0 THEN true ELSE false END FROM Game g WHERE g.coverImage.id = :imageId OR g.headerImage.id = :imageId OR :imageId IN (SELECT i.id FROM g.images i)")
fun existsByImage(@Param("imageId") imageId: Long): Boolean
}
@@ -2,8 +2,7 @@ package org.gameyfin.app.games.repositories
import org.gameyfin.app.games.entities.Image import org.gameyfin.app.games.entities.Image
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import java.net.URL
interface ImageRepository : JpaRepository<Image, Long> { interface ImageRepository : JpaRepository<Image, Long> {
fun findByOriginalUrl(originalUrl: URL): Image? fun findByOriginalUrl(originalUrl: String): Image?
} }
@@ -2,6 +2,7 @@ package org.gameyfin.app.libraries
import org.gameyfin.app.games.GameService import org.gameyfin.app.games.GameService
import org.gameyfin.app.games.entities.Game import org.gameyfin.app.games.entities.Game
import org.gameyfin.app.libraries.entities.Library
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.time.Instant import java.time.Instant
@@ -1,5 +1,6 @@
package org.gameyfin.app.libraries package org.gameyfin.app.libraries
import org.gameyfin.app.libraries.entities.Library
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.Query
@@ -5,16 +5,14 @@ import org.gameyfin.app.core.filesystem.FilesystemService
import org.gameyfin.app.games.GameService import org.gameyfin.app.games.GameService
import org.gameyfin.app.games.entities.Game import org.gameyfin.app.games.entities.Game
import org.gameyfin.app.games.entities.Image import org.gameyfin.app.games.entities.Image
import org.gameyfin.app.libraries.dto.LibraryScanProgress import org.gameyfin.app.libraries.dto.*
import org.gameyfin.app.libraries.dto.LibraryScanStatus import org.gameyfin.app.libraries.entities.Library
import org.gameyfin.app.libraries.dto.LibraryScanStep
import org.gameyfin.app.libraries.enums.ScanType import org.gameyfin.app.libraries.enums.ScanType
import org.gameyfin.app.libraries.scan.* import org.gameyfin.app.libraries.scan.*
import org.gameyfin.app.media.ImageService import org.gameyfin.app.media.ImageService
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks import reactor.core.publisher.Sinks
import java.net.URL
import java.nio.file.Path import java.nio.file.Path
import java.time.Instant import java.time.Instant
import java.util.concurrent.Callable import java.util.concurrent.Callable
@@ -345,19 +343,13 @@ class LibraryScanService(
// Deduplicate by originalUrl // Deduplicate by originalUrl
val uniqueImages = allImages val uniqueImages = allImages
.filter { it.originalUrl != null } .filter { it.originalUrl != null }
.distinctBy { it.originalUrl } .distinctBy { it.originalUrl.toString() }
// Map to track which Image entity was used for download per originalUrl
val downloadedImageMap = ConcurrentHashMap<URL, Image>()
// Download each unique image in parallel // Download each unique image in parallel
val imageDownloadTasks = uniqueImages.map { image -> val imageDownloadTasks = uniqueImages.map { image ->
Callable { Callable {
try { try {
imageService.downloadIfNew(image) imageService.downloadIfNew(image)
image.originalUrl?.let { url ->
downloadedImageMap[url] = image
}
} catch (e: Exception) { } catch (e: Exception) {
log.error { "Error downloading image '${image.originalUrl}': ${e.message}" } log.error { "Error downloading image '${image.originalUrl}': ${e.message}" }
log.debug(e) {} log.debug(e) {}
@@ -369,18 +361,20 @@ class LibraryScanService(
} }
executor.invokeAll(imageDownloadTasks) executor.invokeAll(imageDownloadTasks)
// After downloads, associate the contentId with all other Image entities in the batch with the same originalUrl // For remaining duplicate images, just copy the content metadata from the downloaded unique image
for ((url, downloadedImage) in downloadedImageMap) { val uniqueImagesByUrl = uniqueImages.associateBy { it.originalUrl.toString() }
val contentId = downloadedImage.contentId
if (contentId != null) { allImages.filter { it.originalUrl != null && it !in uniqueImages }
allImages.filter { it.originalUrl.toString() == url.toString() && it !== downloadedImage } .forEach { duplicateImage ->
.forEach { image -> val downloadedImage = uniqueImagesByUrl[duplicateImage.originalUrl.toString()]
imageService.downloadIfNew(image) if (downloadedImage != null && downloadedImage.contentId != null) {
progress.currentStep.current = completedImageDownload.incrementAndGet() duplicateImage.contentId = downloadedImage.contentId
emit(progress) duplicateImage.contentLength = downloadedImage.contentLength
} duplicateImage.mimeType = downloadedImage.mimeType
}
progress.currentStep.current = completedImageDownload.incrementAndGet()
emit(progress)
} }
}
return DownloadImagesResult(gamesWithImages = games) return DownloadImagesResult(gamesWithImages = games)
} }
@@ -3,6 +3,8 @@ package org.gameyfin.app.libraries
import com.vaadin.hilla.exception.EndpointException import com.vaadin.hilla.exception.EndpointException
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.gameyfin.app.libraries.dto.* import org.gameyfin.app.libraries.dto.*
import org.gameyfin.app.libraries.entities.DirectoryMapping
import org.gameyfin.app.libraries.entities.Library
import org.gameyfin.app.libraries.enums.ScanType import org.gameyfin.app.libraries.enums.ScanType
import org.gameyfin.app.libraries.extensions.toDtos import org.gameyfin.app.libraries.extensions.toDtos
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
@@ -1,6 +1,5 @@
package org.gameyfin.app.libraries.dto package org.gameyfin.app.libraries.dto
import org.gameyfin.app.libraries.LibraryScanResult
import org.gameyfin.app.libraries.enums.ScanType import org.gameyfin.app.libraries.enums.ScanType
import java.time.Instant import java.time.Instant
import java.util.* import java.util.*
@@ -1,4 +1,4 @@
package org.gameyfin.app.libraries package org.gameyfin.app.libraries.dto
interface LibraryScanResult { interface LibraryScanResult {
/** /**
@@ -1,4 +1,4 @@
package org.gameyfin.app.libraries package org.gameyfin.app.libraries.entities
import jakarta.persistence.* import jakarta.persistence.*
@@ -1,8 +1,7 @@
package org.gameyfin.app.libraries package org.gameyfin.app.libraries.entities
import org.gameyfin.app.games.entities.Game
import jakarta.persistence.* import jakarta.persistence.*
import org.gameyfin.app.games.entities.LibraryEntityListener import org.gameyfin.app.games.entities.Game
import org.hibernate.annotations.CreationTimestamp import org.hibernate.annotations.CreationTimestamp
import org.hibernate.annotations.UpdateTimestamp import org.hibernate.annotations.UpdateTimestamp
import java.time.Instant import java.time.Instant
@@ -1,9 +1,8 @@
package org.gameyfin.app.games.entities package org.gameyfin.app.libraries.entities
import jakarta.persistence.PostPersist import jakarta.persistence.PostPersist
import jakarta.persistence.PostRemove import jakarta.persistence.PostRemove
import jakarta.persistence.PostUpdate import jakarta.persistence.PostUpdate
import org.gameyfin.app.libraries.Library
import org.gameyfin.app.libraries.LibraryService import org.gameyfin.app.libraries.LibraryService
import org.gameyfin.app.libraries.dto.LibraryAdminEvent import org.gameyfin.app.libraries.dto.LibraryAdminEvent
import org.gameyfin.app.libraries.dto.LibraryUserEvent import org.gameyfin.app.libraries.dto.LibraryUserEvent
@@ -1,8 +1,8 @@
package org.gameyfin.app.libraries.extensions package org.gameyfin.app.libraries.extensions
import org.gameyfin.app.core.security.isCurrentUserAdmin import org.gameyfin.app.core.security.isCurrentUserAdmin
import org.gameyfin.app.libraries.Library
import org.gameyfin.app.libraries.dto.* import org.gameyfin.app.libraries.dto.*
import org.gameyfin.app.libraries.entities.Library
fun Library.toDto(): LibraryDto { fun Library.toDto(): LibraryDto {
@@ -7,6 +7,7 @@ import org.gameyfin.app.core.Role
import org.gameyfin.app.core.Utils import org.gameyfin.app.core.Utils
import org.gameyfin.app.core.annotations.DynamicPublicAccess import org.gameyfin.app.core.annotations.DynamicPublicAccess
import org.gameyfin.app.core.plugins.PluginService import org.gameyfin.app.core.plugins.PluginService
import org.gameyfin.app.core.security.getCurrentAuth
import org.gameyfin.app.games.entities.Image import org.gameyfin.app.games.entities.Image
import org.gameyfin.app.games.entities.ImageType import org.gameyfin.app.games.entities.ImageType
import org.gameyfin.app.users.UserService import org.gameyfin.app.users.UserService
@@ -15,8 +16,6 @@ import org.springframework.core.io.InputStreamResource
import org.springframework.http.HttpHeaders import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
@@ -48,7 +47,7 @@ class ImageEndpoint(
@GetMapping("/plugins/{id}/logo") @GetMapping("/plugins/{id}/logo")
fun getPluginLogo(@PathVariable("id") pluginId: String): ResponseEntity<ByteArrayResource>? { fun getPluginLogo(@PathVariable("id") pluginId: String): ResponseEntity<ByteArrayResource>? {
val logo = pluginService.getLogo(pluginId) val logo = pluginService.getLogo(pluginId)
return Utils.Companion.inputStreamToResponseEntity(logo) return Utils.inputStreamToResponseEntity(logo)
} }
@GetMapping("/avatar") @GetMapping("/avatar")
@@ -61,10 +60,10 @@ class ImageEndpoint(
@PermitAll @PermitAll
@PostMapping("/avatar/upload") @PostMapping("/avatar/upload")
fun uploadAvatar(@RequestParam("file") file: MultipartFile) { fun uploadAvatar(@RequestParam("file") file: MultipartFile) {
val auth: Authentication = SecurityContextHolder.getContext().authentication val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
val image: Image = if (!userService.hasAvatar(auth.name)) { val image: Image = if (!userService.hasAvatar(auth.name)) {
imageService.createFile(ImageType.AVATAR, file.inputStream, file.contentType!!) imageService.createFromInputStream(ImageType.AVATAR, file.inputStream, file.contentType!!)
} else { } else {
val existingAvatar = userService.getAvatar(auth.name)!! val existingAvatar = userService.getAvatar(auth.name)!!
imageService.updateFileContent(existingAvatar, file.inputStream, file.contentType!!) imageService.updateFileContent(existingAvatar, file.inputStream, file.contentType!!)
@@ -76,7 +75,7 @@ class ImageEndpoint(
@PermitAll @PermitAll
@PostMapping("/avatar/delete") @PostMapping("/avatar/delete")
fun deleteAvatar() { fun deleteAvatar() {
val auth: Authentication = SecurityContextHolder.getContext().authentication val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
userService.deleteAvatar(auth.name) userService.deleteAvatar(auth.name)
} }
@@ -86,7 +85,7 @@ class ImageEndpoint(
userService.deleteAvatar(name) userService.deleteAvatar(name)
} }
private fun getImageContent(id: Long): ResponseEntity<InputStreamResource>? { private fun getImageContent(id: Long): ResponseEntity<InputStreamResource> {
val image = imageService.getImage(id) ?: return ResponseEntity.notFound().build() val image = imageService.getImage(id) ?: return ResponseEntity.notFound().build()
val file = image.let { imageService.getFileContent(it) } val file = image.let { imageService.getFileContent(it) }
@@ -2,40 +2,119 @@ package org.gameyfin.app.media
import org.apache.tika.Tika import org.apache.tika.Tika
import org.apache.tika.io.TikaInputStream import org.apache.tika.io.TikaInputStream
import org.gameyfin.app.core.events.GameDeletedEvent
import org.gameyfin.app.core.events.GameUpdatedEvent
import org.gameyfin.app.core.events.UserDeletedEvent
import org.gameyfin.app.core.events.UserUpdatedEvent
import org.gameyfin.app.games.entities.Image import org.gameyfin.app.games.entities.Image
import org.gameyfin.app.games.entities.ImageType import org.gameyfin.app.games.entities.ImageType
import org.gameyfin.app.games.repositories.GameRepository
import org.gameyfin.app.games.repositories.ImageContentStore import org.gameyfin.app.games.repositories.ImageContentStore
import org.gameyfin.app.games.repositories.ImageRepository import org.gameyfin.app.games.repositories.ImageRepository
import org.gameyfin.app.users.persistence.UserRepository
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.event.TransactionPhase
import org.springframework.transaction.event.TransactionalEventListener
import java.io.InputStream import java.io.InputStream
import java.net.URI
@Service @Service
class ImageService( class ImageService(
private val imageRepository: ImageRepository, private val imageRepository: ImageRepository,
private val imageContentStore: ImageContentStore private val imageContentStore: ImageContentStore,
private val gameRepository: GameRepository,
private val userRepository: UserRepository
) { ) {
companion object { companion object {
private val tika = Tika(); private val tika = Tika()
}
@TransactionalEventListener(
classes = [GameDeletedEvent::class],
phase = TransactionPhase.AFTER_COMPLETION
)
fun onGameDeleted(event: GameDeletedEvent) {
val imagesToDelete = listOfNotNull(event.game.coverImage, event.game.headerImage)
.toMutableList()
.apply { addAll(event.game.images) }
imagesToDelete.forEach { deleteImageIfUnused(it) }
}
@TransactionalEventListener(
classes = [GameUpdatedEvent::class],
phase = TransactionPhase.AFTER_COMPLETION
)
fun onGameUpdated(event: GameUpdatedEvent) {
val imagesBeforeUpdate = listOfNotNull(event.previousState.coverImage, event.previousState.headerImage)
.toMutableList()
.apply { addAll(event.previousState.images) }
.toSet()
val imagesStillInUse = listOfNotNull(event.currentState.coverImage, event.currentState.headerImage)
.toMutableList()
.apply { addAll(event.currentState.images) }
.toSet()
imagesBeforeUpdate.minus(imagesStillInUse).forEach { deleteImageIfUnused(it) }
}
@TransactionalEventListener(
classes = [UserDeletedEvent::class],
phase = TransactionPhase.AFTER_COMPLETION
)
fun onAccountDeleted(event: UserDeletedEvent) {
event.user.avatar?.let { deleteImageIfUnused(it) }
}
@TransactionalEventListener(
classes = [UserUpdatedEvent::class],
phase = TransactionPhase.AFTER_COMPLETION
)
fun onUserUpdated(event: UserUpdatedEvent) {
event.previousState.avatar?.let { previousAvatar ->
if (previousAvatar != event.currentState.avatar) {
deleteImageIfUnused(previousAvatar)
}
}
}
fun createOrGet(image: Image): Image {
if (image.originalUrl != null) {
imageRepository.findByOriginalUrl(image.originalUrl)?.let { return it }
}
return imageRepository.save(image)
} }
fun downloadIfNew(image: Image) { fun downloadIfNew(image: Image) {
if (image.originalUrl == null) throw IllegalArgumentException("Image must have an original URL") if (image.originalUrl == null) throw IllegalArgumentException("Image must have an original URL")
// Always try to get existing image first to avoid detached entity issues
val existingImage = imageRepository.findByOriginalUrl(image.originalUrl) val existingImage = imageRepository.findByOriginalUrl(image.originalUrl)
if (existingImage != null && existingImage.contentId != null) { if (existingImage != null && existingImage.contentId != null) {
// If we have an existing image with content, associate it with the current image
imageContentStore.associate(image, existingImage.contentId) imageContentStore.associate(image, existingImage.contentId)
// Update the current image's content metadata
image.contentId = existingImage.contentId
image.contentLength = existingImage.contentLength
image.mimeType = existingImage.mimeType
return return
} }
TikaInputStream.get { image.originalUrl.openStream() }.use { input -> // If no existing image or existing image has no content, download it
TikaInputStream.get { URI.create(image.originalUrl).toURL().openStream() }.use { input ->
image.mimeType = tika.detect(input) image.mimeType = tika.detect(input)
imageContentStore.setContent(image, input) imageContentStore.setContent(image, input)
} }
// Save the image to ensure it's persisted
imageRepository.save(image)
} }
fun createFile(type: ImageType, content: InputStream, mimeType: String): Image { fun createFromInputStream(type: ImageType, content: InputStream, mimeType: String): Image {
val image = Image(type = type, mimeType = mimeType) val image = Image(type = type, mimeType = mimeType)
imageRepository.save(image) imageRepository.save(image)
return imageContentStore.setContent(image, content) return imageContentStore.setContent(image, content)
@@ -45,19 +124,20 @@ class ImageService(
return imageRepository.findByIdOrNull(id) return imageRepository.findByIdOrNull(id)
} }
fun getFileContent(id: Long): InputStream? {
val image = getImage(id) ?: return null
return getFileContent(image)
}
fun getFileContent(image: Image): InputStream? { fun getFileContent(image: Image): InputStream? {
return imageContentStore.getContent(image) return imageContentStore.getContent(image)
} }
fun deleteFile(image: Image) { fun deleteImageIfUnused(image: Image) {
imageContentStore.unsetContent(image) val imageId = image.id ?: return
imageRepository.delete(image)
val isImageStillInUse = gameRepository.existsByImage(imageId) || userRepository.existsByAvatar(imageId)
if (!isImageStillInUse) {
imageContentStore.unsetContent(image)
imageRepository.delete(image)
}
} }
fun updateFileContent(image: Image, content: InputStream, mimeType: String? = null): Image { fun updateFileContent(image: Image, content: InputStream, mimeType: String? = null): Image {
@@ -3,6 +3,7 @@ package org.gameyfin.app.messages
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.gameyfin.app.core.events.* import org.gameyfin.app.core.events.*
import org.gameyfin.app.core.security.getCurrentAuth
import org.gameyfin.app.messages.providers.AbstractMessageProvider import org.gameyfin.app.messages.providers.AbstractMessageProvider
import org.gameyfin.app.messages.templates.MessageTemplateService import org.gameyfin.app.messages.templates.MessageTemplateService
import org.gameyfin.app.messages.templates.MessageTemplates import org.gameyfin.app.messages.templates.MessageTemplates
@@ -10,8 +11,6 @@ import org.gameyfin.app.users.UserService
import org.springframework.context.ApplicationContext import org.springframework.context.ApplicationContext
import org.springframework.context.event.EventListener import org.springframework.context.event.EventListener
import org.springframework.scheduling.annotation.Async import org.springframework.scheduling.annotation.Async
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.util.* import java.util.*
@@ -61,7 +60,7 @@ class MessageService(
} }
try { try {
val auth: Authentication = SecurityContextHolder.getContext().authentication val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
val user = userService.getByUsername(auth.name) ?: throw IllegalStateException("User not found") val user = userService.getByUsername(auth.name) ?: throw IllegalStateException("User not found")
val template = templateService.getMessageTemplate(templateKey) val template = templateService.getMessageTemplate(templateKey)
sendNotification(user.email, "[Gameyfin] Test Notification", template, placeholders) sendNotification(user.email, "[Gameyfin] Test Notification", template, placeholders)
@@ -207,8 +206,8 @@ class MessageService(
} }
@Async @Async
@EventListener(AccountDeletedEvent::class) @EventListener(UserDeletedEvent::class)
fun onAccountDeletion(event: AccountDeletedEvent) { fun onAccountDeletion(event: UserDeletedEvent) {
if (!enabled) { if (!enabled) {
log.error { "No message provider available, can't send account deletion message" } log.error { "No message provider available, can't send account deletion message" }
@@ -0,0 +1,49 @@
package org.gameyfin.app.requests
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.config.ConfigService
import org.gameyfin.app.core.Role
import org.gameyfin.app.core.annotations.DynamicPublicAccess
import org.gameyfin.app.requests.dto.GameRequestCreationDto
import org.gameyfin.app.requests.dto.GameRequestEvent
import org.gameyfin.app.requests.status.GameRequestStatus
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
@Endpoint
@Service
@DynamicPublicAccess
@AnonymousAllowed
class GameRequestEndpoint(
private val gameRequestService: GameRequestService,
private val config: ConfigService
) {
fun subscribe(): Flux<List<GameRequestEvent>> {
return GameRequestService.subscribe()
}
fun getAll() = gameRequestService.getAll()
fun create(gameRequest: GameRequestCreationDto) {
gameRequestService.createRequest(gameRequest)
}
@PermitAll
fun toggleVote(gameRequestId: Long) {
gameRequestService.toggleRequestVote(gameRequestId)
}
@PermitAll
fun delete(gameRequestId: Long) {
gameRequestService.deleteRequest(gameRequestId)
}
@RolesAllowed(Role.Names.ADMIN)
fun changeStatus(gameRequestId: Long, newStatus: GameRequestStatus) {
gameRequestService.changeRequestStatus(gameRequestId, newStatus)
}
}
@@ -0,0 +1,29 @@
package org.gameyfin.app.requests
import org.gameyfin.app.requests.entities.GameRequest
import org.gameyfin.app.requests.status.GameRequestStatus
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import java.time.Instant
interface GameRequestRepository : JpaRepository<GameRequest, Long> {
@Query("SELECT g FROM GameRequest g WHERE g.title = :title AND YEAR(g.release) = YEAR(:release)")
fun findByTitleAndReleaseYear(
@Param("title") title: String,
@Param("release") release: Instant?
): List<GameRequest>
@Query("SELECT g FROM GameRequest g WHERE g.title = :title AND YEAR(g.release) = YEAR(:release) AND g.status NOT IN (:excludedStatuses)")
fun findRequestsByTitleAndReleaseYearAndStatusNotIn(
@Param("title") title: String,
@Param("release") release: Instant?,
@Param("excludedStatuses") excludedStatuses: List<GameRequestStatus>
): List<GameRequest>
@Query("SELECT g FROM GameRequest g WHERE g.requester.id = :requesterId AND g.status IN (:statuses)")
fun findRequestsByRequesterIdAndStatusIn(
@Param("requesterId") requesterId: Long?,
@Param("statuses") statuses: List<GameRequestStatus>
): List<GameRequest>
}
@@ -0,0 +1,201 @@
package org.gameyfin.app.requests
import com.vaadin.hilla.exception.EndpointException
import io.github.oshai.kotlinlogging.KotlinLogging
import org.gameyfin.app.config.ConfigProperties
import org.gameyfin.app.config.ConfigService
import org.gameyfin.app.core.events.GameCreatedEvent
import org.gameyfin.app.core.security.getCurrentAuth
import org.gameyfin.app.core.security.isAdmin
import org.gameyfin.app.games.repositories.GameRepository
import org.gameyfin.app.requests.dto.GameRequestCreationDto
import org.gameyfin.app.requests.dto.GameRequestDto
import org.gameyfin.app.requests.dto.GameRequestEvent
import org.gameyfin.app.requests.entities.GameRequest
import org.gameyfin.app.requests.extensions.toDto
import org.gameyfin.app.requests.extensions.toDtos
import org.gameyfin.app.requests.status.GameRequestStatus
import org.gameyfin.app.users.UserService
import org.gameyfin.app.users.entities.User
import org.springframework.context.event.EventListener
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.toJavaDuration
@Service
class GameRequestService(
private val config: ConfigService,
private val userService: UserService,
private val gameRequestRepository: GameRequestRepository,
private val gameRepository: GameRepository
) {
companion object {
private val log = KotlinLogging.logger {}
/* Websockets */
private val gameRequestEvents = Sinks.many().multicast().onBackpressureBuffer<GameRequestEvent>(1024, false)
fun subscribe(): Flux<List<GameRequestEvent>> {
log.debug { "New user subscription for gameRequestEvents" }
return gameRequestEvents.asFlux()
.buffer(100.milliseconds.toJavaDuration())
.doOnSubscribe {
log.debug { "Subscriber added to gameRequestEvents [${gameRequestEvents.currentSubscriberCount()}]" }
}
.doFinally {
log.debug { "Subscriber removed from gameRequestEvents with signal type $it [${gameRequestEvents.currentSubscriberCount()}]" }
}
}
fun emit(event: GameRequestEvent) {
gameRequestEvents.tryEmitNext(event)
}
}
fun getAll(): List<GameRequestDto> {
val entities = gameRequestRepository.findAll()
return entities.toDtos()
}
fun createRequest(gameRequest: GameRequestCreationDto) {
// Check if requests are enabled
if (config.get(ConfigProperties.Requests.Games.Enabled) != true) {
throw EndpointException("Game requests are disabled")
}
// Check if game is already available
val existingGames = gameRepository.findByTitleAndReleaseYear(gameRequest.title, gameRequest.release)
if (existingGames.isNotEmpty()) {
throw EndpointException(
"This game is already available (ID: ${existingGames[0].id})"
)
}
// Check if a request with the same title and release year already exists
val existingRequests = gameRequestRepository.findByTitleAndReleaseYear(
gameRequest.title,
gameRequest.release
)
if (existingRequests.isNotEmpty()) {
throw EndpointException("A request for this game already exists (ID: ${existingRequests[0].id})")
}
val auth = getCurrentAuth()
val currentUser = auth?.let { userService.getByUsername(it.name) }
// Check if guests are allowed to create requests
if (config.get(ConfigProperties.Requests.Games.AllowGuestsToRequestGames) != true && currentUser == null) {
throw EndpointException("Only registered users can submit game requests")
}
// Check if user has too many open requests (0 means no limit per user)
// Note: All guests are treated as a single user with null ID and thus share their request limit
// Note: Admins are exempt from this limit
val pendingRequestsForUser = gameRequestRepository.findRequestsByRequesterIdAndStatusIn(
currentUser?.id,
listOf(GameRequestStatus.PENDING)
)
val maxRequestsPerUser = config.get(ConfigProperties.Requests.Games.MaxOpenRequestsPerUser) ?: 0
if (maxRequestsPerUser == 0 || (auth?.isAdmin() != true && pendingRequestsForUser.size >= maxRequestsPerUser)) {
throw EndpointException("You have reached the maximum number of pending requests (${maxRequestsPerUser})")
}
val newGameRequest = GameRequest(
title = gameRequest.title,
release = gameRequest.release,
status = GameRequestStatus.PENDING,
requester = currentUser,
voters = mutableSetOf<User>().apply {
currentUser?.let { add(it) }
}
)
gameRequestRepository.save(newGameRequest)
}
fun deleteRequest(id: Long) {
val gameRequest = gameRequestRepository.findById(id)
.orElseThrow { NoSuchElementException("No game request found with id $id") }
val auth = getCurrentAuth()
val currentUser = auth?.let { userService.getByUsername(it.name) }
val requester = gameRequest.requester
// Check if the current user is the requester or an admin
// Note: Requests submitted by guests (request is null) can only be deleted by an admin
if (auth?.isAdmin() != true && (requester == null || requester.id != currentUser?.id)) {
throw EndpointException("Only the requester or an admin can delete a game request")
}
gameRequestRepository.delete(gameRequest)
}
fun changeRequestStatus(id: Long, status: GameRequestStatus) {
val gameRequest = gameRequestRepository.findById(id)
.orElseThrow { NoSuchElementException("No game request found with id $id") }
if (gameRequest.status == GameRequestStatus.FULFILLED) {
log.debug { "Status of requests with status ${GameRequestStatus.FULFILLED} can't be changed" }
return
}
gameRequest.status = status
gameRequestRepository.save(gameRequest)
}
@Transactional
fun toggleRequestVote(id: Long) {
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
val currentUser =
userService.getByUsername(auth.name) ?: throw IllegalStateException("Current user not found")
val gameRequest = gameRequestRepository.findById(id)
.orElseThrow { NoSuchElementException("No game request found with id $id") }
// Replace the voters collection to ensure Hibernate detects the change
val updatedVoters = gameRequest.voters.toMutableSet()
if (updatedVoters.contains(currentUser)) {
updatedVoters.remove(currentUser)
} else {
updatedVoters.add(currentUser)
}
gameRequest.voters = updatedVoters
// Ensure the entity is marked as dirty
gameRequest.status = gameRequest.status
gameRequestRepository.save(gameRequest)
}
@Async
@EventListener(GameCreatedEvent::class)
fun completeMatchingRequests(gameCreatedEvent: GameCreatedEvent) {
val game = gameCreatedEvent.game
val gameTitle = game.title
val gameRelease = game.release
if (gameTitle == null) {
log.warn { "Game '${game.id}' is missing title, cannot complete matching requests" }
return
}
val matchingRequests = gameRequestRepository.findRequestsByTitleAndReleaseYearAndStatusNotIn(
gameTitle,
gameRelease,
listOf(GameRequestStatus.FULFILLED)
)
matchingRequests.forEach { request ->
request.status = GameRequestStatus.FULFILLED
request.linkedGameId = game.id
val persistedRequest = gameRequestRepository.save(request)
emit(GameRequestEvent.Updated(persistedRequest.toDto()))
log.info { "Marked game request '${request.title}' (${request.release}) as FULFILLED because game is now available" }
}
}
}
@@ -0,0 +1,9 @@
package org.gameyfin.app.requests.dto
import java.time.Instant
class GameRequestCreationDto(
val title: String,
val release: Instant?
)
@@ -0,0 +1,16 @@
package org.gameyfin.app.requests.dto
import org.gameyfin.app.requests.status.GameRequestStatus
import org.gameyfin.app.users.dto.UserInfoDto
import java.time.Instant
class GameRequestDto(
val id: Long,
val title: String,
val release: Instant?,
val status: GameRequestStatus,
val requester: UserInfoDto?,
val voters: List<UserInfoDto>,
val createdAt: Instant,
val updatedAt: Instant
)
@@ -0,0 +1,9 @@
package org.gameyfin.app.requests.dto
sealed class GameRequestEvent {
abstract val type: String
data class Created(val gameRequest: GameRequestDto, override val type: String = "created") : GameRequestEvent()
data class Updated(val gameRequest: GameRequestDto, override val type: String = "updated") : GameRequestEvent()
data class Deleted(val gameRequestId: Long, override val type: String = "deleted") : GameRequestEvent()
}
@@ -0,0 +1,46 @@
package org.gameyfin.app.requests.entities
import jakarta.persistence.*
import org.gameyfin.app.requests.status.GameRequestStatus
import org.gameyfin.app.users.entities.User
import org.hibernate.annotations.CreationTimestamp
import org.hibernate.annotations.OnDelete
import org.hibernate.annotations.OnDeleteAction
import org.hibernate.annotations.UpdateTimestamp
import java.time.Instant
@Entity
@EntityListeners(GameRequestEntityListener::class)
class GameRequest(
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
var id: Long? = null,
@Column(nullable = false)
val title: String,
@Column(nullable = false)
val release: Instant?,
@Column(nullable = false)
@Enumerated(EnumType.STRING)
var status: GameRequestStatus,
@ManyToOne(fetch = FetchType.EAGER)
@OnDelete(action = OnDeleteAction.SET_NULL)
var requester: User? = null,
@ManyToMany(fetch = FetchType.EAGER)
@OnDelete(action = OnDeleteAction.CASCADE)
var voters: MutableSet<User> = mutableSetOf(),
var linkedGameId: Long? = null,
@CreationTimestamp
@Column(nullable = false, updatable = false)
var createdAt: Instant? = null,
@UpdateTimestamp
@Column(nullable = false)
var updatedAt: Instant? = null
)
@@ -0,0 +1,25 @@
package org.gameyfin.app.requests.entities
import jakarta.persistence.PostPersist
import jakarta.persistence.PostRemove
import jakarta.persistence.PostUpdate
import org.gameyfin.app.requests.GameRequestService
import org.gameyfin.app.requests.dto.GameRequestEvent
import org.gameyfin.app.requests.extensions.toDto
class GameRequestEntityListener {
@PostPersist
fun created(gameRequest: GameRequest) {
GameRequestService.emit(GameRequestEvent.Created(gameRequest.toDto()))
}
@PostUpdate
fun updated(gameRequest: GameRequest) {
GameRequestService.emit(GameRequestEvent.Updated(gameRequest.toDto()))
}
@PostRemove
fun deleted(gameRequest: GameRequest) {
GameRequestService.emit(GameRequestEvent.Deleted(gameRequest.id!!))
}
}
@@ -0,0 +1,22 @@
package org.gameyfin.app.requests.extensions
import org.gameyfin.app.requests.dto.GameRequestDto
import org.gameyfin.app.requests.entities.GameRequest
import org.gameyfin.app.users.extensions.toUserInfoDto
fun GameRequest.toDto(): GameRequestDto {
return GameRequestDto(
id = this.id!!,
title = this.title,
release = this.release,
status = this.status,
requester = this.requester?.toUserInfoDto(),
voters = this.voters.map { it.toUserInfoDto() },
createdAt = this.createdAt!!,
updatedAt = this.updatedAt!!
)
}
fun Collection<GameRequest>.toDtos(): List<GameRequestDto> {
return this.map { it.toDto() }
}
@@ -0,0 +1,8 @@
package org.gameyfin.app.requests.status
enum class GameRequestStatus {
PENDING,
APPROVED,
REJECTED,
FULFILLED
}
@@ -3,7 +3,7 @@ package org.gameyfin.app.setup
import com.vaadin.flow.server.auth.AnonymousAllowed import com.vaadin.flow.server.auth.AnonymousAllowed
import com.vaadin.hilla.Endpoint import com.vaadin.hilla.Endpoint
import com.vaadin.hilla.exception.EndpointException import com.vaadin.hilla.exception.EndpointException
import org.gameyfin.app.users.dto.UserInfoDto import org.gameyfin.app.users.dto.ExtendedUserInfoDto
import org.gameyfin.app.users.dto.UserRegistrationDto import org.gameyfin.app.users.dto.UserRegistrationDto
@Endpoint @Endpoint
@@ -16,7 +16,7 @@ class SetupEndpoint(
} }
@AnonymousAllowed @AnonymousAllowed
fun registerSuperAdmin(superAdminRegistration: UserRegistrationDto): UserInfoDto { fun registerSuperAdmin(superAdminRegistration: UserRegistrationDto): ExtendedUserInfoDto {
if (setupService.isSetupCompleted()) throw EndpointException("Setup already completed") if (setupService.isSetupCompleted()) throw EndpointException("Setup already completed")
return setupService.createInitialAdminUser(superAdminRegistration) return setupService.createInitialAdminUser(superAdminRegistration)
} }
@@ -1,11 +1,12 @@
package org.gameyfin.app.setup package org.gameyfin.app.setup
import org.gameyfin.app.users.UserService
import org.gameyfin.app.core.Role import org.gameyfin.app.core.Role
import org.gameyfin.app.users.RoleService import org.gameyfin.app.users.RoleService
import org.gameyfin.app.users.dto.UserInfoDto import org.gameyfin.app.users.UserService
import org.gameyfin.app.users.dto.ExtendedUserInfoDto
import org.gameyfin.app.users.dto.UserRegistrationDto import org.gameyfin.app.users.dto.UserRegistrationDto
import org.gameyfin.app.users.entities.User import org.gameyfin.app.users.entities.User
import org.gameyfin.app.users.extensions.toExtendedUserInfoDto
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@Service @Service
@@ -26,7 +27,7 @@ class SetupService(
/** /**
* Creates the initial user with Super-Admin permissions * Creates the initial user with Super-Admin permissions
*/ */
fun createInitialAdminUser(registration: UserRegistrationDto): UserInfoDto { fun createInitialAdminUser(registration: UserRegistrationDto): ExtendedUserInfoDto {
val superAdmin = User( val superAdmin = User(
username = registration.username, username = registration.username,
password = registration.password, password = registration.password,
@@ -36,6 +37,6 @@ class SetupService(
) )
val user = userService.registerOrUpdateUser(superAdmin) val user = userService.registerOrUpdateUser(superAdmin)
return userService.toUserInfo(user) return user.toExtendedUserInfoDto()
} }
} }
@@ -1,7 +1,7 @@
package org.gameyfin.app.users package org.gameyfin.app.users
import org.gameyfin.app.core.security.getCurrentAuth
import org.gameyfin.app.users.entities.User import org.gameyfin.app.users.entities.User
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.session.SessionInformation import org.springframework.security.core.session.SessionInformation
import org.springframework.security.core.session.SessionRegistry import org.springframework.security.core.session.SessionRegistry
@@ -11,14 +11,12 @@ import org.springframework.stereotype.Service
class SessionService(private val sessionRegistry: SessionRegistry) { class SessionService(private val sessionRegistry: SessionRegistry) {
fun logoutAllSessions() { fun logoutAllSessions() {
val auth: Authentication? = SecurityContextHolder.getContext().authentication val auth = getCurrentAuth()
if (auth != null) { val sessions: List<SessionInformation> = sessionRegistry.getAllSessions(auth?.principal, false)
val sessions: List<SessionInformation> = sessionRegistry.getAllSessions(auth.principal, false) for (sessionInfo in sessions) {
for (sessionInfo in sessions) { sessionInfo.expireNow()
sessionInfo.expireNow()
}
SecurityContextHolder.clearContext()
} }
SecurityContextHolder.clearContext()
} }
fun logoutAllSessions(user: User) { fun logoutAllSessions(user: User) {
@@ -5,11 +5,11 @@ import com.vaadin.hilla.Endpoint
import jakarta.annotation.security.PermitAll import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
import org.gameyfin.app.core.Role import org.gameyfin.app.core.Role
import org.gameyfin.app.users.dto.UserInfoDto import org.gameyfin.app.core.security.getCurrentAuth
import org.gameyfin.app.users.dto.ExtendedUserInfoDto
import org.gameyfin.app.users.dto.UserUpdateDto import org.gameyfin.app.users.dto.UserUpdateDto
import org.gameyfin.app.users.enums.RoleAssignmentResult import org.gameyfin.app.users.enums.RoleAssignmentResult
import org.springframework.security.core.Authentication import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder
@Endpoint @Endpoint
@@ -18,15 +18,15 @@ class UserEndpoint(
private val roleService: RoleService private val roleService: RoleService
) { ) {
@AnonymousAllowed @AnonymousAllowed
fun getUserInfo(): UserInfoDto? { fun getUserInfo(): ExtendedUserInfoDto? {
val auth = SecurityContextHolder.getContext().authentication val auth = getCurrentAuth()
if (!auth.isAuthenticated || auth.principal == "anonymousUser") return null if (auth?.isAuthenticated == false || auth?.principal == "anonymousUser") return null
return userService.getUserInfo() return userService.getUserInfo()
} }
@PermitAll @PermitAll
fun updateUser(updates: UserUpdateDto) { fun updateUser(updates: UserUpdateDto) {
val auth: Authentication = SecurityContextHolder.getContext().authentication val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
userService.updateUser(auth.name, updates) userService.updateUser(auth.name, updates)
} }
@@ -36,7 +36,7 @@ class UserEndpoint(
} }
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun getAllUsers(): List<UserInfoDto> { fun getAllUsers(): List<ExtendedUserInfoDto> {
return userService.getAllUsers() return userService.getAllUsers()
} }
@@ -52,7 +52,7 @@ class UserEndpoint(
@PermitAll @PermitAll
fun deleteUser() { fun deleteUser() {
val auth: Authentication = SecurityContextHolder.getContext().authentication val auth: Authentication = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
userService.deleteUser(auth.name) userService.deleteUser(auth.name)
} }
@@ -68,7 +68,7 @@ class UserEndpoint(
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun getRolesBelow(): List<String> { fun getRolesBelow(): List<String> {
val auth: Authentication = SecurityContextHolder.getContext().authentication val auth: Authentication = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
return roleService.getRolesBelowAuth(auth).map { it.roleName } return roleService.getRolesBelowAuth(auth).map { it.roleName }
} }
@@ -5,19 +5,22 @@ import org.gameyfin.app.config.ConfigProperties
import org.gameyfin.app.config.ConfigService import org.gameyfin.app.config.ConfigService
import org.gameyfin.app.core.Role import org.gameyfin.app.core.Role
import org.gameyfin.app.core.Utils import org.gameyfin.app.core.Utils
import org.gameyfin.app.core.events.* import org.gameyfin.app.core.events.AccountStatusChangedEvent
import org.gameyfin.app.core.events.EmailNeedsConfirmationEvent
import org.gameyfin.app.core.events.RegistrationAttemptWithExistingEmailEvent
import org.gameyfin.app.core.events.UserRegistrationWaitingForApprovalEvent
import org.gameyfin.app.core.security.getCurrentAuth
import org.gameyfin.app.games.entities.Image import org.gameyfin.app.games.entities.Image
import org.gameyfin.app.media.ImageService import org.gameyfin.app.media.ImageService
import org.gameyfin.app.users.dto.UserInfoDto import org.gameyfin.app.users.dto.ExtendedUserInfoDto
import org.gameyfin.app.users.dto.UserRegistrationDto import org.gameyfin.app.users.dto.UserRegistrationDto
import org.gameyfin.app.users.dto.UserUpdateDto import org.gameyfin.app.users.dto.UserUpdateDto
import org.gameyfin.app.users.emailconfirmation.EmailConfirmationService import org.gameyfin.app.users.emailconfirmation.EmailConfirmationService
import org.gameyfin.app.users.enums.RoleAssignmentResult import org.gameyfin.app.users.enums.RoleAssignmentResult
import org.gameyfin.app.users.extensions.toAuthorities
import org.gameyfin.app.users.extensions.toExtendedUserInfoDto
import org.gameyfin.app.users.persistence.UserRepository import org.gameyfin.app.users.persistence.UserRepository
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.User import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService import org.springframework.security.core.userdetails.UserDetailsService
@@ -56,7 +59,7 @@ class UserService(
true, true,
true, true,
true, true,
toAuthorities(user.roles) user.roles.toAuthorities()
) )
} }
@@ -67,7 +70,7 @@ class UserService(
true, true,
true, true,
true, true,
toAuthorities(user.roles) user.roles.toAuthorities()
) )
} }
@@ -77,8 +80,8 @@ class UserService(
fun findByOidcProviderId(oidcProviderId: String): org.gameyfin.app.users.entities.User? = fun findByOidcProviderId(oidcProviderId: String): org.gameyfin.app.users.entities.User? =
userRepository.findByOidcProviderId(oidcProviderId) userRepository.findByOidcProviderId(oidcProviderId)
fun getAllUsers(): List<UserInfoDto> { fun getAllUsers(): List<ExtendedUserInfoDto> {
return userRepository.findAll().map { u -> toUserInfo(u) } return userRepository.findAll().map { it.toExtendedUserInfoDto() }
} }
fun getByEmail(email: String): org.gameyfin.app.users.entities.User? { fun getByEmail(email: String): org.gameyfin.app.users.entities.User? {
@@ -93,20 +96,22 @@ class UserService(
return userRepository.findByUsername(username) ?: throw UsernameNotFoundException("Unknown user '$username'") return userRepository.findByUsername(username) ?: throw UsernameNotFoundException("Unknown user '$username'")
} }
fun getUserInfo(): UserInfoDto { fun getUserInfo(): ExtendedUserInfoDto {
val auth = SecurityContextHolder.getContext().authentication val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
val principal = auth.principal val principal = auth.principal
if (principal is OidcUser) { if (principal is OidcUser) {
val oidcUser = org.gameyfin.app.users.entities.User(principal) val oidcUser = org.gameyfin.app.users.entities.User(principal)
val userInfoDto = toUserInfo(oidcUser) val user = userRepository.findByOidcProviderId(oidcUser.oidcProviderId!!)
?: throw UsernameNotFoundException("Unknown OIDC user with provider ID '${oidcUser.oidcProviderId}'")
val userInfoDto = user.toExtendedUserInfoDto()
userInfoDto.roles = roleService.extractGrantedAuthorities(principal.authorities) userInfoDto.roles = roleService.extractGrantedAuthorities(principal.authorities)
.mapNotNull { Role.Companion.safeValueOf(it.authority) } .mapNotNull { Role.safeValueOf(it.authority) }
return userInfoDto return userInfoDto
} }
val user = getByUsernameNonNull(auth.name) val user = getByUsernameNonNull(auth.name)
return toUserInfo(user) return user.toExtendedUserInfoDto()
} }
fun getAvatar(username: String): Image? { fun getAvatar(username: String): Image? {
@@ -124,7 +129,7 @@ class UserService(
val user = getByUsernameNonNull(username) val user = getByUsernameNonNull(username)
if (user.avatar == null) return if (user.avatar == null) return
imageService.deleteFile(user.avatar!!) imageService.deleteImageIfUnused(user.avatar!!)
user.avatar = null user.avatar = null
userRepository.save(user) userRepository.save(user)
@@ -158,7 +163,7 @@ class UserService(
RegistrationAttemptWithExistingEmailEvent( RegistrationAttemptWithExistingEmailEvent(
this, this,
it, it,
Utils.Companion.getBaseUrl() Utils.getBaseUrl()
) )
) )
return return
@@ -179,12 +184,12 @@ class UserService(
if (adminNeedsToApprove) { if (adminNeedsToApprove) {
eventPublisher.publishEvent(UserRegistrationWaitingForApprovalEvent(this, user)) eventPublisher.publishEvent(UserRegistrationWaitingForApprovalEvent(this, user))
} else { } else {
eventPublisher.publishEvent(AccountStatusChangedEvent(this, user, Utils.Companion.getBaseUrl())) eventPublisher.publishEvent(AccountStatusChangedEvent(this, user, Utils.getBaseUrl()))
} }
if (!user.emailConfirmed) { if (!user.emailConfirmed) {
val token = emailConfirmationService.generate(user) val token = emailConfirmationService.generate(user)
eventPublisher.publishEvent(EmailNeedsConfirmationEvent(this, token, Utils.Companion.getBaseUrl())) eventPublisher.publishEvent(EmailNeedsConfirmationEvent(this, token, Utils.getBaseUrl()))
} }
} }
@@ -222,7 +227,7 @@ class UserService(
user.email = it user.email = it
user.emailConfirmed = false user.emailConfirmed = false
val token = emailConfirmationService.generate(user) val token = emailConfirmationService.generate(user)
eventPublisher.publishEvent(EmailNeedsConfirmationEvent(this, token, Utils.Companion.getBaseUrl())) eventPublisher.publishEvent(EmailNeedsConfirmationEvent(this, token, Utils.getBaseUrl()))
} }
userRepository.save(user) userRepository.save(user)
@@ -238,7 +243,7 @@ class UserService(
return RoleAssignmentResult.NO_ROLES_PROVIDED return RoleAssignmentResult.NO_ROLES_PROVIDED
} }
val currentUser = SecurityContextHolder.getContext().authentication val currentUser = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
val targetUser = getByUsernameNonNull(username) val targetUser = getByUsernameNonNull(username)
if (!canManage(targetUser)) { if (!canManage(targetUser)) {
@@ -246,7 +251,7 @@ class UserService(
return RoleAssignmentResult.TARGET_POWER_LEVEL_TOO_HIGH return RoleAssignmentResult.TARGET_POWER_LEVEL_TOO_HIGH
} }
val newAssignedRoles = roleNames.mapNotNull { r -> Role.Companion.safeValueOf(r) } val newAssignedRoles = roleNames.mapNotNull { r -> Role.safeValueOf(r) }
val newAssignedRolesLevel = roleService.getHighestRole(newAssignedRoles).powerLevel val newAssignedRolesLevel = roleService.getHighestRole(newAssignedRoles).powerLevel
val currentUserLevel = roleService.getHighestRoleFromAuthorities(currentUser.authorities).powerLevel val currentUserLevel = roleService.getHighestRoleFromAuthorities(currentUser.authorities).powerLevel
@@ -266,7 +271,7 @@ class UserService(
} }
fun canManage(targetUser: org.gameyfin.app.users.entities.User): Boolean { fun canManage(targetUser: org.gameyfin.app.users.entities.User): Boolean {
val currentUser = SecurityContextHolder.getContext().authentication val currentUser = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
val currentUserLevel = roleService.getHighestRoleFromAuthorities(currentUser.authorities).powerLevel val currentUserLevel = roleService.getHighestRoleFromAuthorities(currentUser.authorities).powerLevel
val targetUserLevel = roleService.getHighestRole(targetUser.roles).powerLevel val targetUserLevel = roleService.getHighestRole(targetUser.roles).powerLevel
return currentUserLevel > targetUserLevel return currentUserLevel > targetUserLevel
@@ -276,29 +281,11 @@ class UserService(
val user = getByUsernameNonNull(username) val user = getByUsernameNonNull(username)
user.enabled = enabled user.enabled = enabled
userRepository.save(user) userRepository.save(user)
eventPublisher.publishEvent(AccountStatusChangedEvent(this, user, Utils.Companion.getBaseUrl())) eventPublisher.publishEvent(AccountStatusChangedEvent(this, user, Utils.getBaseUrl()))
} }
fun deleteUser(username: String) { fun deleteUser(username: String) {
val user = getByUsernameNonNull(username) val user = getByUsernameNonNull(username)
userRepository.delete(user) userRepository.delete(user)
eventPublisher.publishEvent(AccountDeletedEvent(this, user, Utils.Companion.getBaseUrl()))
}
fun toUserInfo(user: org.gameyfin.app.users.entities.User): UserInfoDto {
return UserInfoDto(
username = user.username,
email = user.email,
emailConfirmed = user.emailConfirmed,
enabled = user.enabled,
hasAvatar = user.avatar != null,
avatarId = user.avatar?.id,
managedBySso = user.oidcProviderId != null,
roles = user.roles
)
}
private fun toAuthorities(roles: Collection<Role>): List<GrantedAuthority> {
return roles.map { r -> SimpleGrantedAuthority(r.roleName) }
} }
} }
@@ -0,0 +1,15 @@
package org.gameyfin.app.users.dto
import org.gameyfin.app.core.Role
data class ExtendedUserInfoDto(
val id: Long,
val username: String,
val managedBySso: Boolean,
val email: String,
val emailConfirmed: Boolean,
val enabled: Boolean,
val hasAvatar: Boolean,
val avatarId: Long? = null,
var roles: List<Role>
)
@@ -1,14 +1,8 @@
package org.gameyfin.app.users.dto package org.gameyfin.app.users.dto
import org.gameyfin.app.core.Role
data class UserInfoDto( data class UserInfoDto(
val id: Long,
val username: String, val username: String,
val managedBySso: Boolean,
val email: String,
val emailConfirmed: Boolean,
val enabled: Boolean,
val hasAvatar: Boolean, val hasAvatar: Boolean,
val avatarId: Long? = null, val avatarId: Long? = null,
var roles: List<Role>
) )
@@ -1,11 +1,10 @@
package org.gameyfin.app.users.emailconfirmation package org.gameyfin.app.users.emailconfirmation
import com.vaadin.hilla.Endpoint import com.vaadin.hilla.Endpoint
import org.gameyfin.app.users.UserService
import jakarta.annotation.security.PermitAll import jakarta.annotation.security.PermitAll
import org.gameyfin.app.core.security.getCurrentAuth
import org.gameyfin.app.shared.token.TokenValidationResult import org.gameyfin.app.shared.token.TokenValidationResult
import org.springframework.security.core.Authentication import org.gameyfin.app.users.UserService
import org.springframework.security.core.context.SecurityContextHolder
@Endpoint @Endpoint
class EmailConfirmationEndpoint( class EmailConfirmationEndpoint(
@@ -20,7 +19,7 @@ class EmailConfirmationEndpoint(
@PermitAll @PermitAll
fun resendEmailConfirmation() { fun resendEmailConfirmation() {
val auth: Authentication = SecurityContextHolder.getContext().authentication val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
userService.getByUsername(auth.name)?.let { userService.getByUsername(auth.name)?.let {
emailConfirmationService.resendEmailConfirmation(it) emailConfirmationService.resendEmailConfirmation(it)
} }
@@ -1,14 +1,15 @@
package org.gameyfin.app.users.entities package org.gameyfin.app.users.entities
import org.gameyfin.app.games.entities.Image
import jakarta.persistence.* import jakarta.persistence.*
import org.gameyfin.app.core.Role import org.gameyfin.app.core.Role
import org.gameyfin.app.core.security.EncryptionConverter import org.gameyfin.app.core.security.EncryptionConverter
import org.gameyfin.app.games.entities.Image
import org.springframework.security.oauth2.core.oidc.user.OidcUser import org.springframework.security.oauth2.core.oidc.user.OidcUser
@Entity @Entity
@Table(name = "users") @Table(name = "users")
@EntityListeners(UserEntityListener::class)
class User( class User(
@Id @Id
@GeneratedValue(strategy = GenerationType.AUTO) @GeneratedValue(strategy = GenerationType.AUTO)
@@ -0,0 +1,36 @@
package org.gameyfin.app.users.entities
import jakarta.persistence.EntityManager
import jakarta.persistence.PostRemove
import jakarta.persistence.PreRemove
import org.gameyfin.app.core.Utils
import org.gameyfin.app.core.events.UserDeletedEvent
import org.gameyfin.app.requests.entities.GameRequest
import org.gameyfin.app.util.EntityManagerHolder
import org.gameyfin.app.util.EventPublisherHolder
class UserEntityListener {
@PreRemove
fun preRemove(user: User) {
val entityManager: EntityManager = EntityManagerHolder.getEntityManager()
// Remove user from all GameRequest voters and requester fields
val gameRequests = entityManager.createQuery(
"SELECT gr FROM GameRequest gr WHERE :user MEMBER OF gr.voters OR gr.requester = :user",
GameRequest::class.java
).setParameter("user", user).resultList
for (gr in gameRequests) {
gr.voters.remove(user)
if (gr.requester == user) gr.requester = null
entityManager.merge(gr)
}
}
// UserUpdateEvent triggered via {@link org.gameyfin.app.core.interceptors.EntityUpdateInterceptor#onFlushDirty}
@PostRemove
fun postRemove(user: User) {
EventPublisherHolder.publish(UserDeletedEvent(this, user, Utils.getBaseUrl()))
}
}
@@ -0,0 +1,35 @@
package org.gameyfin.app.users.extensions
import org.gameyfin.app.core.Role
import org.gameyfin.app.users.dto.ExtendedUserInfoDto
import org.gameyfin.app.users.dto.UserInfoDto
import org.gameyfin.app.users.entities.User
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
fun User.toUserInfoDto(): UserInfoDto {
return UserInfoDto(
id = this.id!!,
username = this.username,
hasAvatar = this.avatar != null,
avatarId = this.avatar?.id
)
}
fun User.toExtendedUserInfoDto(): ExtendedUserInfoDto {
return ExtendedUserInfoDto(
id = this.id!!,
username = this.username,
email = this.email,
emailConfirmed = this.emailConfirmed,
enabled = this.enabled,
hasAvatar = this.avatar != null,
avatarId = this.avatar?.id,
managedBySso = this.oidcProviderId != null,
roles = this.roles
)
}
fun Collection<Role>.toAuthorities(): List<GrantedAuthority> {
return this.map { r -> SimpleGrantedAuthority(r.roleName) }
}
@@ -3,6 +3,8 @@ package org.gameyfin.app.users.persistence
import org.gameyfin.app.core.Role import org.gameyfin.app.core.Role
import org.gameyfin.app.users.entities.User import org.gameyfin.app.users.entities.User
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
interface UserRepository : JpaRepository<User, Long> { interface UserRepository : JpaRepository<User, Long> {
fun existsByUsername(userName: String): Boolean fun existsByUsername(userName: String): Boolean
@@ -11,4 +13,7 @@ interface UserRepository : JpaRepository<User, Long> {
fun findByEmail(email: String): User? fun findByEmail(email: String): User?
fun findByOidcProviderId(oidcProviderId: String): User? fun findByOidcProviderId(oidcProviderId: String): User?
fun countUserByRolesContains(role: Role): Int fun countUserByRolesContains(role: Role): Int
@Query("SELECT CASE WHEN COUNT(u) > 0 THEN true ELSE false END FROM User u WHERE u.avatar.id = :imageId")
fun existsByAvatar(@Param("imageId") imageId: Long): Boolean
} }
@@ -1,8 +1,8 @@
package org.gameyfin.app.users.preferences package org.gameyfin.app.users.preferences
import org.gameyfin.app.users.UserService
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.security.core.context.SecurityContextHolder import org.gameyfin.app.core.security.getCurrentAuth
import org.gameyfin.app.users.UserService
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.io.Serializable import java.io.Serializable
@@ -135,7 +135,7 @@ class UserPreferencesService(
} }
private fun id(key: String): UserPreferenceKey { private fun id(key: String): UserPreferenceKey {
val auth = SecurityContextHolder.getContext().authentication val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
val user = userService.getByUsernameNonNull(auth.name) val user = userService.getByUsernameNonNull(auth.name)
return UserPreferenceKey(key, user.id!!) return UserPreferenceKey(key, user.id!!)
} }
@@ -1,18 +1,17 @@
package org.gameyfin.app.users.registration package org.gameyfin.app.users.registration
import org.gameyfin.app.core.Utils
import org.gameyfin.app.core.events.AccountStatusChangedEvent import org.gameyfin.app.core.events.AccountStatusChangedEvent
import org.gameyfin.app.core.events.UserInvitationEvent import org.gameyfin.app.core.events.UserInvitationEvent
import org.gameyfin.app.core.security.getCurrentAuth
import org.gameyfin.app.shared.token.TokenDto import org.gameyfin.app.shared.token.TokenDto
import org.gameyfin.app.shared.token.TokenRepository import org.gameyfin.app.shared.token.TokenRepository
import org.gameyfin.app.users.UserService
import org.gameyfin.app.core.Utils
import org.gameyfin.app.shared.token.TokenService import org.gameyfin.app.shared.token.TokenService
import org.gameyfin.app.shared.token.TokenType import org.gameyfin.app.shared.token.TokenType
import org.gameyfin.app.users.UserService
import org.gameyfin.app.users.dto.UserRegistrationDto import org.gameyfin.app.users.dto.UserRegistrationDto
import org.gameyfin.app.users.enums.UserInvitationAcceptanceResult import org.gameyfin.app.users.enums.UserInvitationAcceptanceResult
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@Service @Service
@@ -30,7 +29,7 @@ class InvitationService(
if (userService.existsByEmail(email)) if (userService.existsByEmail(email))
throw IllegalStateException("User with email ${Utils.Companion.maskEmail(email)} is already registered") throw IllegalStateException("User with email ${Utils.Companion.maskEmail(email)} is already registered")
val auth: Authentication = SecurityContextHolder.getContext().authentication val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
val user = userService.getByUsername(auth.name) ?: throw IllegalStateException("User not found") val user = userService.getByUsername(auth.name) ?: throw IllegalStateException("User not found")
val payload = mapOf(EMAIL_KEY to email) val payload = mapOf(EMAIL_KEY to email)
val token = super.generateWithPayload(user, payload) val token = super.generateWithPayload(user, payload)
@@ -45,7 +44,8 @@ class InvitationService(
} }
fun acceptInvitation(secret: String, registration: UserRegistrationDto): UserInvitationAcceptanceResult { fun acceptInvitation(secret: String, registration: UserRegistrationDto): UserInvitationAcceptanceResult {
val invitationToken = super.get(secret, TokenType.Invitation) ?: return UserInvitationAcceptanceResult.TOKEN_INVALID val invitationToken =
super.get(secret, TokenType.Invitation) ?: return UserInvitationAcceptanceResult.TOKEN_INVALID
val email = invitationToken.payload[EMAIL_KEY] ?: return UserInvitationAcceptanceResult.TOKEN_INVALID val email = invitationToken.payload[EMAIL_KEY] ?: return UserInvitationAcceptanceResult.TOKEN_INVALID
if (invitationToken.expired) return UserInvitationAcceptanceResult.TOKEN_EXPIRED if (invitationToken.expired) return UserInvitationAcceptanceResult.TOKEN_EXPIRED
@@ -0,0 +1,19 @@
package org.gameyfin.app.util
import jakarta.persistence.EntityManager
import org.springframework.context.ApplicationContext
import org.springframework.context.ApplicationContextAware
import org.springframework.stereotype.Component
@Component
object EntityManagerHolder : ApplicationContextAware {
private var entityManager: EntityManager? = null
override fun setApplicationContext(context: ApplicationContext) {
entityManager = context.getBean(EntityManager::class.java)
}
fun getEntityManager(): EntityManager {
return entityManager ?: throw IllegalStateException("EntityManager not set")
}
}
@@ -0,0 +1,30 @@
package org.gameyfin.db.h2
import java.sql.Connection
import java.sql.SQLException
/**
* H2 helper methods exposed as SQL ALIASes.
* <p>
* Kotlin implementation replacing the former Java version so a JDK (javac) is not
* required at runtime for defining aliases in migration scripts.
*/
object H2Aliases {
/**
* Renames a constraint if it exists, swallowing only H2 error code 90057 (constraint not found).
*/
@JvmStatic
@Throws(SQLException::class)
fun renameConstraintIfExists(conn: Connection, table: String, oldName: String, newName: String) {
conn.createStatement().use { st ->
try {
st.execute("ALTER TABLE $table RENAME CONSTRAINT $oldName TO $newName")
} catch (e: SQLException) {
if (e.errorCode != 90057) { // ignore only 'constraint not found'
throw e
}
}
}
}
}
+4 -1
View File
@@ -31,7 +31,7 @@ spring:
jpa: jpa:
# defer-datasource-initialization: true # defer-datasource-initialization: true
hibernate: hibernate:
ddl-auto: update ddl-auto: validate
open-in-view: true open-in-view: true
mustache: mustache:
check-template-location: false check-template-location: false
@@ -50,6 +50,9 @@ spring:
virtual.enabled: true virtual.enabled: true
mvc: mvc:
async.request-timeout: 0 async.request-timeout: 0
flyway:
baseline-on-migrate: true
baseline-version: 2.0.0
vaadin: vaadin:
# To improve the performance during development. # To improve the performance during development.
@@ -0,0 +1,359 @@
-- Flyway Migration: V2.0.0
-- Purpose: Initial schema creation for Gameyfin application.
/******************************************************************************************
* 1. Sequences (hi/lo allocation size = 50 for performance)
******************************************************************************************/
CREATE SEQUENCE COMPANY_SEQ
INCREMENT BY 50;
CREATE SEQUENCE DIRECTORY_MAPPING_SEQ
INCREMENT BY 50;
CREATE SEQUENCE GAME_FIELD_METADATA_SEQ
INCREMENT BY 50;
CREATE SEQUENCE GAME_FIELD_SOURCE_SEQ
INCREMENT BY 50;
CREATE SEQUENCE GAME_SEQ
INCREMENT BY 50;
CREATE SEQUENCE IMAGE_SEQ
INCREMENT BY 50;
CREATE SEQUENCE LIBRARY_SEQ
INCREMENT BY 50;
CREATE SEQUENCE USERS_SEQ
INCREMENT BY 50;
/******************************************************************************************
* 2. Tables
******************************************************************************************/
CREATE TABLE APP_CONFIG
(
"key" CHARACTER VARYING(255) NOT NULL
PRIMARY KEY,
"value" CHARACTER VARYING(255)
);
CREATE TABLE COMPANY
(
ID BIGINT NOT NULL
PRIMARY KEY,
NAME CHARACTER VARYING(255),
TYPE TINYINT,
CONSTRAINT UK4UCNYHR8I0URHWDUDFAHKOB9E
UNIQUE (NAME, TYPE),
CHECK ("TYPE" BETWEEN 0 AND 1)
);
CREATE TABLE DIRECTORY_MAPPING
(
ID BIGINT NOT NULL
PRIMARY KEY,
EXTERNAL_PATH CHARACTER VARYING(255),
INTERNAL_PATH CHARACTER VARYING(255)
CONSTRAINT UKJ3GSATFAHEWFOLSEAJ29O3KYT
UNIQUE
);
CREATE TABLE IMAGE
(
ID BIGINT NOT NULL
PRIMARY KEY,
CONTENT_ID CHARACTER VARYING(255),
CONTENT_LENGTH BIGINT,
MIME_TYPE CHARACTER VARYING(255),
ORIGINAL_URL CHARACTER VARYING(255),
TYPE TINYINT,
CHECK ("TYPE" BETWEEN 0 AND 3)
);
CREATE TABLE LIBRARY
(
ID BIGINT NOT NULL
PRIMARY KEY,
CREATED_AT TIMESTAMP WITH TIME ZONE NOT NULL,
NAME CHARACTER VARYING(255),
UPDATED_AT TIMESTAMP WITH TIME ZONE NOT NULL
);
CREATE TABLE GAME
(
ID BIGINT NOT NULL
PRIMARY KEY,
COMMENT CHARACTER LARGE OBJECT,
CREATED_AT TIMESTAMP WITH TIME ZONE NOT NULL,
CRITIC_RATING INTEGER,
DOWNLOAD_COUNT INTEGER,
FILE_SIZE BIGINT,
MATCH_CONFIRMED BOOLEAN,
PATH CHARACTER VARYING(255)
CONSTRAINT UK4WXN9FPXFQ8QXPSB7FY0O3NOA
UNIQUE,
RELEASE TIMESTAMP WITH TIME ZONE,
SUMMARY CHARACTER LARGE OBJECT,
TITLE CHARACTER VARYING(255),
UPDATED_AT TIMESTAMP WITH TIME ZONE NOT NULL,
USER_RATING INTEGER,
COVER_IMAGE_ID BIGINT
CONSTRAINT UK52RQ62FLPBNTI77BYKM7UAHKQ
UNIQUE,
HEADER_IMAGE_ID BIGINT
CONSTRAINT UK30B16LLQV54H40XIOGP7T9P35
UNIQUE,
LIBRARY_ID BIGINT,
CONSTRAINT FK6CVB43REAYSNYPI0XDY6HQTVF
FOREIGN KEY (COVER_IMAGE_ID) REFERENCES IMAGE,
CONSTRAINT FK8N86NDPGKMOO7YOLX6HL8N84G
FOREIGN KEY (HEADER_IMAGE_ID) REFERENCES IMAGE,
CONSTRAINT FKIUVR8XFB63T1K6T43EYYXVO2C
FOREIGN KEY (LIBRARY_ID) REFERENCES LIBRARY
);
CREATE TABLE GAME_DEVELOPERS
(
GAME_ID BIGINT NOT NULL,
DEVELOPERS_ID BIGINT NOT NULL,
CONSTRAINT FKB12PO9L2B9OJBAIHC82MM2QXB
FOREIGN KEY (DEVELOPERS_ID) REFERENCES COMPANY,
CONSTRAINT FKS4IJSVPIJ53DSL143XVRGBS09
FOREIGN KEY (GAME_ID) REFERENCES GAME
);
CREATE TABLE GAME_FEATURES
(
GAME_ID BIGINT NOT NULL,
FEATURES TINYINT,
CONSTRAINT FK63XLTCT60SCIMPM06K8BHBE4A
FOREIGN KEY (GAME_ID) REFERENCES GAME,
CHECK ("FEATURES" BETWEEN 0 AND 23)
);
CREATE TABLE GAME_GENRES
(
GAME_ID BIGINT NOT NULL,
GENRES TINYINT,
CONSTRAINT FKDTSX09YOPD98E0LUEWRUSJD9E
FOREIGN KEY (GAME_ID) REFERENCES GAME,
CHECK ("GENRES" BETWEEN 0 AND 25)
);
CREATE TABLE GAME_IMAGES
(
GAME_ID BIGINT NOT NULL,
IMAGES_ID BIGINT NOT NULL
CONSTRAINT UKBDE7M3TKHIEEYBINM2ED0B6X1
UNIQUE,
CONSTRAINT FK5YWV1DMXCM2VSQUEB7RHQ3JK9
FOREIGN KEY (IMAGES_ID) REFERENCES IMAGE,
CONSTRAINT FKOWCPUCV45OX8GT28TXGVHF1AA
FOREIGN KEY (GAME_ID) REFERENCES GAME
);
CREATE TABLE GAME_KEYWORDS
(
GAME_ID BIGINT NOT NULL,
KEYWORDS CHARACTER VARYING(255),
CONSTRAINT FKMVF6HNJ7ROMQQM2EX70A9NVAC
FOREIGN KEY (GAME_ID) REFERENCES GAME
);
CREATE TABLE GAME_PERSPECTIVES
(
GAME_ID BIGINT NOT NULL,
PERSPECTIVES TINYINT,
CONSTRAINT FKHUEENG29Y1GHBRDI5QHGUXH6E
FOREIGN KEY (GAME_ID) REFERENCES GAME,
CHECK ("PERSPECTIVES" BETWEEN 0 AND 7)
);
CREATE TABLE GAME_PUBLISHERS
(
GAME_ID BIGINT NOT NULL,
PUBLISHERS_ID BIGINT NOT NULL,
CONSTRAINT FK49R2KB61LIJ54BQB4VNTST97N
FOREIGN KEY (GAME_ID) REFERENCES GAME,
CONSTRAINT FKNGLD5ESGRBRH95J5BJF0HEF85
FOREIGN KEY (PUBLISHERS_ID) REFERENCES COMPANY
);
CREATE TABLE GAME_THEMES
(
GAME_ID BIGINT NOT NULL,
THEMES TINYINT,
CONSTRAINT FKRV351JXLIOY0A17Y5BBJJ6FW4
FOREIGN KEY (GAME_ID) REFERENCES GAME,
CHECK ("THEMES" BETWEEN 0 AND 22)
);
CREATE TABLE GAME_VIDEO_URLS
(
GAME_ID BIGINT NOT NULL,
VIDEO_URLS BINARY VARYING(255),
CONSTRAINT FKJKKWO8WDS086AS7B2KSLSVKM6
FOREIGN KEY (GAME_ID) REFERENCES GAME
);
CREATE TABLE LIBRARY_DIRECTORIES
(
LIBRARY_ID BIGINT NOT NULL,
DIRECTORIES_ID BIGINT NOT NULL
CONSTRAINT UKB5UM4CADBNC6UC8DVOMO81N5F
UNIQUE,
CONSTRAINT FKFNCKIU58I9L89MLXV388DY13B
FOREIGN KEY (LIBRARY_ID) REFERENCES LIBRARY,
CONSTRAINT FKJDXS58Q1IRTU0IDP6DXJHWAPM
FOREIGN KEY (DIRECTORIES_ID) REFERENCES DIRECTORY_MAPPING
);
CREATE TABLE LIBRARY_GAMES
(
LIBRARY_ID BIGINT NOT NULL,
GAMES_ID BIGINT NOT NULL
CONSTRAINT UK3E4VB9NQXPY27VMTA27GU5FY8
UNIQUE,
CONSTRAINT FK6C71EEDM0I2N1JXDE9BOBWG5M
FOREIGN KEY (LIBRARY_ID) REFERENCES LIBRARY,
CONSTRAINT FKDKKKES3DAY0WJ1QMV42KMMFDK
FOREIGN KEY (GAMES_ID) REFERENCES GAME
);
CREATE TABLE LIBRARY_UNMATCHED_PATHS
(
LIBRARY_ID BIGINT NOT NULL,
UNMATCHED_PATHS CHARACTER VARYING(255),
CONSTRAINT FKSJ51WC2LBNNXY0LKLWELI6VSB
FOREIGN KEY (LIBRARY_ID) REFERENCES LIBRARY
);
CREATE TABLE PLUGIN_CONFIG
(
"key" CHARACTER VARYING(255) NOT NULL,
PLUGIN_ID CHARACTER VARYING(255) NOT NULL,
"value" CHARACTER VARYING(255),
PRIMARY KEY ("key", PLUGIN_ID)
);
CREATE TABLE PLUGIN_MANAGEMENT_ENTRY
(
PLUGIN_ID CHARACTER VARYING(255) NOT NULL
PRIMARY KEY,
ENABLED BOOLEAN NOT NULL,
PRIORITY INTEGER NOT NULL,
TRUST_LEVEL TINYINT,
CHECK ("TRUST_LEVEL" BETWEEN 0 AND 4)
);
CREATE TABLE GAME_ORIGINAL_IDS
(
GAME_ID BIGINT NOT NULL,
ORIGINAL_IDS CHARACTER VARYING(255),
ORIGINAL_IDS_KEY CHARACTER VARYING(255) NOT NULL,
PRIMARY KEY (GAME_ID, ORIGINAL_IDS_KEY),
CONSTRAINT FK1CSD5QD7VJT7BTTA3G7HGYBUX
FOREIGN KEY (GAME_ID) REFERENCES GAME,
CONSTRAINT FKMT0XWLPWPU9NP0Q289JBAHJRY
FOREIGN KEY (ORIGINAL_IDS_KEY) REFERENCES PLUGIN_MANAGEMENT_ENTRY
);
CREATE TABLE USERS
(
ID BIGINT NOT NULL
PRIMARY KEY,
EMAIL CHARACTER VARYING(255)
CONSTRAINT UK6DOTKOTT2KJSP8VW4D0M25FB7
UNIQUE,
EMAIL_CONFIRMED BOOLEAN NOT NULL,
ENABLED BOOLEAN NOT NULL,
OIDC_PROVIDER_ID CHARACTER VARYING(255),
PASSWORD CHARACTER VARYING(255),
USERNAME CHARACTER VARYING(255)
CONSTRAINT UKR43AF9AP4EDM43MMTQ01ODDJ6
UNIQUE,
AVATAR_ID BIGINT
CONSTRAINT UKRSULCN2GYNJY3CDDPWMOSV881
UNIQUE,
CONSTRAINT FK19LFLPG5SEIS4DWRM2LVJLXFV
FOREIGN KEY (AVATAR_ID) REFERENCES IMAGE
);
CREATE TABLE GAME_FIELD_SOURCE
(
DTYPE CHARACTER VARYING(31) NOT NULL,
ID BIGINT NOT NULL
PRIMARY KEY,
PLUGIN_PLUGIN_ID CHARACTER VARYING(255),
USER_ID BIGINT,
CONSTRAINT FKNJC4QSS5APFHTPWP42OAEAL5G
FOREIGN KEY (PLUGIN_PLUGIN_ID) REFERENCES PLUGIN_MANAGEMENT_ENTRY,
CONSTRAINT FKSR1BGTX5XJVMAL7FEFGL982TP
FOREIGN KEY (USER_ID) REFERENCES USERS
);
CREATE TABLE GAME_FIELD_METADATA
(
ID BIGINT NOT NULL
PRIMARY KEY,
UPDATED_AT TIMESTAMP WITH TIME ZONE,
SOURCE_ID BIGINT
CONSTRAINT UKHW6U2Y9FLWPTI57QB7K0P27BL
UNIQUE,
CONSTRAINT FKQ4RC409TP8FUBTTM733PMJD8F
FOREIGN KEY (SOURCE_ID) REFERENCES GAME_FIELD_SOURCE
);
CREATE TABLE GAME_FIELDS
(
GAME_ID BIGINT NOT NULL,
FIELDS_ID BIGINT NOT NULL
CONSTRAINT UK1L5OAH0UOOUV4V5A9P0PAK77X
UNIQUE,
FIELDS_KEY CHARACTER VARYING(255) NOT NULL,
PRIMARY KEY (GAME_ID, FIELDS_KEY),
CONSTRAINT FKLNEPI7YWCI86YH21KO9WD9PYF
FOREIGN KEY (GAME_ID) REFERENCES GAME,
CONSTRAINT FKT8FLOFDAPX5M746S5LW54C5B3
FOREIGN KEY (FIELDS_ID) REFERENCES GAME_FIELD_METADATA
);
CREATE TABLE TOKEN
(
SECRET CHARACTER VARYING(255) NOT NULL
PRIMARY KEY,
CREATED_ON TIMESTAMP WITH TIME ZONE,
PAYLOAD CHARACTER VARYING(255),
TYPE CHARACTER VARYING(255),
CREATOR_ID BIGINT,
CONSTRAINT FKGHOIALAPTI5JFEJ506JBB1O8Y
FOREIGN KEY (CREATOR_ID) REFERENCES USERS
ON DELETE CASCADE
);
CREATE TABLE USER_PREFERENCE
(
"key" CHARACTER VARYING(255) NOT NULL,
USER_ID BIGINT NOT NULL,
"value" CHARACTER VARYING(255),
PRIMARY KEY ("key", USER_ID)
);
CREATE TABLE USER_ROLES
(
USER_ID BIGINT NOT NULL,
ROLES ENUM ('ADMIN', 'SUPERADMIN', 'USER'),
CONSTRAINT FKHFH9DX7W3UBF1CO1VDEV94G3F
FOREIGN KEY (USER_ID) REFERENCES USERS
);
CREATE TABLE JOB_RUN_RESULT
(
ID BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY,
JOB_NAME VARCHAR(255),
STARTED_AT TIMESTAMP,
FINISHED_AT TIMESTAMP,
STATUS VARCHAR(255),
MESSAGE VARCHAR(255)
);
@@ -0,0 +1,97 @@
-- Flyway Migration: V2.1.0 (Part 1)
-- Purpose:
-- 1. Drop unique constraints on GAME.COVER_IMAGE_ID and GAME.HEADER_IMAGE_ID
-- 2. Rename all remaining UK*/FK* constraints to human-readable names (idempotent)
-- This version has been updated to be resilient: if a hashed constraint name
-- does not exist (e.g. on legacy schemas where Hibernate never created it, or
-- on future fresh installs where the friendly name may already be present),
-- the rename attempt is ignored instead of failing the migration.
/******************************************************************************************
* Helper: Idempotent constraint rename for H2
* (Updated) Use precompiled Java class instead of inline Java to avoid needing 'javac' at runtime.
* Class: org.gameyfin.db.h2.H2Aliases.renameConstraintIfExists(Connection,String,String,String)
******************************************************************************************/
CREATE ALIAS IF NOT EXISTS RENAME_CONSTRAINT_IF_EXISTS FOR "org.gameyfin.db.h2.H2Aliases.renameConstraintIfExists";
/******************************************************************************************
* 1. Drop the two unwanted unique constraints on GAME
******************************************************************************************/
ALTER TABLE GAME
DROP CONSTRAINT IF EXISTS UK52RQ62FLPBNTI77BYKM7UAHKQ; -- COVER_IMAGE_ID unique
ALTER TABLE GAME
DROP CONSTRAINT IF EXISTS UK30B16LLQV54H40XIOGP7T9P35;
-- HEADER_IMAGE_ID unique
/******************************************************************************************
* 2. Rename remaining UNIQUE constraints (UK*) (tolerant)
******************************************************************************************/
CALL RENAME_CONSTRAINT_IF_EXISTS('COMPANY', 'UK4UCNYHR8I0URHWDUDFAHKOB9E', 'UQ_COMPANY_NAME_TYPE');
CALL RENAME_CONSTRAINT_IF_EXISTS('DIRECTORY_MAPPING', 'UKJ3GSATFAHEWFOLSEAJ29O3KYT',
'UQ_DIRECTORY_MAPPING_INTERNAL_PATH');
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME', 'UK4WXN9FPXFQ8QXPSB7FY0O3NOA', 'UQ_GAME_PATH');
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_IMAGES', 'UKBDE7M3TKHIEEYBINM2ED0B6X1', 'UQ_GAME_IMAGES_IMAGE_ID');
CALL RENAME_CONSTRAINT_IF_EXISTS('LIBRARY_DIRECTORIES', 'UKB5UM4CADBNC6UC8DVOMO81N5F',
'UQ_LIBRARY_DIRECTORIES_DIRECTORY_ID');
CALL RENAME_CONSTRAINT_IF_EXISTS('LIBRARY_GAMES', 'UK3E4VB9NQXPY27VMTA27GU5FY8', 'UQ_LIBRARY_GAMES_GAME_ID');
CALL RENAME_CONSTRAINT_IF_EXISTS('USERS', 'UK6DOTKOTT2KJSP8VW4D0M25FB7', 'UQ_USERS_EMAIL');
CALL RENAME_CONSTRAINT_IF_EXISTS('USERS', 'UKR43AF9AP4EDM43MMTQ01ODDJ6', 'UQ_USERS_USERNAME');
CALL RENAME_CONSTRAINT_IF_EXISTS('USERS', 'UKRSULCN2GYNJY3CDDPWMOSV881', 'UQ_USERS_AVATAR_ID');
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_FIELD_METADATA', 'UKHW6U2Y9FLWPTI57QB7K0P27BL',
'UQ_GAME_FIELD_METADATA_SOURCE_ID');
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_FIELDS', 'UK1L5OAH0UOOUV4V5A9P0PAK77X', 'UQ_GAME_FIELDS_FIELD_METADATA_ID');
/******************************************************************************************
* 3. Rename FOREIGN KEY constraints (FK*) (tolerant)
******************************************************************************************/
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME', 'FK6CVB43REAYSNYPI0XDY6HQTVF', 'FK_GAME_COVER_IMAGE');
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME', 'FK8N86NDPGKMOO7YOLX6HL8N84G', 'FK_GAME_HEADER_IMAGE');
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME', 'FKIUVR8XFB63T1K6T43EYYXVO2C', 'FK_GAME_LIBRARY');
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_DEVELOPERS', 'FKB12PO9L2B9OJBAIHC82MM2QXB', 'FK_GAME_DEVELOPERS_COMPANY');
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_DEVELOPERS', 'FKS4IJSVPIJ53DSL143XVRGBS09', 'FK_GAME_DEVELOPERS_GAME');
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_FEATURES', 'FK63XLTCT60SCIMPM06K8BHBE4A', 'FK_GAME_FEATURES_GAME');
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_GENRES', 'FKDTSX09YOPD98E0LUEWRUSJD9E', 'FK_GAME_GENRES_GAME');
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_IMAGES', 'FK5YWV1DMXCM2VSQUEB7RHQ3JK9', 'FK_GAME_IMAGES_IMAGE');
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_IMAGES', 'FKOWCPUCV45OX8GT28TXGVHF1AA', 'FK_GAME_IMAGES_GAME');
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_KEYWORDS', 'FKMVF6HNJ7ROMQQM2EX70A9NVAC', 'FK_GAME_KEYWORDS_GAME');
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_PERSPECTIVES', 'FKHUEENG29Y1GHBRDI5QHGUXH6E', 'FK_GAME_PERSPECTIVES_GAME');
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_PUBLISHERS', 'FK49R2KB61LIJ54BQB4VNTST97N', 'FK_GAME_PUBLISHERS_GAME');
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_PUBLISHERS', 'FKNGLD5ESGRBRH95J5BJF0HEF85', 'FK_GAME_PUBLISHERS_COMPANY');
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_THEMES', 'FKRV351JXLIOY0A17Y5BBJJ6FW4', 'FK_GAME_THEMES_GAME');
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_VIDEO_URLS', 'FKJKKWO8WDS086AS7B2KSLSVKM6', 'FK_GAME_VIDEO_URLS_GAME');
CALL RENAME_CONSTRAINT_IF_EXISTS('LIBRARY_DIRECTORIES', 'FKFNCKIU58I9L89MLXV388DY13B',
'FK_LIBRARY_DIRECTORIES_LIBRARY');
CALL RENAME_CONSTRAINT_IF_EXISTS('LIBRARY_DIRECTORIES', 'FKJDXS58Q1IRTU0IDP6DXJHWAPM',
'FK_LIBRARY_DIRECTORIES_DIRECTORY_MAPPING');
CALL RENAME_CONSTRAINT_IF_EXISTS('LIBRARY_GAMES', 'FK6C71EEDM0I2N1JXDE9BOBWG5M', 'FK_LIBRARY_GAMES_LIBRARY');
CALL RENAME_CONSTRAINT_IF_EXISTS('LIBRARY_GAMES', 'FKDKKKES3DAY0WJ1QMV42KMMFDK', 'FK_LIBRARY_GAMES_GAME');
CALL RENAME_CONSTRAINT_IF_EXISTS('LIBRARY_UNMATCHED_PATHS', 'FKSJ51WC2LBNNXY0LKLWELI6VSB',
'FK_LIBRARY_UNMATCHED_PATHS_LIBRARY');
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_ORIGINAL_IDS', 'FK1CSD5QD7VJT7BTTA3G7HGYBUX', 'FK_GAME_ORIGINAL_IDS_GAME');
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_ORIGINAL_IDS', 'FKMT0XWLPWPU9NP0Q289JBAHJRY', 'FK_GAME_ORIGINAL_IDS_PLUGIN');
CALL RENAME_CONSTRAINT_IF_EXISTS('USERS', 'FK19LFLPG5SEIS4DWRM2LVJLXFV', 'FK_USERS_AVATAR_IMAGE');
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_FIELD_SOURCE', 'FKNJC4QSS5APFHTPWP42OAEAL5G', 'FK_GAME_FIELD_SOURCE_PLUGIN');
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_FIELD_SOURCE', 'FKSR1BGTX5XJVMAL7FEFGL982TP', 'FK_GAME_FIELD_SOURCE_USER');
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_FIELD_METADATA', 'FKQ4RC409TP8FUBTTM733PMJD8F', 'FK_GAME_FIELD_METADATA_SOURCE');
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_FIELDS', 'FKLNEPI7YWCI86YH21KO9WD9PYF', 'FK_GAME_FIELDS_GAME');
CALL RENAME_CONSTRAINT_IF_EXISTS('GAME_FIELDS', 'FKT8FLOFDAPX5M746S5LW54C5B3', 'FK_GAME_FIELDS_METADATA');
CALL RENAME_CONSTRAINT_IF_EXISTS('TOKEN', 'FKGHOIALAPTI5JFEJ506JBB1O8Y', 'FK_TOKEN_CREATOR_USER');
CALL RENAME_CONSTRAINT_IF_EXISTS('USER_ROLES', 'FKHFH9DX7W3UBF1CO1VDEV94G3F', 'FK_USER_ROLES_USER');
-- End of migration
@@ -0,0 +1,41 @@
-- Flyway Migration: V2.1.0 (Part 2)
-- Purpose:
-- 1. Create tables for the game requests feature
/******************************************************************************************
* 1. Create new sequence
******************************************************************************************/
CREATE SEQUENCE IF NOT EXISTS GAME_REQUEST_SEQ
INCREMENT BY 50;
/******************************************************************************************
* 2. Create new tables
******************************************************************************************/
CREATE TABLE IF NOT EXISTS GAME_REQUEST
(
ID BIGINT NOT NULL PRIMARY KEY,
TITLE VARCHAR(255) NOT NULL,
RELEASE TIMESTAMP NOT NULL,
STATUS VARCHAR(255) NOT NULL,
REQUESTER_ID BIGINT,
LINKED_GAME_ID BIGINT,
CREATED_AT TIMESTAMP NOT NULL,
UPDATED_AT TIMESTAMP NOT NULL,
CONSTRAINT FK_GAMEREQUEST_ON_REQUESTER
FOREIGN KEY (REQUESTER_ID) REFERENCES USERS
ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS GAME_REQUEST_VOTERS
(
GAME_REQUEST_ID BIGINT NOT NULL,
VOTERS_ID BIGINT NOT NULL,
PRIMARY KEY (GAME_REQUEST_ID, VOTERS_ID),
CONSTRAINT FK_GAMREQVOT_ON_GAME_REQUEST
FOREIGN KEY (GAME_REQUEST_ID) REFERENCES GAME_REQUEST
ON DELETE CASCADE,
CONSTRAINT FK_GAMREQVOT_ON_USER
FOREIGN KEY (VOTERS_ID) REFERENCES USERS
);
-- End of migration
+23 -2
View File
@@ -82,6 +82,8 @@ const themeOptions = {
}; };
const hasExportedWebComponents = existsSync(path.resolve(frontendFolder, 'web-component.html')); const hasExportedWebComponents = existsSync(path.resolve(frontendFolder, 'web-component.html'));
const commercialBannerComponent = path.resolve(frontendFolder, settings.generatedFolder, 'commercial-banner.js');
const hasCommercialBanner = existsSync(commercialBannerComponent);
const target = ['safari15', 'es2022']; const target = ['safari15', 'es2022'];
@@ -259,6 +261,10 @@ function statsExtracterPlugin(): PluginOption {
); );
frontendFiles[`index.ts`] = createHash('sha256').update(fileBuffer, 'utf8').digest('hex'); frontendFiles[`index.ts`] = createHash('sha256').update(fileBuffer, 'utf8').digest('hex');
} }
if (hasCommercialBanner) {
const fileBuffer = readFileSync(commercialBannerComponent, { encoding: 'utf-8' }).replace(/\r\n/g, '\n');
frontendFiles[settings.generatedFolder + '/commercial-banner.js'] = createHash('sha256').update(fileBuffer, 'utf8').digest('hex');
}
const themeJsonContents: Record<string, string> = {}; const themeJsonContents: Record<string, string> = {};
const themesFolder = path.resolve(jarResourcesFolder, 'themes'); const themesFolder = path.resolve(jarResourcesFolder, 'themes');
@@ -576,6 +582,7 @@ function preserveUsageStats() {
export const vaadinConfig: UserConfigFn = (env) => { export const vaadinConfig: UserConfigFn = (env) => {
const devMode = env.mode === 'development'; const devMode = env.mode === 'development';
const productionMode = !devMode && !devBundle const productionMode = !devMode && !devBundle
const commercialBanner = productionMode && hasCommercialBanner;
if (devMode && process.env.watchDogPort) { if (devMode && process.env.watchDogPort) {
// Open a connection with the Java dev-mode handler in order to finish // Open a connection with the Java dev-mode handler in order to finish
@@ -734,14 +741,21 @@ export const vaadinConfig: UserConfigFn = (env) => {
if (path !== '/web-component.html') { if (path !== '/web-component.html') {
return; return;
} }
const scripts = [
return [
{ {
tag: 'script', tag: 'script',
attrs: { type: 'module', src: `/generated/vaadin-web-component.ts` }, attrs: { type: 'module', src: `/generated/vaadin-web-component.ts` },
injectTo: 'head' injectTo: 'head'
} }
]; ];
if (commercialBanner) {
scripts.push({
tag: 'script',
attrs: { type: 'module', src: '/generated/commercial-banner.js' },
injectTo: 'head'
});
}
return scripts;
} }
} }
}, },
@@ -768,6 +782,13 @@ export const vaadinConfig: UserConfigFn = (env) => {
attrs: { type: 'module', src: '/generated/vaadin.ts' }, attrs: { type: 'module', src: '/generated/vaadin.ts' },
injectTo: 'head' injectTo: 'head'
}); });
if (commercialBanner) {
scripts.push({
tag: 'script',
attrs: { type: 'module', src: '/generated/commercial-banner.js' },
injectTo: 'head'
});
}
return scripts; return scripts;
} }
} }
+1 -1
View File
@@ -6,7 +6,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
import java.nio.file.Files import java.nio.file.Files
group = "org.gameyfin" group = "org.gameyfin"
version = "2.0.1" version = "2.1.0-preview"
allprojects { allprojects {
repositories { repositories {
+5 -5
View File
@@ -3,12 +3,12 @@ org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m
org.gradle.parallel=true org.gradle.parallel=true
org.gradle.caching=true org.gradle.caching=true
# Plugin versions # Plugin versions
kotlinVersion=2.2.0 kotlinVersion=2.2.20
kspVersion=2.2.0-2.0.2 kspVersion=2.2.20-2.0.3
vaadinVersion=24.8.3 vaadinVersion=24.9.0
springBootVersion=3.5.3 springBootVersion=3.5.6
springCloudVersion=2025.0.0 springCloudVersion=2025.0.0
springDependencyManagementVersion=1.1.7 springDependencyManagementVersion=1.1.7
# Dependency versions # Dependency versions
pf4jVersion=3.13.0 pf4jVersion=3.13.0
pf4jKspVersion=2.2.0-1.0.3 pf4jKspVersion=2.2.20-1.0.3