mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
Implement request system (#643)
This commit is contained in:
@@ -19,7 +19,15 @@ runs:
|
||||
username: ${{ inputs.ghcr_username }}
|
||||
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
|
||||
with:
|
||||
context: ${{ inputs.context }}
|
||||
@@ -30,6 +38,17 @@ runs:
|
||||
cache-from: 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:
|
||||
dockerhub_username:
|
||||
required: true
|
||||
|
||||
@@ -18,10 +18,10 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
|
||||
@@ -12,10 +12,10 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
release_version: ${{ steps.get_version.outputs.release_version }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -64,17 +64,17 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download modified files
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: modified-files
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
@@ -119,17 +119,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download modified files
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: modified-files
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
@@ -150,12 +150,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download modified files
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
name: modified-files
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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="ALTERNATIVE_JRE_PATH" value="BUNDLED" />
|
||||
<envs>
|
||||
Generated
+798
-1124
File diff suppressed because it is too large
Load Diff
+111
-111
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gameyfin",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@heroui/react": "2.7.9",
|
||||
@@ -9,22 +9,22 @@
|
||||
"@polymer/polymer": "3.5.2",
|
||||
"@react-stately/data": "^3.12.2",
|
||||
"@react-types/shared": "^3.28.0",
|
||||
"@vaadin/bundles": "24.8.3",
|
||||
"@vaadin/bundles": "24.8.6",
|
||||
"@vaadin/common-frontend": "0.0.19",
|
||||
"@vaadin/hilla-file-router": "24.8.2",
|
||||
"@vaadin/hilla-frontend": "24.8.2",
|
||||
"@vaadin/hilla-lit-form": "24.8.2",
|
||||
"@vaadin/hilla-react-auth": "24.8.2",
|
||||
"@vaadin/hilla-react-crud": "24.8.2",
|
||||
"@vaadin/hilla-react-form": "24.8.2",
|
||||
"@vaadin/hilla-react-i18n": "24.8.2",
|
||||
"@vaadin/hilla-react-signals": "24.8.2",
|
||||
"@vaadin/polymer-legacy-adapter": "24.8.3",
|
||||
"@vaadin/react-components": "24.8.3",
|
||||
"@vaadin/hilla-file-router": "24.8.7",
|
||||
"@vaadin/hilla-frontend": "24.8.7",
|
||||
"@vaadin/hilla-lit-form": "24.8.7",
|
||||
"@vaadin/hilla-react-auth": "24.8.7",
|
||||
"@vaadin/hilla-react-crud": "24.8.7",
|
||||
"@vaadin/hilla-react-form": "24.8.7",
|
||||
"@vaadin/hilla-react-i18n": "24.8.7",
|
||||
"@vaadin/hilla-react-signals": "24.8.7",
|
||||
"@vaadin/polymer-legacy-adapter": "24.8.6",
|
||||
"@vaadin/react-components": "24.8.6",
|
||||
"@vaadin/vaadin-development-mode-detector": "2.0.7",
|
||||
"@vaadin/vaadin-lumo-styles": "24.8.3",
|
||||
"@vaadin/vaadin-material-styles": "24.8.3",
|
||||
"@vaadin/vaadin-themable-mixin": "24.8.3",
|
||||
"@vaadin/vaadin-lumo-styles": "24.8.6",
|
||||
"@vaadin/vaadin-material-styles": "24.8.6",
|
||||
"@vaadin/vaadin-themable-mixin": "24.8.6",
|
||||
"@vaadin/vaadin-usage-statistics": "2.1.3",
|
||||
"classnames": "^2.5.1",
|
||||
"construct-style-sheets-polyfill": "3.1.0",
|
||||
@@ -61,17 +61,17 @@
|
||||
"@types/node": "^22.4.0",
|
||||
"@types/react": "18.3.23",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"@vaadin/hilla-generator-cli": "24.8.2",
|
||||
"@vaadin/hilla-generator-core": "24.8.2",
|
||||
"@vaadin/hilla-generator-plugin-backbone": "24.8.2",
|
||||
"@vaadin/hilla-generator-plugin-barrel": "24.8.2",
|
||||
"@vaadin/hilla-generator-plugin-client": "24.8.2",
|
||||
"@vaadin/hilla-generator-plugin-model": "24.8.2",
|
||||
"@vaadin/hilla-generator-plugin-push": "24.8.2",
|
||||
"@vaadin/hilla-generator-plugin-signals": "24.8.2",
|
||||
"@vaadin/hilla-generator-plugin-subtypes": "24.8.2",
|
||||
"@vaadin/hilla-generator-plugin-transfertypes": "24.8.2",
|
||||
"@vaadin/hilla-generator-utils": "24.8.2",
|
||||
"@vaadin/hilla-generator-cli": "24.8.7",
|
||||
"@vaadin/hilla-generator-core": "24.8.7",
|
||||
"@vaadin/hilla-generator-plugin-backbone": "24.8.7",
|
||||
"@vaadin/hilla-generator-plugin-barrel": "24.8.7",
|
||||
"@vaadin/hilla-generator-plugin-client": "24.8.7",
|
||||
"@vaadin/hilla-generator-plugin-model": "24.8.7",
|
||||
"@vaadin/hilla-generator-plugin-push": "24.8.7",
|
||||
"@vaadin/hilla-generator-plugin-signals": "24.8.7",
|
||||
"@vaadin/hilla-generator-plugin-subtypes": "24.8.7",
|
||||
"@vaadin/hilla-generator-plugin-transfertypes": "24.8.7",
|
||||
"@vaadin/hilla-generator-utils": "24.8.7",
|
||||
"@vitejs/plugin-react": "4.5.0",
|
||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||
"async": "3.2.6",
|
||||
@@ -142,85 +142,85 @@
|
||||
"valtio": "$valtio",
|
||||
"valtio-reactive": "$valtio-reactive",
|
||||
"fzf": "$fzf",
|
||||
"@vaadin/a11y-base": "24.8.3",
|
||||
"@vaadin/accordion": "24.8.3",
|
||||
"@vaadin/app-layout": "24.8.3",
|
||||
"@vaadin/avatar": "24.8.3",
|
||||
"@vaadin/avatar-group": "24.8.3",
|
||||
"@vaadin/button": "24.8.3",
|
||||
"@vaadin/card": "24.8.3",
|
||||
"@vaadin/checkbox": "24.8.3",
|
||||
"@vaadin/checkbox-group": "24.8.3",
|
||||
"@vaadin/combo-box": "24.8.3",
|
||||
"@vaadin/component-base": "24.8.3",
|
||||
"@vaadin/confirm-dialog": "24.8.3",
|
||||
"@vaadin/context-menu": "24.8.3",
|
||||
"@vaadin/custom-field": "24.8.3",
|
||||
"@vaadin/date-picker": "24.8.3",
|
||||
"@vaadin/date-time-picker": "24.8.3",
|
||||
"@vaadin/details": "24.8.3",
|
||||
"@vaadin/dialog": "24.8.3",
|
||||
"@vaadin/email-field": "24.8.3",
|
||||
"@vaadin/field-base": "24.8.3",
|
||||
"@vaadin/field-highlighter": "24.8.3",
|
||||
"@vaadin/form-layout": "24.8.3",
|
||||
"@vaadin/grid": "24.8.3",
|
||||
"@vaadin/horizontal-layout": "24.8.3",
|
||||
"@vaadin/icon": "24.8.3",
|
||||
"@vaadin/icons": "24.8.3",
|
||||
"@vaadin/input-container": "24.8.3",
|
||||
"@vaadin/integer-field": "24.8.3",
|
||||
"@vaadin/item": "24.8.3",
|
||||
"@vaadin/list-box": "24.8.3",
|
||||
"@vaadin/lit-renderer": "24.8.3",
|
||||
"@vaadin/login": "24.8.3",
|
||||
"@vaadin/markdown": "24.8.3",
|
||||
"@vaadin/master-detail-layout": "24.8.3",
|
||||
"@vaadin/menu-bar": "24.8.3",
|
||||
"@vaadin/message-input": "24.8.3",
|
||||
"@vaadin/message-list": "24.8.3",
|
||||
"@vaadin/multi-select-combo-box": "24.8.3",
|
||||
"@vaadin/notification": "24.8.3",
|
||||
"@vaadin/number-field": "24.8.3",
|
||||
"@vaadin/overlay": "24.8.3",
|
||||
"@vaadin/password-field": "24.8.3",
|
||||
"@vaadin/popover": "24.8.3",
|
||||
"@vaadin/progress-bar": "24.8.3",
|
||||
"@vaadin/radio-group": "24.8.3",
|
||||
"@vaadin/scroller": "24.8.3",
|
||||
"@vaadin/select": "24.8.3",
|
||||
"@vaadin/side-nav": "24.8.3",
|
||||
"@vaadin/split-layout": "24.8.3",
|
||||
"@vaadin/tabs": "24.8.3",
|
||||
"@vaadin/tabsheet": "24.8.3",
|
||||
"@vaadin/text-area": "24.8.3",
|
||||
"@vaadin/text-field": "24.8.3",
|
||||
"@vaadin/time-picker": "24.8.3",
|
||||
"@vaadin/tooltip": "24.8.3",
|
||||
"@vaadin/upload": "24.8.3",
|
||||
"@vaadin/a11y-base": "24.8.6",
|
||||
"@vaadin/accordion": "24.8.6",
|
||||
"@vaadin/app-layout": "24.8.6",
|
||||
"@vaadin/avatar": "24.8.6",
|
||||
"@vaadin/avatar-group": "24.8.6",
|
||||
"@vaadin/button": "24.8.6",
|
||||
"@vaadin/card": "24.8.6",
|
||||
"@vaadin/checkbox": "24.8.6",
|
||||
"@vaadin/checkbox-group": "24.8.6",
|
||||
"@vaadin/combo-box": "24.8.6",
|
||||
"@vaadin/component-base": "24.8.6",
|
||||
"@vaadin/confirm-dialog": "24.8.6",
|
||||
"@vaadin/context-menu": "24.8.6",
|
||||
"@vaadin/custom-field": "24.8.6",
|
||||
"@vaadin/date-picker": "24.8.6",
|
||||
"@vaadin/date-time-picker": "24.8.6",
|
||||
"@vaadin/details": "24.8.6",
|
||||
"@vaadin/dialog": "24.8.6",
|
||||
"@vaadin/email-field": "24.8.6",
|
||||
"@vaadin/field-base": "24.8.6",
|
||||
"@vaadin/field-highlighter": "24.8.6",
|
||||
"@vaadin/form-layout": "24.8.6",
|
||||
"@vaadin/grid": "24.8.6",
|
||||
"@vaadin/horizontal-layout": "24.8.6",
|
||||
"@vaadin/icon": "24.8.6",
|
||||
"@vaadin/icons": "24.8.6",
|
||||
"@vaadin/input-container": "24.8.6",
|
||||
"@vaadin/integer-field": "24.8.6",
|
||||
"@vaadin/item": "24.8.6",
|
||||
"@vaadin/list-box": "24.8.6",
|
||||
"@vaadin/lit-renderer": "24.8.6",
|
||||
"@vaadin/login": "24.8.6",
|
||||
"@vaadin/markdown": "24.8.6",
|
||||
"@vaadin/master-detail-layout": "24.8.6",
|
||||
"@vaadin/menu-bar": "24.8.6",
|
||||
"@vaadin/message-input": "24.8.6",
|
||||
"@vaadin/message-list": "24.8.6",
|
||||
"@vaadin/multi-select-combo-box": "24.8.6",
|
||||
"@vaadin/notification": "24.8.6",
|
||||
"@vaadin/number-field": "24.8.6",
|
||||
"@vaadin/overlay": "24.8.6",
|
||||
"@vaadin/password-field": "24.8.6",
|
||||
"@vaadin/popover": "24.8.6",
|
||||
"@vaadin/progress-bar": "24.8.6",
|
||||
"@vaadin/radio-group": "24.8.6",
|
||||
"@vaadin/scroller": "24.8.6",
|
||||
"@vaadin/select": "24.8.6",
|
||||
"@vaadin/side-nav": "24.8.6",
|
||||
"@vaadin/split-layout": "24.8.6",
|
||||
"@vaadin/tabs": "24.8.6",
|
||||
"@vaadin/tabsheet": "24.8.6",
|
||||
"@vaadin/text-area": "24.8.6",
|
||||
"@vaadin/text-field": "24.8.6",
|
||||
"@vaadin/time-picker": "24.8.6",
|
||||
"@vaadin/tooltip": "24.8.6",
|
||||
"@vaadin/upload": "24.8.6",
|
||||
"@vaadin/router": "2.0.0",
|
||||
"@vaadin/vertical-layout": "24.8.3",
|
||||
"@vaadin/virtual-list": "24.8.3"
|
||||
"@vaadin/vertical-layout": "24.8.6",
|
||||
"@vaadin/virtual-list": "24.8.6"
|
||||
},
|
||||
"vaadin": {
|
||||
"dependencies": {
|
||||
"@polymer/polymer": "3.5.2",
|
||||
"@vaadin/bundles": "24.8.3",
|
||||
"@vaadin/bundles": "24.8.6",
|
||||
"@vaadin/common-frontend": "0.0.19",
|
||||
"@vaadin/hilla-file-router": "24.8.2",
|
||||
"@vaadin/hilla-frontend": "24.8.2",
|
||||
"@vaadin/hilla-lit-form": "24.8.2",
|
||||
"@vaadin/hilla-react-auth": "24.8.2",
|
||||
"@vaadin/hilla-react-crud": "24.8.2",
|
||||
"@vaadin/hilla-react-form": "24.8.2",
|
||||
"@vaadin/hilla-react-i18n": "24.8.2",
|
||||
"@vaadin/hilla-react-signals": "24.8.2",
|
||||
"@vaadin/polymer-legacy-adapter": "24.8.3",
|
||||
"@vaadin/react-components": "24.8.3",
|
||||
"@vaadin/hilla-file-router": "24.8.7",
|
||||
"@vaadin/hilla-frontend": "24.8.7",
|
||||
"@vaadin/hilla-lit-form": "24.8.7",
|
||||
"@vaadin/hilla-react-auth": "24.8.7",
|
||||
"@vaadin/hilla-react-crud": "24.8.7",
|
||||
"@vaadin/hilla-react-form": "24.8.7",
|
||||
"@vaadin/hilla-react-i18n": "24.8.7",
|
||||
"@vaadin/hilla-react-signals": "24.8.7",
|
||||
"@vaadin/polymer-legacy-adapter": "24.8.6",
|
||||
"@vaadin/react-components": "24.8.6",
|
||||
"@vaadin/vaadin-development-mode-detector": "2.0.7",
|
||||
"@vaadin/vaadin-lumo-styles": "24.8.3",
|
||||
"@vaadin/vaadin-material-styles": "24.8.3",
|
||||
"@vaadin/vaadin-themable-mixin": "24.8.3",
|
||||
"@vaadin/vaadin-lumo-styles": "24.8.6",
|
||||
"@vaadin/vaadin-material-styles": "24.8.6",
|
||||
"@vaadin/vaadin-themable-mixin": "24.8.6",
|
||||
"@vaadin/vaadin-usage-statistics": "2.1.3",
|
||||
"construct-style-sheets-polyfill": "3.1.0",
|
||||
"date-fns": "2.29.3",
|
||||
@@ -236,17 +236,17 @@
|
||||
"@rollup/pluginutils": "5.1.4",
|
||||
"@types/react": "18.3.23",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"@vaadin/hilla-generator-cli": "24.8.2",
|
||||
"@vaadin/hilla-generator-core": "24.8.2",
|
||||
"@vaadin/hilla-generator-plugin-backbone": "24.8.2",
|
||||
"@vaadin/hilla-generator-plugin-barrel": "24.8.2",
|
||||
"@vaadin/hilla-generator-plugin-client": "24.8.2",
|
||||
"@vaadin/hilla-generator-plugin-model": "24.8.2",
|
||||
"@vaadin/hilla-generator-plugin-push": "24.8.2",
|
||||
"@vaadin/hilla-generator-plugin-signals": "24.8.2",
|
||||
"@vaadin/hilla-generator-plugin-subtypes": "24.8.2",
|
||||
"@vaadin/hilla-generator-plugin-transfertypes": "24.8.2",
|
||||
"@vaadin/hilla-generator-utils": "24.8.2",
|
||||
"@vaadin/hilla-generator-cli": "24.8.7",
|
||||
"@vaadin/hilla-generator-core": "24.8.7",
|
||||
"@vaadin/hilla-generator-plugin-backbone": "24.8.7",
|
||||
"@vaadin/hilla-generator-plugin-barrel": "24.8.7",
|
||||
"@vaadin/hilla-generator-plugin-client": "24.8.7",
|
||||
"@vaadin/hilla-generator-plugin-model": "24.8.7",
|
||||
"@vaadin/hilla-generator-plugin-push": "24.8.7",
|
||||
"@vaadin/hilla-generator-plugin-signals": "24.8.7",
|
||||
"@vaadin/hilla-generator-plugin-subtypes": "24.8.7",
|
||||
"@vaadin/hilla-generator-plugin-transfertypes": "24.8.7",
|
||||
"@vaadin/hilla-generator-utils": "24.8.7",
|
||||
"@vitejs/plugin-react": "4.5.0",
|
||||
"async": "3.2.6",
|
||||
"glob": "11.0.2",
|
||||
@@ -263,6 +263,6 @@
|
||||
"workbox-precaching": "7.3.0"
|
||||
},
|
||||
"disableUsageStatistics": true,
|
||||
"hash": "962eccc3fa0735d5234901be4f9e384096113c45bec22564a53688096d62aef4"
|
||||
"hash": "e499c8893c397649c698f302e100ee1e48833c88e57bd0829fbf86dc5a14cfd8"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {initializePluginState} from "Frontend/state/PluginState";
|
||||
import {isAdmin} from "Frontend/util/utils";
|
||||
import {useRouteMetadata} from "Frontend/util/routing";
|
||||
import {useEffect} from "react";
|
||||
import {initializeGameRequestState} from "Frontend/state/GameRequestState";
|
||||
|
||||
export default function App() {
|
||||
client.middlewares = [ErrorHandlingMiddleware];
|
||||
@@ -45,10 +46,11 @@ function ViewWithAuth() {
|
||||
|
||||
initializeLibraryState();
|
||||
initializeGameState();
|
||||
initializeGameRequestState();
|
||||
initializePluginState();
|
||||
|
||||
if (isAdmin(auth)) {
|
||||
initializeScanState();
|
||||
initializePluginState();
|
||||
}
|
||||
}, [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")}/>
|
||||
|
||||
<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"
|
||||
value={["auto-register-new-users"]}>
|
||||
<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"]}/>
|
||||
</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"/>
|
||||
<ConfigFormField configElement={getConfig("sso.oidc.client-id")}
|
||||
isDisabled={!formik.values.sso.oidc.enabled}/>
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import React from "react";
|
||||
import {SystemEndpoint} from "Frontend/generated/endpoints";
|
||||
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";
|
||||
|
||||
function SystemManagementLayout() {
|
||||
|
||||
function restart() {
|
||||
SystemEndpoint.restart().then(() =>
|
||||
addToast({
|
||||
title: "Restarting",
|
||||
description: "Gameyfin is restarting. This may take a few moments.",
|
||||
color: "success"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col mt-4">
|
||||
<Section title="Restart Gameyfin"/>
|
||||
<Button onPress={() => SystemEndpoint.restart()}>Restart</Button>
|
||||
<Button onPress={restart}>Restart</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,16 +3,16 @@ import ConfigFormField from "Frontend/components/administration/ConfigFormField"
|
||||
import withConfigPage from "Frontend/components/administration/withConfigPage";
|
||||
import Section from "Frontend/components/general/Section";
|
||||
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 {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
||||
import {Info, UserPlus} from "@phosphor-icons/react";
|
||||
import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
|
||||
import InviteUserModal from "Frontend/components/general/modals/InviteUserModal";
|
||||
import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto";
|
||||
|
||||
function UserManagementLayout({getConfig, formik}: any) {
|
||||
const inviteUserModal = useDisclosure();
|
||||
const [users, setUsers] = useState<UserInfoDto[]>([]);
|
||||
const [users, setUsers] = useState<ExtendedUserInfoDto[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
UserEndpoint.getAllUsers().then(
|
||||
|
||||
@@ -15,13 +15,22 @@ const Avatar = ({...props}) => {
|
||||
}
|
||||
|
||||
// TODO: Check if avatar can be loaded from SSO
|
||||
return (
|
||||
<NextUiAvatar
|
||||
showFallback
|
||||
src={`/images/avatar?username=${username}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
if (auth.state.user?.hasAvatar) {
|
||||
return (
|
||||
<NextUiAvatar
|
||||
showFallback
|
||||
src={`/images/avatar?username=${username}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<NextUiAvatar
|
||||
showFallback
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 {useEffect, useState} from "react";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {MessageEndpoint, PasswordResetEndpoint, UserEndpoint} from "Frontend/generated/endpoints";
|
||||
import {AvatarEndpoint} from "Frontend/endpoints/endpoints";
|
||||
import Avatar from "Frontend/components/general/Avatar";
|
||||
import ConfirmUserDeletionModal from "Frontend/components/general/modals/ConfirmUserDeletionModal";
|
||||
import PasswordResetTokenModal from "Frontend/components/general/modals/PasswortResetTokenModal";
|
||||
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 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 passwordResetTokenModal = useDisclosure();
|
||||
const roleAssignmentModal = useDisclosure();
|
||||
@@ -108,6 +108,27 @@ export function UserManagementCard({user}: { user: UserInfoDto }) {
|
||||
<>
|
||||
<Card
|
||||
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">
|
||||
<Avatar username={user.username}
|
||||
name={user.username?.charAt(0)}
|
||||
@@ -118,30 +139,12 @@ export function UserManagementCard({user}: { user: UserInfoDto }) {
|
||||
}}/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<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) => (
|
||||
<RoleChip role={role as string}/>
|
||||
<RoleChip key={role} role={role as string}/>
|
||||
))}
|
||||
</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>
|
||||
<ConfirmUserDeletionModal isOpen={userDeletionConfirmationModal.isOpen}
|
||||
onOpenChange={userDeletionConfirmationModal.onOpenChange}
|
||||
|
||||
@@ -12,14 +12,14 @@ import {
|
||||
SelectItem
|
||||
} from "@heroui/react";
|
||||
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 RoleAssignmentResult from "Frontend/generated/org/gameyfin/app/users/enums/RoleAssignmentResult";
|
||||
import ExtendedUserInfoDto from "Frontend/generated/org/gameyfin/app/users/dto/ExtendedUserInfoDto";
|
||||
|
||||
interface AssignRolesModalProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: () => void;
|
||||
user: UserInfoDto;
|
||||
user: ExtendedUserInfoDto;
|
||||
}
|
||||
|
||||
interface Role {
|
||||
|
||||
@@ -21,7 +21,7 @@ import {useSnapshot} from "valtio/react";
|
||||
import {pluginState} from "Frontend/state/PluginState";
|
||||
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
|
||||
|
||||
interface EditGameMetadataModalProps {
|
||||
interface MatchGameModalProps {
|
||||
path: string;
|
||||
libraryId: number;
|
||||
replaceGameId?: number;
|
||||
@@ -37,7 +37,7 @@ export default function MatchGameModal({
|
||||
initialSearchTerm,
|
||||
isOpen,
|
||||
onOpenChange
|
||||
}: EditGameMetadataModalProps) {
|
||||
}: MatchGameModalProps) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]);
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,8 @@ import RecentlyAddedView from "Frontend/views/RecentlyAddedView";
|
||||
import LibraryView from "Frontend/views/LibraryView";
|
||||
import {RouterConfigurationBuilder} from "@vaadin/hilla-file-router/runtime.js";
|
||||
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()
|
||||
.withReactRoutes([
|
||||
@@ -47,6 +49,11 @@ export const {router, routes} = new RouterConfigurationBuilder()
|
||||
element: <RecentlyAddedView/>,
|
||||
handle: {title: 'Recently Added'}
|
||||
},
|
||||
{
|
||||
path: '/requests',
|
||||
element: <GameRequestView/>,
|
||||
handle: {title: 'Game requests'}
|
||||
},
|
||||
{
|
||||
path: 'library/:libraryId',
|
||||
element: <LibraryView/>
|
||||
@@ -87,6 +94,11 @@ export const {router, routes} = new RouterConfigurationBuilder()
|
||||
element: <LibraryManagementView/>,
|
||||
handle: {title: 'Administration - Library'}
|
||||
},
|
||||
{
|
||||
path: 'requests',
|
||||
element: <GameRequestManagement/>,
|
||||
handle: {title: 'Administration - Game Requests'}
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
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";
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
@@ -7,6 +7,11 @@ const menuItems: MenuItem[] = [
|
||||
url: "libraries",
|
||||
icon: <GameController/>
|
||||
},
|
||||
{
|
||||
title: "Game Requests",
|
||||
url: "requests",
|
||||
icon: <Disc/>
|
||||
},
|
||||
{
|
||||
title: "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}/>
|
||||
</>)
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export default function GameView() {
|
||||
}, {} as Record<string, ComboButtonOption>);
|
||||
setDownloadOptions(options);
|
||||
});
|
||||
}, []);
|
||||
}, [gameId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.isLoaded && (!gameId || !state.state[parseInt(gameId)])) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import GameyfinLogo from "Frontend/components/theming/GameyfinLogo";
|
||||
import * as PackageJson from "../../../../package.json";
|
||||
import {Outlet, useLocation, useNavigate} from "react-router";
|
||||
import {useAuth} from "Frontend/util/auth";
|
||||
import {ArrowLeft, DiceSix, Heart, House, ListMagnifyingGlass, 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 {useTheme} from "next-themes";
|
||||
import {useUserPreferenceService} from "Frontend/util/user-preference-service";
|
||||
@@ -93,10 +93,26 @@ export default function MainLayout() {
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</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) &&
|
||||
<NavbarItem>
|
||||
<ScanProgressPopover/>
|
||||
<Tooltip content="View library scan results" placement="bottom">
|
||||
<div>
|
||||
<ScanProgressPopover/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</NavbarItem>
|
||||
}
|
||||
{auth.state.user &&
|
||||
|
||||
@@ -55,4 +55,8 @@ class ConfigEndpoint(
|
||||
@DynamicPublicAccess
|
||||
@AnonymousAllowed
|
||||
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 */
|
||||
sealed class Users {
|
||||
sealed class SignUps {
|
||||
@@ -147,6 +173,20 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
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>(
|
||||
String::class,
|
||||
"sso.oidc.client-id",
|
||||
|
||||
@@ -4,6 +4,8 @@ import com.fasterxml.jackson.annotation.JsonCreator
|
||||
import com.fasterxml.jackson.annotation.JsonValue
|
||||
import org.gameyfin.app.users.RoleService
|
||||
import java.lang.Enum
|
||||
import kotlin.Int
|
||||
import kotlin.String
|
||||
|
||||
enum class Role(val roleName: String, val powerLevel: Int) {
|
||||
|
||||
@@ -21,12 +23,12 @@ enum class Role(val roleName: String, val powerLevel: Int) {
|
||||
@JsonCreator
|
||||
@JvmStatic
|
||||
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 }
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
class Names {
|
||||
companion object {
|
||||
const val SUPERADMIN = "${RoleService.Companion.INTERNAL_ROLE_PREFIX}SUPERADMIN"
|
||||
const val ADMIN = "${RoleService.Companion.INTERNAL_ROLE_PREFIX}ADMIN"
|
||||
const val USER = "${RoleService.Companion.INTERNAL_ROLE_PREFIX}USER"
|
||||
const val SUPERADMIN = "${RoleService.INTERNAL_ROLE_PREFIX}SUPERADMIN"
|
||||
const val ADMIN = "${RoleService.INTERNAL_ROLE_PREFIX}ADMIN"
|
||||
const val USER = "${RoleService.INTERNAL_ROLE_PREFIX}USER"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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.TokenType
|
||||
import org.gameyfin.app.users.entities.User
|
||||
@@ -23,4 +24,6 @@ class PasswordResetRequestEvent(source: Any, val token: Token<TokenType.Password
|
||||
|
||||
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
|
||||
|
||||
import org.gameyfin.app.config.ConfigService
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.apache.commons.io.FilenameUtils
|
||||
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 java.io.File
|
||||
import java.nio.file.FileSystems
|
||||
|
||||
@@ -1,41 +1,46 @@
|
||||
package org.gameyfin.app.core.plugins
|
||||
|
||||
import com.vaadin.flow.server.auth.AnonymousAllowed
|
||||
import com.vaadin.hilla.Endpoint
|
||||
import jakarta.annotation.security.PermitAll
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
import org.gameyfin.app.core.Role
|
||||
import org.gameyfin.app.core.annotations.DynamicPublicAccess
|
||||
import org.gameyfin.app.core.plugins.dto.PluginUpdateDto
|
||||
import org.gameyfin.app.core.security.isCurrentUserAdmin
|
||||
import org.gameyfin.pluginapi.core.config.PluginConfigValidationResult
|
||||
import reactor.core.publisher.Flux
|
||||
|
||||
@Endpoint
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
@DynamicPublicAccess
|
||||
@AnonymousAllowed
|
||||
class PluginEndpoint(
|
||||
private val pluginService: PluginService,
|
||||
) {
|
||||
|
||||
@PermitAll
|
||||
fun subscribe(): Flux<List<PluginUpdateDto>> {
|
||||
return if (isCurrentUserAdmin()) PluginService.subscribe()
|
||||
else Flux.empty()
|
||||
return PluginService.subscribe()
|
||||
}
|
||||
|
||||
fun getAll() = pluginService.getAll().sortedByDescending { it.priority }
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun enablePlugin(pluginId: String) = pluginService.enablePlugin(pluginId)
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun disablePlugin(pluginId: String) = pluginService.disablePlugin(pluginId)
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun setPluginPriorities(pluginPriorities: Map<String, Int>) =
|
||||
pluginService.setPluginPriorities(pluginPriorities)
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun validatePluginConfig(pluginId: String): PluginConfigValidationResult =
|
||||
pluginService.validatePluginConfig(pluginId, true)
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun validateNewConfig(pluginId: String, config: Map<String, String>): PluginConfigValidationResult =
|
||||
pluginService.validatePluginConfig(pluginId, config)
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun updateConfig(pluginId: String, updatedConfig: Map<String, String>) =
|
||||
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("/library/**").access(DynamicPublicAccessAuthorizationManager(config))
|
||||
.requestMatchers("/search/**").access(DynamicPublicAccessAuthorizationManager(config))
|
||||
.requestMatchers("/requests/**").access(DynamicPublicAccessAuthorizationManager(config))
|
||||
.requestMatchers("/download/**").access(DynamicPublicAccessAuthorizationManager(config))
|
||||
}
|
||||
|
||||
@@ -98,7 +99,7 @@ class SecurityConfig(
|
||||
val clientRegistration = ClientRegistration.withRegistrationId(SSO_PROVIDER_KEY)
|
||||
.clientId(config.get(ConfigProperties.SSO.OIDC.ClientId))
|
||||
.clientSecret(config.get(ConfigProperties.SSO.OIDC.ClientSecret))
|
||||
.scope("openid", "profile", "email")
|
||||
.scope(config.get(ConfigProperties.SSO.OIDC.OAuthScopes)?.toList())
|
||||
.userNameAttributeName("preferred_username")
|
||||
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
|
||||
.issuerUri(config.get(ConfigProperties.SSO.OIDC.IssuerUrl))
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
package org.gameyfin.app.core.security
|
||||
|
||||
import org.gameyfin.app.core.Role
|
||||
import org.springframework.security.core.Authentication
|
||||
import org.springframework.security.core.context.SecurityContextHolder
|
||||
|
||||
fun getCurrentAuth(): Authentication? {
|
||||
return SecurityContextHolder.getContext().authentication
|
||||
}
|
||||
|
||||
fun isCurrentUserAdmin(): Boolean {
|
||||
return SecurityContextHolder.getContext().authentication?.authorities?.any { it.authority == Role.Names.ADMIN || it.authority == Role.Names.SUPERADMIN }
|
||||
?: false
|
||||
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 org.gameyfin.app.core.Role
|
||||
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.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.LibraryService
|
||||
import reactor.core.publisher.Flux
|
||||
@@ -30,6 +34,10 @@ class GameEndpoint(
|
||||
|
||||
fun getAll(): List<GameDto> = gameService.getAll()
|
||||
|
||||
fun getPotentialMatches(searchTerm: String): List<GameSearchResultDto> {
|
||||
return gameService.getPotentialMatches(searchTerm)
|
||||
}
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun updateGame(game: GameUpdateDto) = gameService.edit(game)
|
||||
|
||||
@@ -40,12 +48,12 @@ class GameEndpoint(
|
||||
}
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun getPotentialMatches(searchTerm: String): List<GameSearchResultDto> {
|
||||
return gameService.getPotentialMatches(searchTerm)
|
||||
}
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun matchManually(originalIds: Map<String, OriginalIdDto>, path: String, libraryId: Long, replaceGameId: Long?) {
|
||||
fun matchManually(
|
||||
originalIds: Map<String, ExternalProviderIdDto>,
|
||||
path: String,
|
||||
libraryId: Long,
|
||||
replaceGameId: Long?
|
||||
) {
|
||||
val library = libraryService.getById(libraryId)
|
||||
val game = gameService.matchManually(originalIds, Path.of(path), library, replaceGameId)
|
||||
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.filterValuesNotNull
|
||||
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.GameyfinPluginManager
|
||||
import org.gameyfin.app.core.plugins.management.PluginManagementEntry
|
||||
import org.gameyfin.app.core.replaceRomanNumerals
|
||||
import org.gameyfin.app.core.security.getCurrentAuth
|
||||
import org.gameyfin.app.games.dto.*
|
||||
import org.gameyfin.app.games.entities.*
|
||||
import org.gameyfin.app.games.extensions.toDtos
|
||||
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.users.UserService
|
||||
import org.gameyfin.pluginapi.gamemetadata.*
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.security.core.context.SecurityContextHolder
|
||||
import org.springframework.security.core.userdetails.UserDetails
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -149,11 +150,10 @@ class GameService(
|
||||
val existingGame = gameRepository.findByIdOrNull(gameUpdateDto.id)
|
||||
?: throw IllegalArgumentException("Game with ID $gameUpdateDto.id not found")
|
||||
|
||||
val userDetails = SecurityContextHolder.getContext().authentication.principal
|
||||
val user = when (userDetails) {
|
||||
val user = when (val userDetails = getCurrentAuth()?.principal) {
|
||||
is UserDetails -> userService.getByUsernameNonNull(userDetails.username)
|
||||
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
|
||||
@@ -260,12 +260,12 @@ class GameService(
|
||||
|
||||
val game = getById(game.id!!)
|
||||
|
||||
val originalIds: Map<String, OriginalIdDto> = game.metadata.originalIds
|
||||
val originalIds: Map<String, ExternalProviderIdDto> = game.metadata.originalIds
|
||||
.map { (provider, originalId) ->
|
||||
val providerId = pluginManager.getExtensions(provider.pluginId).first()?.javaClass?.name ?: return null
|
||||
val pluginId = provider.pluginId
|
||||
val originalId = originalId
|
||||
providerId to OriginalIdDto(pluginId, originalId)
|
||||
providerId to ExternalProviderIdDto(pluginId, originalId)
|
||||
}
|
||||
.toMap()
|
||||
|
||||
@@ -504,12 +504,12 @@ class GameService(
|
||||
sorted.mapNotNull { selector(it.second) }.firstOrNull { it.isNotEmpty() }
|
||||
|
||||
// Collect originalIds for this group
|
||||
val originalIds: Map<String, OriginalIdDto> = group
|
||||
val originalIds: Map<String, ExternalProviderIdDto> = group
|
||||
.mapNotNull { (provider, metadata) ->
|
||||
val providerId = provider.javaClass.name
|
||||
val pluginId = providerToManagementEntry[provider]?.pluginId ?: return@mapNotNull null
|
||||
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()
|
||||
|
||||
@@ -567,7 +567,7 @@ class GameService(
|
||||
}
|
||||
|
||||
fun matchManually(
|
||||
originalIds: Map<String, OriginalIdDto>,
|
||||
originalIds: Map<String, ExternalProviderIdDto>,
|
||||
path: Path,
|
||||
library: Library,
|
||||
replaceGameId: Long? = null,
|
||||
@@ -578,7 +578,7 @@ class GameService(
|
||||
coroutineScope {
|
||||
metadataPlugins.associateWith { plugin ->
|
||||
async {
|
||||
val originalId = originalIds[plugin.javaClass.name]?.originalId ?: return@async null
|
||||
val originalId = originalIds[plugin.javaClass.name]?.externalProviderId ?: return@async null
|
||||
try {
|
||||
return@async plugin.fetchById(originalId)
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.gameyfin.app.games.dto
|
||||
|
||||
import org.gameyfin.app.core.plugins.dto.ExternalProviderIdDto
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
@@ -11,18 +12,9 @@ class GameSearchResultDto(
|
||||
val release: Instant?,
|
||||
val publishers: 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(
|
||||
val url: String,
|
||||
val pluginId: String
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package org.gameyfin.app.games.entities
|
||||
|
||||
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.Genre
|
||||
import org.gameyfin.pluginapi.gamemetadata.PlayerPerspective
|
||||
|
||||
@@ -3,28 +3,32 @@ package org.gameyfin.app.games.entities
|
||||
import jakarta.persistence.PostPersist
|
||||
import jakarta.persistence.PostRemove
|
||||
import jakarta.persistence.PostUpdate
|
||||
import org.gameyfin.app.core.events.GameCreatedEvent
|
||||
import org.gameyfin.app.games.GameService
|
||||
import org.gameyfin.app.games.dto.GameAdminEvent
|
||||
import org.gameyfin.app.games.dto.GameUserEvent
|
||||
import org.gameyfin.app.games.extensions.toAdminDto
|
||||
import org.gameyfin.app.games.extensions.toUserDto
|
||||
import org.gameyfin.app.util.EventPublisherHolder
|
||||
|
||||
class GameEntityListener {
|
||||
|
||||
@PostPersist
|
||||
fun created(game: Game) {
|
||||
GameService.Companion.emitUser(GameUserEvent.Created(game.toUserDto()))
|
||||
GameService.Companion.emitAdmin(GameAdminEvent.Created(game.toAdminDto()))
|
||||
GameService.emitUser(GameUserEvent.Created(game.toUserDto()))
|
||||
GameService.emitAdmin(GameAdminEvent.Created(game.toAdminDto()))
|
||||
EventPublisherHolder.publish(GameCreatedEvent(this, game))
|
||||
}
|
||||
|
||||
@PostUpdate
|
||||
fun updated(game: Game) {
|
||||
GameService.Companion.emitUser(GameUserEvent.Updated(game.toUserDto()))
|
||||
GameService.Companion.emitAdmin(GameAdminEvent.Updated(game.toAdminDto()))
|
||||
GameService.emitUser(GameUserEvent.Updated(game.toUserDto()))
|
||||
GameService.emitAdmin(GameAdminEvent.Updated(game.toAdminDto()))
|
||||
}
|
||||
|
||||
@PostRemove
|
||||
fun deleted(game: Game) {
|
||||
GameService.Companion.emitUser(GameUserEvent.Deleted(game.id!!))
|
||||
GameService.Companion.emitAdmin(GameAdminEvent.Deleted(game.id!!))
|
||||
GameService.emitUser(GameUserEvent.Deleted(game.id!!))
|
||||
GameService.emitAdmin(GameAdminEvent.Deleted(game.id!!))
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
package org.gameyfin.app.games.entities
|
||||
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.GeneratedValue
|
||||
import jakarta.persistence.GenerationType
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.*
|
||||
import org.springframework.content.commons.annotations.ContentId
|
||||
import org.springframework.content.commons.annotations.ContentLength
|
||||
import org.springframework.content.commons.annotations.MimeType
|
||||
import java.net.URL
|
||||
|
||||
@Entity
|
||||
@EntityListeners(ImageEntityListener::class)
|
||||
class Image(
|
||||
@Id
|
||||
@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.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.entities.Game
|
||||
import org.gameyfin.app.libraries.entities.Library
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.Instant
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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.Query
|
||||
|
||||
|
||||
@@ -4,15 +4,16 @@ import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.gameyfin.app.core.filesystem.FilesystemService
|
||||
import org.gameyfin.app.games.GameService
|
||||
import org.gameyfin.app.games.entities.Game
|
||||
import org.gameyfin.app.libraries.dto.LibraryScanProgress
|
||||
import org.gameyfin.app.libraries.dto.LibraryScanStatus
|
||||
import org.gameyfin.app.libraries.dto.LibraryScanStep
|
||||
import org.gameyfin.app.games.entities.Image
|
||||
import org.gameyfin.app.libraries.dto.*
|
||||
import org.gameyfin.app.libraries.entities.Library
|
||||
import org.gameyfin.app.libraries.enums.ScanType
|
||||
import org.gameyfin.app.libraries.scan.*
|
||||
import org.gameyfin.app.media.ImageService
|
||||
import org.springframework.stereotype.Service
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Sinks
|
||||
import java.net.URL
|
||||
import java.nio.file.Path
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.Callable
|
||||
@@ -331,39 +332,56 @@ class LibraryScanService(
|
||||
private fun downloadImages(games: List<Game>, progress: LibraryScanProgress): DownloadImagesResult {
|
||||
val completedImageDownload = AtomicInteger(0)
|
||||
|
||||
val imageDownloadTasks = games.map { game ->
|
||||
Callable<Game?> {
|
||||
// Collect all images from all games in the batch
|
||||
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 {
|
||||
game.coverImage?.let {
|
||||
imageService.downloadIfNew(it)
|
||||
completedImageDownload.andIncrement
|
||||
imageService.downloadIfNew(image)
|
||||
image.originalUrl?.let { url ->
|
||||
downloadedImageMap[url] = image
|
||||
}
|
||||
|
||||
game.headerImage?.let {
|
||||
imageService.downloadIfNew(it)
|
||||
completedImageDownload.andIncrement
|
||||
}
|
||||
|
||||
game.images.map {
|
||||
imageService.downloadIfNew(it)
|
||||
completedImageDownload.andIncrement
|
||||
}
|
||||
|
||||
game
|
||||
} 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) {}
|
||||
null
|
||||
} finally {
|
||||
progress.currentStep.current = completedImageDownload.get()
|
||||
progress.currentStep.current = completedImageDownload.incrementAndGet()
|
||||
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 {
|
||||
|
||||
@@ -3,6 +3,8 @@ package org.gameyfin.app.libraries
|
||||
import com.vaadin.hilla.exception.EndpointException
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
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.extensions.toDtos
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.gameyfin.app.libraries.dto
|
||||
|
||||
import org.gameyfin.app.libraries.LibraryScanResult
|
||||
import org.gameyfin.app.libraries.enums.ScanType
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package org.gameyfin.app.libraries
|
||||
package org.gameyfin.app.libraries.dto
|
||||
|
||||
interface LibraryScanResult {
|
||||
/**
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package org.gameyfin.app.libraries
|
||||
package org.gameyfin.app.libraries.entities
|
||||
|
||||
import jakarta.persistence.*
|
||||
|
||||
+2
-3
@@ -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 org.gameyfin.app.games.entities.LibraryEntityListener
|
||||
import org.gameyfin.app.games.entities.Game
|
||||
import org.hibernate.annotations.CreationTimestamp
|
||||
import org.hibernate.annotations.UpdateTimestamp
|
||||
import java.time.Instant
|
||||
+1
-2
@@ -1,9 +1,8 @@
|
||||
package org.gameyfin.app.games.entities
|
||||
package org.gameyfin.app.libraries.entities
|
||||
|
||||
import jakarta.persistence.PostPersist
|
||||
import jakarta.persistence.PostRemove
|
||||
import jakarta.persistence.PostUpdate
|
||||
import org.gameyfin.app.libraries.Library
|
||||
import org.gameyfin.app.libraries.LibraryService
|
||||
import org.gameyfin.app.libraries.dto.LibraryAdminEvent
|
||||
import org.gameyfin.app.libraries.dto.LibraryUserEvent
|
||||
@@ -1,8 +1,8 @@
|
||||
package org.gameyfin.app.libraries.extensions
|
||||
|
||||
import org.gameyfin.app.core.security.isCurrentUserAdmin
|
||||
import org.gameyfin.app.libraries.Library
|
||||
import org.gameyfin.app.libraries.dto.*
|
||||
import org.gameyfin.app.libraries.entities.Library
|
||||
|
||||
|
||||
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.annotations.DynamicPublicAccess
|
||||
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.ImageType
|
||||
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.MediaType
|
||||
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.multipart.MultipartFile
|
||||
|
||||
@@ -61,7 +60,7 @@ class ImageEndpoint(
|
||||
@PermitAll
|
||||
@PostMapping("/avatar/upload")
|
||||
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)) {
|
||||
imageService.createFile(ImageType.AVATAR, file.inputStream, file.contentType!!)
|
||||
@@ -76,7 +75,7 @@ class ImageEndpoint(
|
||||
@PermitAll
|
||||
@PostMapping("/avatar/delete")
|
||||
fun deleteAvatar() {
|
||||
val auth: Authentication = SecurityContextHolder.getContext().authentication
|
||||
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||
userService.deleteAvatar(auth.name)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.gameyfin.app.messages
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
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.templates.MessageTemplateService
|
||||
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.event.EventListener
|
||||
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 java.util.*
|
||||
|
||||
@@ -61,7 +60,7 @@ class MessageService(
|
||||
}
|
||||
|
||||
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 template = templateService.getMessageTemplate(templateKey)
|
||||
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.hilla.Endpoint
|
||||
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
|
||||
|
||||
@Endpoint
|
||||
@@ -16,7 +16,7 @@ class SetupEndpoint(
|
||||
}
|
||||
|
||||
@AnonymousAllowed
|
||||
fun registerSuperAdmin(superAdminRegistration: UserRegistrationDto): UserInfoDto {
|
||||
fun registerSuperAdmin(superAdminRegistration: UserRegistrationDto): ExtendedUserInfoDto {
|
||||
if (setupService.isSetupCompleted()) throw EndpointException("Setup already completed")
|
||||
return setupService.createInitialAdminUser(superAdminRegistration)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package org.gameyfin.app.setup
|
||||
|
||||
import org.gameyfin.app.users.UserService
|
||||
import org.gameyfin.app.core.Role
|
||||
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.entities.User
|
||||
import org.gameyfin.app.users.extensions.toExtendedUserInfoDto
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
@@ -26,7 +27,7 @@ class SetupService(
|
||||
/**
|
||||
* Creates the initial user with Super-Admin permissions
|
||||
*/
|
||||
fun createInitialAdminUser(registration: UserRegistrationDto): UserInfoDto {
|
||||
fun createInitialAdminUser(registration: UserRegistrationDto): ExtendedUserInfoDto {
|
||||
val superAdmin = User(
|
||||
username = registration.username,
|
||||
password = registration.password,
|
||||
@@ -36,6 +37,6 @@ class SetupService(
|
||||
)
|
||||
|
||||
val user = userService.registerOrUpdateUser(superAdmin)
|
||||
return userService.toUserInfo(user)
|
||||
return user.toExtendedUserInfoDto()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
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.users.entities.User
|
||||
import org.gameyfin.app.users.persistence.UserRepository
|
||||
@@ -11,7 +13,8 @@ import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
class RoleService(
|
||||
private val userRepository: UserRepository
|
||||
private val userRepository: UserRepository,
|
||||
private val configService: ConfigService
|
||||
) {
|
||||
|
||||
companion object {
|
||||
@@ -66,7 +69,8 @@ class RoleService(
|
||||
.filterIsInstance<OidcUserAuthority>()
|
||||
.flatMap { oidcUserAuthority ->
|
||||
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 {
|
||||
if (it.startsWith(SSO_ROLE_PREFIX)) SimpleGrantedAuthority(
|
||||
it.replace(SSO_ROLE_PREFIX, INTERNAL_ROLE_PREFIX)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package org.gameyfin.app.users
|
||||
|
||||
import org.gameyfin.app.core.security.getCurrentAuth
|
||||
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.session.SessionInformation
|
||||
import org.springframework.security.core.session.SessionRegistry
|
||||
@@ -11,14 +11,12 @@ import org.springframework.stereotype.Service
|
||||
class SessionService(private val sessionRegistry: SessionRegistry) {
|
||||
|
||||
fun logoutAllSessions() {
|
||||
val auth: Authentication? = SecurityContextHolder.getContext().authentication
|
||||
if (auth != null) {
|
||||
val sessions: List<SessionInformation> = sessionRegistry.getAllSessions(auth.principal, false)
|
||||
for (sessionInfo in sessions) {
|
||||
sessionInfo.expireNow()
|
||||
}
|
||||
SecurityContextHolder.clearContext()
|
||||
val auth = getCurrentAuth()
|
||||
val sessions: List<SessionInformation> = sessionRegistry.getAllSessions(auth?.principal, false)
|
||||
for (sessionInfo in sessions) {
|
||||
sessionInfo.expireNow()
|
||||
}
|
||||
SecurityContextHolder.clearContext()
|
||||
}
|
||||
|
||||
fun logoutAllSessions(user: User) {
|
||||
|
||||
@@ -5,11 +5,11 @@ import com.vaadin.hilla.Endpoint
|
||||
import jakarta.annotation.security.PermitAll
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
import org.gameyfin.app.core.Role
|
||||
import org.gameyfin.app.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.enums.RoleAssignmentResult
|
||||
import org.springframework.security.core.Authentication
|
||||
import org.springframework.security.core.context.SecurityContextHolder
|
||||
|
||||
|
||||
@Endpoint
|
||||
@@ -18,15 +18,15 @@ class UserEndpoint(
|
||||
private val roleService: RoleService
|
||||
) {
|
||||
@AnonymousAllowed
|
||||
fun getUserInfo(): UserInfoDto? {
|
||||
val auth = SecurityContextHolder.getContext().authentication
|
||||
if (!auth.isAuthenticated || auth.principal == "anonymousUser") return null
|
||||
fun getUserInfo(): ExtendedUserInfoDto? {
|
||||
val auth = getCurrentAuth()
|
||||
if (auth?.isAuthenticated == false || auth?.principal == "anonymousUser") return null
|
||||
return userService.getUserInfo()
|
||||
}
|
||||
|
||||
@PermitAll
|
||||
fun updateUser(updates: UserUpdateDto) {
|
||||
val auth: Authentication = SecurityContextHolder.getContext().authentication
|
||||
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||
userService.updateUser(auth.name, updates)
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ class UserEndpoint(
|
||||
}
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
fun getAllUsers(): List<UserInfoDto> {
|
||||
fun getAllUsers(): List<ExtendedUserInfoDto> {
|
||||
return userService.getAllUsers()
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ class UserEndpoint(
|
||||
|
||||
@PermitAll
|
||||
fun deleteUser() {
|
||||
val auth: Authentication = SecurityContextHolder.getContext().authentication
|
||||
val auth: Authentication = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||
userService.deleteUser(auth.name)
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ class UserEndpoint(
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -6,18 +6,18 @@ import org.gameyfin.app.config.ConfigService
|
||||
import org.gameyfin.app.core.Role
|
||||
import org.gameyfin.app.core.Utils
|
||||
import org.gameyfin.app.core.events.*
|
||||
import org.gameyfin.app.core.security.getCurrentAuth
|
||||
import org.gameyfin.app.games.entities.Image
|
||||
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.UserUpdateDto
|
||||
import org.gameyfin.app.users.emailconfirmation.EmailConfirmationService
|
||||
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.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.UserDetails
|
||||
import org.springframework.security.core.userdetails.UserDetailsService
|
||||
@@ -56,7 +56,7 @@ class UserService(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
toAuthorities(user.roles)
|
||||
user.roles.toAuthorities()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ class UserService(
|
||||
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? =
|
||||
userRepository.findByOidcProviderId(oidcProviderId)
|
||||
|
||||
fun getAllUsers(): List<UserInfoDto> {
|
||||
return userRepository.findAll().map { u -> toUserInfo(u) }
|
||||
fun getAllUsers(): List<ExtendedUserInfoDto> {
|
||||
return userRepository.findAll().map { it.toExtendedUserInfoDto() }
|
||||
}
|
||||
|
||||
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'")
|
||||
}
|
||||
|
||||
fun getUserInfo(): UserInfoDto {
|
||||
val auth = SecurityContextHolder.getContext().authentication
|
||||
fun getUserInfo(): ExtendedUserInfoDto {
|
||||
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||
val principal = auth.principal
|
||||
|
||||
if (principal is OidcUser) {
|
||||
val oidcUser = org.gameyfin.app.users.entities.User(principal)
|
||||
val userInfoDto = toUserInfo(oidcUser)
|
||||
val userInfoDto = oidcUser.toExtendedUserInfoDto()
|
||||
userInfoDto.roles = roleService.extractGrantedAuthorities(principal.authorities)
|
||||
.mapNotNull { Role.Companion.safeValueOf(it.authority) }
|
||||
.mapNotNull { Role.safeValueOf(it.authority) }
|
||||
return userInfoDto
|
||||
}
|
||||
|
||||
val user = getByUsernameNonNull(auth.name)
|
||||
return toUserInfo(user)
|
||||
return user.toExtendedUserInfoDto()
|
||||
}
|
||||
|
||||
fun getAvatar(username: String): Image? {
|
||||
@@ -158,7 +158,7 @@ class UserService(
|
||||
RegistrationAttemptWithExistingEmailEvent(
|
||||
this,
|
||||
it,
|
||||
Utils.Companion.getBaseUrl()
|
||||
Utils.getBaseUrl()
|
||||
)
|
||||
)
|
||||
return
|
||||
@@ -179,12 +179,12 @@ class UserService(
|
||||
if (adminNeedsToApprove) {
|
||||
eventPublisher.publishEvent(UserRegistrationWaitingForApprovalEvent(this, user))
|
||||
} else {
|
||||
eventPublisher.publishEvent(AccountStatusChangedEvent(this, user, Utils.Companion.getBaseUrl()))
|
||||
eventPublisher.publishEvent(AccountStatusChangedEvent(this, user, Utils.getBaseUrl()))
|
||||
}
|
||||
|
||||
if (!user.emailConfirmed) {
|
||||
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.emailConfirmed = false
|
||||
val token = emailConfirmationService.generate(user)
|
||||
eventPublisher.publishEvent(EmailNeedsConfirmationEvent(this, token, Utils.Companion.getBaseUrl()))
|
||||
eventPublisher.publishEvent(EmailNeedsConfirmationEvent(this, token, Utils.getBaseUrl()))
|
||||
}
|
||||
|
||||
userRepository.save(user)
|
||||
@@ -238,7 +238,7 @@ class UserService(
|
||||
return RoleAssignmentResult.NO_ROLES_PROVIDED
|
||||
}
|
||||
|
||||
val currentUser = SecurityContextHolder.getContext().authentication
|
||||
val currentUser = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||
val targetUser = getByUsernameNonNull(username)
|
||||
|
||||
if (!canManage(targetUser)) {
|
||||
@@ -246,7 +246,7 @@ class UserService(
|
||||
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 currentUserLevel = roleService.getHighestRoleFromAuthorities(currentUser.authorities).powerLevel
|
||||
|
||||
@@ -266,7 +266,7 @@ class UserService(
|
||||
}
|
||||
|
||||
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 targetUserLevel = roleService.getHighestRole(targetUser.roles).powerLevel
|
||||
return currentUserLevel > targetUserLevel
|
||||
@@ -276,29 +276,12 @@ class UserService(
|
||||
val user = getByUsernameNonNull(username)
|
||||
user.enabled = enabled
|
||||
userRepository.save(user)
|
||||
eventPublisher.publishEvent(AccountStatusChangedEvent(this, user, Utils.Companion.getBaseUrl()))
|
||||
eventPublisher.publishEvent(AccountStatusChangedEvent(this, user, Utils.getBaseUrl()))
|
||||
}
|
||||
|
||||
fun deleteUser(username: String) {
|
||||
val user = getByUsernameNonNull(username)
|
||||
userRepository.delete(user)
|
||||
eventPublisher.publishEvent(AccountDeletedEvent(this, user, Utils.Companion.getBaseUrl()))
|
||||
}
|
||||
|
||||
fun toUserInfo(user: org.gameyfin.app.users.entities.User): UserInfoDto {
|
||||
return UserInfoDto(
|
||||
username = user.username,
|
||||
email = user.email,
|
||||
emailConfirmed = user.emailConfirmed,
|
||||
enabled = user.enabled,
|
||||
hasAvatar = user.avatar != null,
|
||||
avatarId = user.avatar?.id,
|
||||
managedBySso = user.oidcProviderId != null,
|
||||
roles = user.roles
|
||||
)
|
||||
}
|
||||
|
||||
private fun toAuthorities(roles: Collection<Role>): List<GrantedAuthority> {
|
||||
return roles.map { r -> SimpleGrantedAuthority(r.roleName) }
|
||||
eventPublisher.publishEvent(AccountDeletedEvent(this, user, Utils.getBaseUrl()))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
import org.gameyfin.app.core.Role
|
||||
|
||||
data class UserInfoDto(
|
||||
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>
|
||||
)
|
||||
+3
-4
@@ -1,11 +1,10 @@
|
||||
package org.gameyfin.app.users.emailconfirmation
|
||||
|
||||
import com.vaadin.hilla.Endpoint
|
||||
import org.gameyfin.app.users.UserService
|
||||
import jakarta.annotation.security.PermitAll
|
||||
import org.gameyfin.app.core.security.getCurrentAuth
|
||||
import org.gameyfin.app.shared.token.TokenValidationResult
|
||||
import org.springframework.security.core.Authentication
|
||||
import org.springframework.security.core.context.SecurityContextHolder
|
||||
import org.gameyfin.app.users.UserService
|
||||
|
||||
@Endpoint
|
||||
class EmailConfirmationEndpoint(
|
||||
@@ -20,7 +19,7 @@ class EmailConfirmationEndpoint(
|
||||
|
||||
@PermitAll
|
||||
fun resendEmailConfirmation() {
|
||||
val auth: Authentication = SecurityContextHolder.getContext().authentication
|
||||
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
|
||||
userService.getByUsername(auth.name)?.let {
|
||||
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
|
||||
|
||||
import org.gameyfin.app.users.UserService
|
||||
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 java.io.Serializable
|
||||
|
||||
@@ -135,7 +135,7 @@ class UserPreferencesService(
|
||||
}
|
||||
|
||||
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)
|
||||
return UserPreferenceKey(key, user.id!!)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
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.UserInvitationEvent
|
||||
import org.gameyfin.app.core.security.getCurrentAuth
|
||||
import org.gameyfin.app.shared.token.TokenDto
|
||||
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.TokenType
|
||||
import org.gameyfin.app.users.UserService
|
||||
import org.gameyfin.app.users.dto.UserRegistrationDto
|
||||
import org.gameyfin.app.users.enums.UserInvitationAcceptanceResult
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.security.core.Authentication
|
||||
import org.springframework.security.core.context.SecurityContextHolder
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
@@ -30,7 +29,7 @@ class InvitationService(
|
||||
if (userService.existsByEmail(email))
|
||||
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 payload = mapOf(EMAIL_KEY to email)
|
||||
val token = super.generateWithPayload(user, payload)
|
||||
@@ -45,7 +44,8 @@ class InvitationService(
|
||||
}
|
||||
|
||||
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
|
||||
if (invitationToken.expired) return UserInvitationAcceptanceResult.TOKEN_EXPIRED
|
||||
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
|
||||
import java.nio.file.Files
|
||||
|
||||
group = "org.gameyfin"
|
||||
version = "2.0.0"
|
||||
version = "2.0.1"
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -5,7 +5,7 @@ org.gradle.caching=true
|
||||
# Plugin versions
|
||||
kotlinVersion=2.2.0
|
||||
kspVersion=2.2.0-2.0.2
|
||||
vaadinVersion=24.8.3
|
||||
vaadinVersion=24.8.7
|
||||
springBootVersion=3.5.3
|
||||
springCloudVersion=2025.0.0
|
||||
springDependencyManagementVersion=1.1.7
|
||||
|
||||
+1
-1
@@ -150,7 +150,7 @@ class TorrentDownloadPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin
|
||||
|
||||
private fun getTrackerUri(): URI {
|
||||
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 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-Id: org.gameyfin.plugins.download.torrent
|
||||
Plugin-Name: Torrent Download
|
||||
|
||||
Reference in New Issue
Block a user