Implement request system (#643)

This commit is contained in:
Simon
2025-09-03 10:26:57 +02:00
committed by GitHub
80 changed files with 2436 additions and 1482 deletions
+20 -1
View File
@@ -19,7 +19,15 @@ runs:
username: ${{ inputs.ghcr_username }} username: ${{ inputs.ghcr_username }}
password: ${{ inputs.ghcr_token }} password: ${{ inputs.ghcr_token }}
- name: Build and push Docker image - name: Prepare Ubuntu tags
id: ubuntu_tags
shell: bash
run: |
TAGS="${{ inputs.tags }}"
UBUNTU_TAGS=$(echo "$TAGS" | awk -F, '{for(i=1;i<=NF;i++){split($i,a,":"); printf "%s:%s-ubuntu", a[1], a[2]; if(i<NF) printf ","}}')
echo "ubuntu_tags=$UBUNTU_TAGS" >> $GITHUB_OUTPUT
- name: Build and push Docker image (Alpine)
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: ${{ inputs.context }} context: ${{ inputs.context }}
@@ -30,6 +38,17 @@ runs:
cache-from: type=gha cache-from: type=gha
cache-to: type=gha cache-to: type=gha
- name: Build and push Docker image (Ubuntu)
uses: docker/build-push-action@v5
with:
context: ${{ inputs.context }}
file: docker/Dockerfile.ubuntu
platforms: ${{ inputs.platforms }}
push: true
tags: ${{ steps.ubuntu_tags.outputs.ubuntu_tags }}
cache-from: type=gha
cache-to: type=gha
inputs: inputs:
dockerhub_username: dockerhub_username:
required: true required: true
+2 -2
View File
@@ -18,10 +18,10 @@ jobs:
packages: write packages: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '21' java-version: '21'
+2 -2
View File
@@ -12,10 +12,10 @@ jobs:
packages: write packages: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '21' java-version: '21'
+9 -9
View File
@@ -23,7 +23,7 @@ jobs:
release_version: ${{ steps.get_version.outputs.release_version }} release_version: ${{ steps.get_version.outputs.release_version }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -64,17 +64,17 @@ jobs:
packages: write packages: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Download modified files - name: Download modified files
uses: actions/download-artifact@v4 uses: actions/download-artifact@v5
with: with:
name: modified-files name: modified-files
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '21' java-version: '21'
@@ -119,17 +119,17 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Download modified files - name: Download modified files
uses: actions/download-artifact@v4 uses: actions/download-artifact@v5
with: with:
name: modified-files name: modified-files
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '21' java-version: '21'
@@ -150,12 +150,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Download modified files - name: Download modified files
uses: actions/download-artifact@v4 uses: actions/download-artifact@v5
with: with:
name: modified-files name: modified-files
@@ -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>
+798 -1124
View File
File diff suppressed because it is too large Load Diff
+110 -110
View File
@@ -1,6 +1,6 @@
{ {
"name": "gameyfin", "name": "gameyfin",
"version": "2.0.0", "version": "2.0.1",
"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.8.6",
"@vaadin/common-frontend": "0.0.19", "@vaadin/common-frontend": "0.0.19",
"@vaadin/hilla-file-router": "24.8.2", "@vaadin/hilla-file-router": "24.8.7",
"@vaadin/hilla-frontend": "24.8.2", "@vaadin/hilla-frontend": "24.8.7",
"@vaadin/hilla-lit-form": "24.8.2", "@vaadin/hilla-lit-form": "24.8.7",
"@vaadin/hilla-react-auth": "24.8.2", "@vaadin/hilla-react-auth": "24.8.7",
"@vaadin/hilla-react-crud": "24.8.2", "@vaadin/hilla-react-crud": "24.8.7",
"@vaadin/hilla-react-form": "24.8.2", "@vaadin/hilla-react-form": "24.8.7",
"@vaadin/hilla-react-i18n": "24.8.2", "@vaadin/hilla-react-i18n": "24.8.7",
"@vaadin/hilla-react-signals": "24.8.2", "@vaadin/hilla-react-signals": "24.8.7",
"@vaadin/polymer-legacy-adapter": "24.8.3", "@vaadin/polymer-legacy-adapter": "24.8.6",
"@vaadin/react-components": "24.8.3", "@vaadin/react-components": "24.8.6",
"@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.8.6",
"@vaadin/vaadin-material-styles": "24.8.3", "@vaadin/vaadin-material-styles": "24.8.6",
"@vaadin/vaadin-themable-mixin": "24.8.3", "@vaadin/vaadin-themable-mixin": "24.8.6",
"@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.8.7",
"@vaadin/hilla-generator-core": "24.8.2", "@vaadin/hilla-generator-core": "24.8.7",
"@vaadin/hilla-generator-plugin-backbone": "24.8.2", "@vaadin/hilla-generator-plugin-backbone": "24.8.7",
"@vaadin/hilla-generator-plugin-barrel": "24.8.2", "@vaadin/hilla-generator-plugin-barrel": "24.8.7",
"@vaadin/hilla-generator-plugin-client": "24.8.2", "@vaadin/hilla-generator-plugin-client": "24.8.7",
"@vaadin/hilla-generator-plugin-model": "24.8.2", "@vaadin/hilla-generator-plugin-model": "24.8.7",
"@vaadin/hilla-generator-plugin-push": "24.8.2", "@vaadin/hilla-generator-plugin-push": "24.8.7",
"@vaadin/hilla-generator-plugin-signals": "24.8.2", "@vaadin/hilla-generator-plugin-signals": "24.8.7",
"@vaadin/hilla-generator-plugin-subtypes": "24.8.2", "@vaadin/hilla-generator-plugin-subtypes": "24.8.7",
"@vaadin/hilla-generator-plugin-transfertypes": "24.8.2", "@vaadin/hilla-generator-plugin-transfertypes": "24.8.7",
"@vaadin/hilla-generator-utils": "24.8.2", "@vaadin/hilla-generator-utils": "24.8.7",
"@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",
@@ -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.8.6",
"@vaadin/accordion": "24.8.3", "@vaadin/accordion": "24.8.6",
"@vaadin/app-layout": "24.8.3", "@vaadin/app-layout": "24.8.6",
"@vaadin/avatar": "24.8.3", "@vaadin/avatar": "24.8.6",
"@vaadin/avatar-group": "24.8.3", "@vaadin/avatar-group": "24.8.6",
"@vaadin/button": "24.8.3", "@vaadin/button": "24.8.6",
"@vaadin/card": "24.8.3", "@vaadin/card": "24.8.6",
"@vaadin/checkbox": "24.8.3", "@vaadin/checkbox": "24.8.6",
"@vaadin/checkbox-group": "24.8.3", "@vaadin/checkbox-group": "24.8.6",
"@vaadin/combo-box": "24.8.3", "@vaadin/combo-box": "24.8.6",
"@vaadin/component-base": "24.8.3", "@vaadin/component-base": "24.8.6",
"@vaadin/confirm-dialog": "24.8.3", "@vaadin/confirm-dialog": "24.8.6",
"@vaadin/context-menu": "24.8.3", "@vaadin/context-menu": "24.8.6",
"@vaadin/custom-field": "24.8.3", "@vaadin/custom-field": "24.8.6",
"@vaadin/date-picker": "24.8.3", "@vaadin/date-picker": "24.8.6",
"@vaadin/date-time-picker": "24.8.3", "@vaadin/date-time-picker": "24.8.6",
"@vaadin/details": "24.8.3", "@vaadin/details": "24.8.6",
"@vaadin/dialog": "24.8.3", "@vaadin/dialog": "24.8.6",
"@vaadin/email-field": "24.8.3", "@vaadin/email-field": "24.8.6",
"@vaadin/field-base": "24.8.3", "@vaadin/field-base": "24.8.6",
"@vaadin/field-highlighter": "24.8.3", "@vaadin/field-highlighter": "24.8.6",
"@vaadin/form-layout": "24.8.3", "@vaadin/form-layout": "24.8.6",
"@vaadin/grid": "24.8.3", "@vaadin/grid": "24.8.6",
"@vaadin/horizontal-layout": "24.8.3", "@vaadin/horizontal-layout": "24.8.6",
"@vaadin/icon": "24.8.3", "@vaadin/icon": "24.8.6",
"@vaadin/icons": "24.8.3", "@vaadin/icons": "24.8.6",
"@vaadin/input-container": "24.8.3", "@vaadin/input-container": "24.8.6",
"@vaadin/integer-field": "24.8.3", "@vaadin/integer-field": "24.8.6",
"@vaadin/item": "24.8.3", "@vaadin/item": "24.8.6",
"@vaadin/list-box": "24.8.3", "@vaadin/list-box": "24.8.6",
"@vaadin/lit-renderer": "24.8.3", "@vaadin/lit-renderer": "24.8.6",
"@vaadin/login": "24.8.3", "@vaadin/login": "24.8.6",
"@vaadin/markdown": "24.8.3", "@vaadin/markdown": "24.8.6",
"@vaadin/master-detail-layout": "24.8.3", "@vaadin/master-detail-layout": "24.8.6",
"@vaadin/menu-bar": "24.8.3", "@vaadin/menu-bar": "24.8.6",
"@vaadin/message-input": "24.8.3", "@vaadin/message-input": "24.8.6",
"@vaadin/message-list": "24.8.3", "@vaadin/message-list": "24.8.6",
"@vaadin/multi-select-combo-box": "24.8.3", "@vaadin/multi-select-combo-box": "24.8.6",
"@vaadin/notification": "24.8.3", "@vaadin/notification": "24.8.6",
"@vaadin/number-field": "24.8.3", "@vaadin/number-field": "24.8.6",
"@vaadin/overlay": "24.8.3", "@vaadin/overlay": "24.8.6",
"@vaadin/password-field": "24.8.3", "@vaadin/password-field": "24.8.6",
"@vaadin/popover": "24.8.3", "@vaadin/popover": "24.8.6",
"@vaadin/progress-bar": "24.8.3", "@vaadin/progress-bar": "24.8.6",
"@vaadin/radio-group": "24.8.3", "@vaadin/radio-group": "24.8.6",
"@vaadin/scroller": "24.8.3", "@vaadin/scroller": "24.8.6",
"@vaadin/select": "24.8.3", "@vaadin/select": "24.8.6",
"@vaadin/side-nav": "24.8.3", "@vaadin/side-nav": "24.8.6",
"@vaadin/split-layout": "24.8.3", "@vaadin/split-layout": "24.8.6",
"@vaadin/tabs": "24.8.3", "@vaadin/tabs": "24.8.6",
"@vaadin/tabsheet": "24.8.3", "@vaadin/tabsheet": "24.8.6",
"@vaadin/text-area": "24.8.3", "@vaadin/text-area": "24.8.6",
"@vaadin/text-field": "24.8.3", "@vaadin/text-field": "24.8.6",
"@vaadin/time-picker": "24.8.3", "@vaadin/time-picker": "24.8.6",
"@vaadin/tooltip": "24.8.3", "@vaadin/tooltip": "24.8.6",
"@vaadin/upload": "24.8.3", "@vaadin/upload": "24.8.6",
"@vaadin/router": "2.0.0", "@vaadin/router": "2.0.0",
"@vaadin/vertical-layout": "24.8.3", "@vaadin/vertical-layout": "24.8.6",
"@vaadin/virtual-list": "24.8.3" "@vaadin/virtual-list": "24.8.6"
}, },
"vaadin": { "vaadin": {
"dependencies": { "dependencies": {
"@polymer/polymer": "3.5.2", "@polymer/polymer": "3.5.2",
"@vaadin/bundles": "24.8.3", "@vaadin/bundles": "24.8.6",
"@vaadin/common-frontend": "0.0.19", "@vaadin/common-frontend": "0.0.19",
"@vaadin/hilla-file-router": "24.8.2", "@vaadin/hilla-file-router": "24.8.7",
"@vaadin/hilla-frontend": "24.8.2", "@vaadin/hilla-frontend": "24.8.7",
"@vaadin/hilla-lit-form": "24.8.2", "@vaadin/hilla-lit-form": "24.8.7",
"@vaadin/hilla-react-auth": "24.8.2", "@vaadin/hilla-react-auth": "24.8.7",
"@vaadin/hilla-react-crud": "24.8.2", "@vaadin/hilla-react-crud": "24.8.7",
"@vaadin/hilla-react-form": "24.8.2", "@vaadin/hilla-react-form": "24.8.7",
"@vaadin/hilla-react-i18n": "24.8.2", "@vaadin/hilla-react-i18n": "24.8.7",
"@vaadin/hilla-react-signals": "24.8.2", "@vaadin/hilla-react-signals": "24.8.7",
"@vaadin/polymer-legacy-adapter": "24.8.3", "@vaadin/polymer-legacy-adapter": "24.8.6",
"@vaadin/react-components": "24.8.3", "@vaadin/react-components": "24.8.6",
"@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.8.6",
"@vaadin/vaadin-material-styles": "24.8.3", "@vaadin/vaadin-material-styles": "24.8.6",
"@vaadin/vaadin-themable-mixin": "24.8.3", "@vaadin/vaadin-themable-mixin": "24.8.6",
"@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.8.7",
"@vaadin/hilla-generator-core": "24.8.2", "@vaadin/hilla-generator-core": "24.8.7",
"@vaadin/hilla-generator-plugin-backbone": "24.8.2", "@vaadin/hilla-generator-plugin-backbone": "24.8.7",
"@vaadin/hilla-generator-plugin-barrel": "24.8.2", "@vaadin/hilla-generator-plugin-barrel": "24.8.7",
"@vaadin/hilla-generator-plugin-client": "24.8.2", "@vaadin/hilla-generator-plugin-client": "24.8.7",
"@vaadin/hilla-generator-plugin-model": "24.8.2", "@vaadin/hilla-generator-plugin-model": "24.8.7",
"@vaadin/hilla-generator-plugin-push": "24.8.2", "@vaadin/hilla-generator-plugin-push": "24.8.7",
"@vaadin/hilla-generator-plugin-signals": "24.8.2", "@vaadin/hilla-generator-plugin-signals": "24.8.7",
"@vaadin/hilla-generator-plugin-subtypes": "24.8.2", "@vaadin/hilla-generator-plugin-subtypes": "24.8.7",
"@vaadin/hilla-generator-plugin-transfertypes": "24.8.2", "@vaadin/hilla-generator-plugin-transfertypes": "24.8.7",
"@vaadin/hilla-generator-utils": "24.8.2", "@vaadin/hilla-generator-utils": "24.8.7",
"@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",
@@ -263,6 +263,6 @@
"workbox-precaching": "7.3.0" "workbox-precaching": "7.3.0"
}, },
"disableUsageStatistics": true, "disableUsageStatistics": true,
"hash": "962eccc3fa0735d5234901be4f9e384096113c45bec22564a53688096d62aef4" "hash": "e499c8893c397649c698f302e100ee1e48833c88e57bd0829fbf86dc5a14cfd8"
} }
} }
+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]);
@@ -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);
@@ -49,7 +49,7 @@ function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
<ConfigFormField configElement={getConfig("sso.oidc.enabled")}/> <ConfigFormField configElement={getConfig("sso.oidc.enabled")}/>
<Section title="SSO user handling"/> <Section title="SSO user handling"/>
<div className="flex flex-row items-baseline"> <div className="flex flex-row items-baseline mb-4">
<CheckboxGroup className="flex flex-col flex-1 items-baseline gap-2" <CheckboxGroup className="flex flex-col flex-1 items-baseline gap-2"
value={["auto-register-new-users"]}> value={["auto-register-new-users"]}>
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
@@ -70,6 +70,13 @@ function SsoManagementLayout({getConfig, formik, setSaveMessage}: any) {
!formik.values.sso.oidc["auto-register-new-users"]}/> !formik.values.sso.oidc["auto-register-new-users"]}/>
</div> </div>
<div className="flex flex-row items-center gap-4">
<ConfigFormField configElement={getConfig("sso.oidc.roles-claim")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.oauth-scopes")}
isDisabled={!formik.values.sso.oidc.enabled}/>
</div>
<Section title="SSO provider configuration"/> <Section title="SSO provider configuration"/>
<ConfigFormField configElement={getConfig("sso.oidc.client-id")} <ConfigFormField configElement={getConfig("sso.oidc.client-id")}
isDisabled={!formik.values.sso.oidc.enabled}/> isDisabled={!formik.values.sso.oidc.enabled}/>
@@ -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;
@@ -1,17 +1,17 @@
import {Card, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, useDisclosure} from "@heroui/react"; import {Button, Card, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, useDisclosure} from "@heroui/react";
import {DotsThreeVertical} from "@phosphor-icons/react"; import {DotsThreeVertical} from "@phosphor-icons/react";
import {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import {MessageEndpoint, PasswordResetEndpoint, UserEndpoint} from "Frontend/generated/endpoints"; import {MessageEndpoint, PasswordResetEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
import {AvatarEndpoint} from "Frontend/endpoints/endpoints"; import {AvatarEndpoint} from "Frontend/endpoints/endpoints";
import Avatar from "Frontend/components/general/Avatar"; 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();
@@ -108,6 +108,27 @@ export function UserManagementCard({user}: { user: UserInfoDto }) {
<> <>
<Card <Card
className={`flex flex-row justify-between p-2 ${userEnabled ? "" : "bg-warning/25"} ${user.managedBySso ? "text-foreground/50" : ""}`}> className={`flex flex-row justify-between p-2 ${userEnabled ? "" : "bg-warning/25"} ${user.managedBySso ? "text-foreground/50" : ""}`}>
<div className="absolute right-0 top-0">
<Dropdown placement="bottom-end" size="sm" backdrop="opaque">
<DropdownTrigger>
<Button isIconOnly variant="light">
<DotsThreeVertical/>
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Static Actions" items={dropdownItems} disabledKeys={disabledKeys}>
{(item) => (
<DropdownItem
key={item.key}
onPress={item.onPress}
color={item.key === "delete" ? "danger" : "default"}
className={item.key === "delete" ? "text-danger" : ""}
>
{item.label}
</DropdownItem>
)}
</DropdownMenu>
</Dropdown>
</div>
<div className="flex flex-row items-center gap-4"> <div className="flex flex-row items-center gap-4">
<Avatar username={user.username} <Avatar username={user.username}
name={user.username?.charAt(0)} name={user.username?.charAt(0)}
@@ -118,30 +139,12 @@ export function UserManagementCard({user}: { user: UserInfoDto }) {
}}/> }}/>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<p className="font-semibold">{user.username}</p> <p className="font-semibold">{user.username}</p>
<p className="text-sm">{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>
<Dropdown placement="bottom-end" size="sm" backdrop="opaque">
<DropdownTrigger>
<DotsThreeVertical cursor="pointer"/>
</DropdownTrigger>
<DropdownMenu aria-label="Static Actions" items={dropdownItems} disabledKeys={disabledKeys}>
{(item) => (
<DropdownItem
key={item.key}
onPress={item.onPress}
color={item.key === "delete" ? "danger" : "default"}
className={item.key === "delete" ? "text-danger" : ""}
>
{item.label}
</DropdownItem>
)}
</DropdownMenu>
</Dropdown>
</Card> </Card>
<ConfirmUserDeletionModal isOpen={userDeletionConfirmationModal.isOpen} <ConfirmUserDeletionModal isOpen={userDeletionConfirmationModal.isOpen}
onOpenChange={userDeletionConfirmationModal.onOpenChange} onOpenChange={userDeletionConfirmationModal.onOpenChange}
@@ -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 {
@@ -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;
}
})
});
}
@@ -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,301 @@
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);
useEffect(() => {
ConfigEndpoint.areGameRequestsEnabled().then(setAreGameRequestsEnabled);
}, []);
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-foreground/70"/>
}
<Button className="w-fit"
color="primary"
startContent={<PlusCircle weight="fill"/>}
onPress={requestGameModal.onOpen}
isDisabled={!areGameRequestsEnabled}>
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-foreground/70">
{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="Vote for this request">
<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>
</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}/>
</>)
}
+1 -1
View File
@@ -46,7 +46,7 @@ export default function GameView() {
}, {} as Record<string, ComboButtonOption>); }, {} as Record<string, ComboButtonOption>);
setDownloadOptions(options); setDownloadOptions(options);
}); });
}, []); }, [gameId]);
useEffect(() => { useEffect(() => {
if (state.isLoaded && (!gameId || !state.state[parseInt(gameId)])) { if (state.isLoaded && (!gameId || !state.state[parseInt(gameId)])) {
+19 -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,26 @@ export default function MainLayout() {
</Button> </Button>
</Tooltip> </Tooltip>
</NavbarContent>} </NavbarContent>}
<NavbarContent justify="end"> <NavbarContent justify="end" className="items-center">
{auth.state.user &&
<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 &&
@@ -55,4 +55,8 @@ class ConfigEndpoint(
@DynamicPublicAccess @DynamicPublicAccess
@AnonymousAllowed @AnonymousAllowed
fun isPublicAccessEnabled(): Boolean = configService.get(ConfigProperties.Libraries.AllowPublicAccess) == true fun isPublicAccessEnabled(): Boolean = configService.get(ConfigProperties.Libraries.AllowPublicAccess) == true
@DynamicPublicAccess
@AnonymousAllowed
fun areGameRequestsEnabled(): Boolean = configService.get(ConfigProperties.Requests.Games.Enabled) == 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 {
@@ -147,6 +173,20 @@ sealed class ConfigProperties<T : Serializable>(
true true
) )
data object RolesClaim : ConfigProperties<String>(
String::class,
"sso.oidc.roles-claim",
"JWT claim to extract roles from",
"roles"
)
data object OAuthScopes : ConfigProperties<Array<String>>(
Array<String>::class,
"sso.oidc.oauth-scopes",
"OAuth2 scopes to request",
arrayOf("openid", "profile", "email", "roles")
)
data object ClientId : ConfigProperties<String>( data object ClientId : ConfigProperties<String>(
String::class, String::class,
"sso.oidc.client-id", "sso.oidc.client-id",
@@ -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"
} }
} }
} }
@@ -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
@@ -24,3 +25,5 @@ class PasswordResetRequestEvent(source: Any, val token: Token<TokenType.Password
class AccountDeletedEvent(source: Any, val user: User, val baseUrl: String) : ApplicationEvent(source) class AccountDeletedEvent(source: Any, val user: User, val baseUrl: String) : ApplicationEvent(source)
class LibraryScanScheduleUpdatedEvent(source: Any) : ApplicationEvent(source) class LibraryScanScheduleUpdatedEvent(source: Any) : ApplicationEvent(source)
class GameCreatedEvent(source: Any, val game: Game) : 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
@@ -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"
}
}
@@ -53,6 +53,7 @@ class SecurityConfig(
.requestMatchers("/game/**").access(DynamicPublicAccessAuthorizationManager(config)) .requestMatchers("/game/**").access(DynamicPublicAccessAuthorizationManager(config))
.requestMatchers("/library/**").access(DynamicPublicAccessAuthorizationManager(config)) .requestMatchers("/library/**").access(DynamicPublicAccessAuthorizationManager(config))
.requestMatchers("/search/**").access(DynamicPublicAccessAuthorizationManager(config)) .requestMatchers("/search/**").access(DynamicPublicAccessAuthorizationManager(config))
.requestMatchers("/requests/**").access(DynamicPublicAccessAuthorizationManager(config))
.requestMatchers("/download/**").access(DynamicPublicAccessAuthorizationManager(config)) .requestMatchers("/download/**").access(DynamicPublicAccessAuthorizationManager(config))
} }
@@ -98,7 +99,7 @@ class SecurityConfig(
val clientRegistration = ClientRegistration.withRegistrationId(SSO_PROVIDER_KEY) val clientRegistration = ClientRegistration.withRegistrationId(SSO_PROVIDER_KEY)
.clientId(config.get(ConfigProperties.SSO.OIDC.ClientId)) .clientId(config.get(ConfigProperties.SSO.OIDC.ClientId))
.clientSecret(config.get(ConfigProperties.SSO.OIDC.ClientSecret)) .clientSecret(config.get(ConfigProperties.SSO.OIDC.ClientSecret))
.scope("openid", "profile", "email") .scope(config.get(ConfigProperties.SSO.OIDC.OAuthScopes)?.toList())
.userNameAttributeName("preferred_username") .userNameAttributeName("preferred_username")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.issuerUri(config.get(ConfigProperties.SSO.OIDC.IssuerUrl)) .issuerUri(config.get(ConfigProperties.SSO.OIDC.IssuerUrl))
@@ -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 isCurrentUserAdmin(): Boolean { fun getCurrentAuth(): Authentication? {
return SecurityContextHolder.getContext().authentication?.authorities?.any { it.authority == Role.Names.ADMIN || it.authority == Role.Names.SUPERADMIN } return SecurityContextHolder.getContext().authentication
?: false }
fun isCurrentUserAdmin(): Boolean {
return getCurrentAuth()?.isAdmin() ?: 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,20 +12,21 @@ 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
@@ -149,11 +150,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
@@ -260,12 +260,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()
@@ -504,12 +504,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 +567,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 +578,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) {
@@ -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,7 @@
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 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
@@ -3,28 +3,32 @@ 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.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.Companion.emitUser(GameUserEvent.Created(game.toUserDto())) GameService.emitUser(GameUserEvent.Created(game.toUserDto()))
GameService.Companion.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.Companion.emitUser(GameUserEvent.Updated(game.toUserDto())) GameService.emitUser(GameUserEvent.Updated(game.toUserDto()))
GameService.Companion.emitAdmin(GameAdminEvent.Updated(game.toAdminDto())) GameService.emitAdmin(GameAdminEvent.Updated(game.toAdminDto()))
} }
@PostRemove @PostRemove
fun deleted(game: Game) { fun deleted(game: Game) {
GameService.Companion.emitUser(GameUserEvent.Deleted(game.id!!)) GameService.emitUser(GameUserEvent.Deleted(game.id!!))
GameService.Companion.emitAdmin(GameAdminEvent.Deleted(game.id!!)) GameService.emitAdmin(GameAdminEvent.Deleted(game.id!!))
} }
} }
@@ -1,15 +1,13 @@
package org.gameyfin.app.games.entities package org.gameyfin.app.games.entities
import jakarta.persistence.Entity import jakarta.persistence.*
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 import java.net.URL
@Entity @Entity
@EntityListeners(ImageEntityListener::class)
class Image( class Image(
@Id @Id
@GeneratedValue(strategy = GenerationType.AUTO) @GeneratedValue(strategy = GenerationType.AUTO)
@@ -0,0 +1,27 @@
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,14 @@ 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>
}
@@ -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
@@ -4,15 +4,16 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import org.gameyfin.app.core.filesystem.FilesystemService 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.libraries.dto.LibraryScanProgress import org.gameyfin.app.games.entities.Image
import org.gameyfin.app.libraries.dto.LibraryScanStatus import org.gameyfin.app.libraries.dto.*
import org.gameyfin.app.libraries.dto.LibraryScanStep 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.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
@@ -331,39 +332,56 @@ class LibraryScanService(
private fun downloadImages(games: List<Game>, progress: LibraryScanProgress): DownloadImagesResult { private fun downloadImages(games: List<Game>, progress: LibraryScanProgress): DownloadImagesResult {
val completedImageDownload = AtomicInteger(0) val completedImageDownload = AtomicInteger(0)
val imageDownloadTasks = games.map { game -> // Collect all images from all games in the batch
Callable<Game?> { val allImages = games.flatMap { game ->
val images = mutableListOf<Image>()
game.coverImage?.let { images.add(it) }
game.headerImage?.let { images.add(it) }
images.addAll(game.images)
images
}
// Deduplicate by originalUrl
val uniqueImages = allImages
.filter { it.originalUrl != null }
.distinctBy { it.originalUrl }
// Map to track which Image entity was used for download per originalUrl
val downloadedImageMap = ConcurrentHashMap<URL, Image>()
// Download each unique image in parallel
val imageDownloadTasks = uniqueImages.map { image ->
Callable {
try { try {
game.coverImage?.let { imageService.downloadIfNew(image)
imageService.downloadIfNew(it) image.originalUrl?.let { url ->
completedImageDownload.andIncrement downloadedImageMap[url] = image
} }
game.headerImage?.let {
imageService.downloadIfNew(it)
completedImageDownload.andIncrement
}
game.images.map {
imageService.downloadIfNew(it)
completedImageDownload.andIncrement
}
game
} catch (e: Exception) { } catch (e: Exception) {
log.error { "Error downloading images for game '${game.title}' (${game.id}): ${e.message}" } log.error { "Error downloading image '${image.originalUrl}': ${e.message}" }
log.debug(e) {} log.debug(e) {}
null
} finally { } finally {
progress.currentStep.current = completedImageDownload.get() progress.currentStep.current = completedImageDownload.incrementAndGet()
emit(progress) emit(progress)
} }
} }
} }
executor.invokeAll(imageDownloadTasks)
val gamesWithImages = executor.invokeAll(imageDownloadTasks).mapNotNull { it.get() } // After downloads, associate the contentId with all other Image entities in the batch with the same originalUrl
for ((url, downloadedImage) in downloadedImageMap) {
val contentId = downloadedImage.contentId
if (contentId != null) {
allImages.filter { it.originalUrl.toString() == url.toString() && it !== downloadedImage }
.forEach { image ->
imageService.downloadIfNew(image)
progress.currentStep.current = completedImageDownload.incrementAndGet()
emit(progress)
}
}
}
return DownloadImagesResult(gamesWithImages = gamesWithImages) return DownloadImagesResult(gamesWithImages = games)
} }
private fun calculateFileSizes(games: List<Game>, progress: LibraryScanProgress): CalculateFilesizesResult { private fun calculateFileSizes(games: List<Game>, progress: LibraryScanProgress): CalculateFilesizesResult {
@@ -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
@@ -61,7 +60,7 @@ 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.createFile(ImageType.AVATAR, 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)
} }
@@ -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)
@@ -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 create 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,5 +1,7 @@
package org.gameyfin.app.users package org.gameyfin.app.users
import org.gameyfin.app.config.ConfigProperties
import org.gameyfin.app.config.ConfigService
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.gameyfin.app.users.persistence.UserRepository import org.gameyfin.app.users.persistence.UserRepository
@@ -11,7 +13,8 @@ import org.springframework.stereotype.Service
@Service @Service
class RoleService( class RoleService(
private val userRepository: UserRepository private val userRepository: UserRepository,
private val configService: ConfigService
) { ) {
companion object { companion object {
@@ -66,7 +69,8 @@ class RoleService(
.filterIsInstance<OidcUserAuthority>() .filterIsInstance<OidcUserAuthority>()
.flatMap { oidcUserAuthority -> .flatMap { oidcUserAuthority ->
val userInfo = oidcUserAuthority.userInfo val userInfo = oidcUserAuthority.userInfo
val roles = userInfo.getClaim<List<String>>("roles") ?: return@flatMap emptySequence() val rolesClaim = configService.get(ConfigProperties.SSO.OIDC.RolesClaim)
val roles = userInfo.getClaim<List<String>>(rolesClaim) ?: return@flatMap emptySequence()
roles.asSequence().mapNotNull { roles.asSequence().mapNotNull {
if (it.startsWith(SSO_ROLE_PREFIX)) SimpleGrantedAuthority( if (it.startsWith(SSO_ROLE_PREFIX)) SimpleGrantedAuthority(
it.replace(SSO_ROLE_PREFIX, INTERNAL_ROLE_PREFIX) it.replace(SSO_ROLE_PREFIX, INTERNAL_ROLE_PREFIX)
@@ -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 }
} }
@@ -6,18 +6,18 @@ 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.*
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 +56,7 @@ class UserService(
true, true,
true, true,
true, true,
toAuthorities(user.roles) user.roles.toAuthorities()
) )
} }
@@ -67,7 +67,7 @@ class UserService(
true, true,
true, true,
true, true,
toAuthorities(user.roles) user.roles.toAuthorities()
) )
} }
@@ -77,8 +77,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 +93,20 @@ 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 userInfoDto = oidcUser.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? {
@@ -158,7 +158,7 @@ class UserService(
RegistrationAttemptWithExistingEmailEvent( RegistrationAttemptWithExistingEmailEvent(
this, this,
it, it,
Utils.Companion.getBaseUrl() Utils.getBaseUrl()
) )
) )
return return
@@ -179,12 +179,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 +222,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 +238,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 +246,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 +266,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 +276,12 @@ 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())) eventPublisher.publishEvent(AccountDeletedEvent(this, user, Utils.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)
} }
@@ -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) }
}
@@ -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
+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.0" version = "2.0.1"
allprojects { allprojects {
repositories { repositories {
+39
View File
@@ -0,0 +1,39 @@
# syntax=docker/dockerfile:1.4
FROM eclipse-temurin:21-jre
MAINTAINER grimsi
# Install necessary packages
RUN apt-get update && \
apt-get install -y tini gosu && \
rm -rf /var/lib/apt/lists/*
ENV USER=gameyfin
RUN groupadd gameyfin && \
useradd -M -g gameyfin gameyfin
WORKDIR /opt/gameyfin
# Create necessary directories with appropriate permissions
RUN mkdir -p plugins db data logs && \
chown -R gameyfin:gameyfin .
# Copy entrypoint script and set permissions
COPY --chown=gameyfin:gameyfin ./docker/entrypoint.ubuntu.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Copy application jar (not ending with -plain.jar)
COPY --chown=gameyfin:gameyfin ./app/build/libs/ /tmp/app-libs/
RUN find /tmp/app-libs -type f -name "*.jar" ! -name "*-plain.jar" -exec cp {} gameyfin.jar \; && \
rm -rf /tmp/app-libs
# Copy all plugin jars
COPY --chown=gameyfin:gameyfin ./plugins/ /tmp/plugins/
RUN find /tmp/plugins -type f -path "*/build/libs/*.jar" -exec cp {} plugins/ \; && \
rm -rf /tmp/plugins
EXPOSE 8080
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
set -e
if [ -n "$PUID" ] && [ -n "$PGID" ]; then
groupmod -o -g "$PGID" gameyfin
usermod -o -u "$PUID" gameyfin
chown -R gameyfin:gameyfin /opt/gameyfin
exec gosu gameyfin:gameyfin java -jar gameyfin.jar
else
exec gosu gameyfin:gameyfin java -jar gameyfin.jar
fi
+1 -1
View File
@@ -5,7 +5,7 @@ org.gradle.caching=true
# Plugin versions # Plugin versions
kotlinVersion=2.2.0 kotlinVersion=2.2.0
kspVersion=2.2.0-2.0.2 kspVersion=2.2.0-2.0.2
vaadinVersion=24.8.3 vaadinVersion=24.8.7
springBootVersion=3.5.3 springBootVersion=3.5.3
springCloudVersion=2025.0.0 springCloudVersion=2025.0.0
springDependencyManagementVersion=1.1.7 springDependencyManagementVersion=1.1.7
@@ -150,7 +150,7 @@ class TorrentDownloadPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin
private fun getTrackerUri(): URI { private fun getTrackerUri(): URI {
val protocol = "http" // No SSL support in ttorrent: https://github.com/mpetazzoni/ttorrent/issues/4 val protocol = "http" // No SSL support in ttorrent: https://github.com/mpetazzoni/ttorrent/issues/4
val host = getHostname().getCanonicalHostName() val host = getHostname().getHostName()
val port = config<Int>("trackerPort") val port = config<Int>("trackerPort")
val path = "announce" val path = "announce"
@@ -1,4 +1,4 @@
Plugin-Version: 1.0.0 Plugin-Version: 1.0.1
Plugin-Class: org.gameyfin.plugins.download.torrent.TorrentDownloadPlugin Plugin-Class: org.gameyfin.plugins.download.torrent.TorrentDownloadPlugin
Plugin-Id: org.gameyfin.plugins.download.torrent Plugin-Id: org.gameyfin.plugins.download.torrent
Plugin-Name: Torrent Download Plugin-Name: Torrent Download