mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
WIP: Implement config in Frontend
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
<option name="executionName" />
|
<option name="executionName" />
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
<option name="externalSystemIdString" value="GRADLE" />
|
<option name="externalSystemIdString" value="GRADLE" />
|
||||||
<option name="scriptParameters" value="--stacktrace" />
|
<option name="scriptParameters" value="" />
|
||||||
<option name="taskDescriptions">
|
<option name="taskDescriptions">
|
||||||
<list />
|
<list />
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
<component name="ProjectRunConfigurationManager">
|
||||||
<configuration default="false" name="UI debug" type="JavascriptDebugType" engineId="37cae5b9-e8b2-4949-9172-aafa37fbc09c" uri="http://localhost:8080" useFirstLineBreakpoints="true">
|
<configuration default="false" name="UI debug" type="JavascriptDebugType" uri="http://localhost:8080" useFirstLineBreakpoints="true">
|
||||||
<method v="2" />
|
<method v="2" />
|
||||||
</configuration>
|
</configuration>
|
||||||
</component>
|
</component>
|
||||||
Generated
-15631
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
|||||||
|
import {useField} from "formik";
|
||||||
|
import {Checkbox} from "@nextui-org/react";
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const CheckboxInput = ({label, ...props}) => {
|
||||||
|
// @ts-ignore
|
||||||
|
const [field] = useField(props);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row flex-grow items-center gap-2 my-2">
|
||||||
|
<Checkbox
|
||||||
|
{...field}
|
||||||
|
id={field.name}>
|
||||||
|
{label}
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CheckboxInput;
|
||||||
@@ -8,12 +8,12 @@ const Input = ({label, ...props}) => {
|
|||||||
const [field, meta] = useField(props);
|
const [field, meta] = useField(props);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid w-full max-w-sm items-center gap-1.5">
|
<div className="grid w-full max-w-sm items-center gap-2 my-2">
|
||||||
<NextUiInput
|
<NextUiInput
|
||||||
{...props}
|
{...props}
|
||||||
{...field}
|
{...field}
|
||||||
id={label}
|
id={label}
|
||||||
placeholder={label}
|
label={label}
|
||||||
isInvalid={meta.touched && !!meta.error}
|
isInvalid={meta.touched && !!meta.error}
|
||||||
errorMessage={
|
errorMessage={
|
||||||
<small className="flex flex-row items-center gap-1 text-danger">
|
<small className="flex flex-row items-center gap-1 text-danger">
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ export default function ProfileMenu() {
|
|||||||
{
|
{
|
||||||
label: "My Profile",
|
label: "My Profile",
|
||||||
icon: <User/>,
|
icon: <User/>,
|
||||||
onClick: () => navigate('/profile')
|
onClick: () => navigate('/profile/')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Administration",
|
label: "Administration",
|
||||||
icon: <GearFine/>,
|
icon: <GearFine/>,
|
||||||
onClick: () => alert("Administration"),
|
onClick: () => navigate("/administration/libraries"),
|
||||||
showIf: state.user?.roles?.some(a => a?.includes("ADMIN"))
|
showIf: state.user?.roles?.some(a => a?.includes("ADMIN"))
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import ConfigEntryDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigEntryDto";
|
||||||
|
import React from "react";
|
||||||
|
import Input from "Frontend/components/Input";
|
||||||
|
import CheckboxInput from "Frontend/components/CheckboxInput";
|
||||||
|
|
||||||
|
export default function ConfigFormField({configElement}: {
|
||||||
|
configElement: ConfigEntryDto | undefined
|
||||||
|
}) {
|
||||||
|
function inputElement(configElement: ConfigEntryDto) {
|
||||||
|
switch (configElement.type) {
|
||||||
|
case "Boolean":
|
||||||
|
return (
|
||||||
|
<CheckboxInput label={configElement.description} name={configElement.key}/>
|
||||||
|
);
|
||||||
|
case "String":
|
||||||
|
return (
|
||||||
|
<Input label={configElement.description} name={configElement.key} type="text"/>
|
||||||
|
);
|
||||||
|
case "Int" || "Float":
|
||||||
|
return (
|
||||||
|
<Input label={configElement.description} name={configElement.key} type="number"/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <pre>Unsupported type: {configElement.type} for key {configElement.key}</pre>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (inputElement(configElement!));
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import React, {useEffect, useRef, useState} from "react";
|
||||||
|
import {ConfigController} from "Frontend/generated/endpoints";
|
||||||
|
import ConfigEntryDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigEntryDto";
|
||||||
|
import {Form, Formik} from "formik";
|
||||||
|
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
||||||
|
import {Button, Divider, Skeleton} from "@nextui-org/react";
|
||||||
|
import {toast} from "sonner";
|
||||||
|
|
||||||
|
type NestedConfig = {
|
||||||
|
[field: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfigValuePair = {
|
||||||
|
key: string;
|
||||||
|
value: string | number | boolean | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LibraryManagement() {
|
||||||
|
const isInitialized = useRef(false);
|
||||||
|
const [configDtos, setConfigDtos] = useState<ConfigEntryDto[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ConfigController.getConfigs("library").then((response: any) => {
|
||||||
|
setConfigDtos(response as ConfigEntryDto[]);
|
||||||
|
isInitialized.current = true;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleSubmit(values: NestedConfig) {
|
||||||
|
const configValues = toConfigValuePair(values);
|
||||||
|
await Promise.all(configValues.map(async (c: ConfigValuePair) => {
|
||||||
|
if (c.value === null || c.value === undefined) {
|
||||||
|
await ConfigController.deleteConfig(c.key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ConfigController.setConfig(c.key, c.value.toString());
|
||||||
|
}));
|
||||||
|
|
||||||
|
toast.success("Configuration saved");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfig(key: string) {
|
||||||
|
return configDtos.find((configDto: ConfigEntryDto) => configDto.key === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNestedConfig(configArray: ConfigEntryDto[]): NestedConfig {
|
||||||
|
const nestedConfig: NestedConfig = {};
|
||||||
|
|
||||||
|
configArray.forEach(item => {
|
||||||
|
const keys = item.key!.split('.');
|
||||||
|
let currentLevel = nestedConfig;
|
||||||
|
|
||||||
|
// Traverse the nested structure and create objects as needed
|
||||||
|
keys.forEach((key, index) => {
|
||||||
|
if (index === keys.length - 1) {
|
||||||
|
// Convert value to the appropriate type
|
||||||
|
let value: any;
|
||||||
|
switch (item.type) {
|
||||||
|
case 'Boolean':
|
||||||
|
value = item.value === 'true';
|
||||||
|
break;
|
||||||
|
case 'Int':
|
||||||
|
value = parseInt(item.value!);
|
||||||
|
break;
|
||||||
|
case 'Float':
|
||||||
|
value = parseFloat(item.value!);
|
||||||
|
break;
|
||||||
|
case 'String':
|
||||||
|
default:
|
||||||
|
value = item.value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
currentLevel[key] = value;
|
||||||
|
} else {
|
||||||
|
if (!currentLevel[key]) {
|
||||||
|
currentLevel[key] = {};
|
||||||
|
}
|
||||||
|
currentLevel = currentLevel[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return nestedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toConfigValuePair(obj: NestedConfig, parentKey: string = ''): ConfigValuePair[] {
|
||||||
|
let result: ConfigValuePair[] = [];
|
||||||
|
|
||||||
|
for (const key in obj) {
|
||||||
|
if (obj.hasOwnProperty(key)) {
|
||||||
|
const newKey = parentKey ? `${parentKey}.${key}` : key;
|
||||||
|
if (typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
|
||||||
|
result = result.concat(toConfigValuePair(obj[key], newKey));
|
||||||
|
} else {
|
||||||
|
result.push({key: newKey, value: obj[key]});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isInitialized.current) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Skeleton className="h-3 w-3/5 rounded-md"/>
|
||||||
|
<Skeleton className="h-3 w-4/5 rounded-md"/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
initialValues={toNestedConfig(configDtos)}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
{(formik: { values: any; isSubmitting: any; }) => (
|
||||||
|
<Form>
|
||||||
|
<div className="flex flex-row flex-grow justify-between mb-8">
|
||||||
|
<h1 className="text-2xl font-bold">Library Management</h1>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
isLoading={formik.isSubmitting}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{formik.isSubmitting ? "" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="mb-8 flex flex-col flex-grow">
|
||||||
|
|
||||||
|
<ConfigFormField configElement={getConfig("library.allow-public-access")}></ConfigFormField>
|
||||||
|
<ConfigFormField
|
||||||
|
configElement={getConfig("library.scan.enable-filesystem-watcher")}></ConfigFormField>
|
||||||
|
|
||||||
|
<h2 className="text-xl font-bold mt-4">Metadata</h2>
|
||||||
|
<Divider/>
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<ConfigFormField
|
||||||
|
configElement={getConfig("library.metadata.update.enabled")}></ConfigFormField>
|
||||||
|
<ConfigFormField
|
||||||
|
configElement={getConfig("library.metadata.update.schedule")}></ConfigFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfigFormField
|
||||||
|
configElement={getConfig("library.display.games-per-page")}></ConfigFormField>
|
||||||
|
</div>
|
||||||
|
<pre>{JSON.stringify(formik.values, null, 2)}</pre>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -52,7 +52,6 @@ const Wizard = ({children, initialValues, onSubmit}: {
|
|||||||
{(formik: { values: any; isSubmitting: any; }) => (
|
{(formik: { values: any; isSubmitting: any; }) => (
|
||||||
<Form className="flex flex-col h-full">
|
<Form className="flex flex-col h-full">
|
||||||
<div className="w-full mb-8">
|
<div className="w-full mb-8">
|
||||||
{/*<p>Step {stepNumber + 1} of {steps.length}</p>*/}
|
|
||||||
<Stepper activeStep={stepNumber} activeLineClassName="bg-primary"
|
<Stepper activeStep={stepNumber} activeLineClassName="bg-primary"
|
||||||
lineClassName="bg-foreground"
|
lineClassName="bg-foreground"
|
||||||
placeholder={undefined}
|
placeholder={undefined}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import SetupView from "Frontend/views/SetupView";
|
|||||||
import ProfileView from "Frontend/views/ProfileView";
|
import ProfileView from "Frontend/views/ProfileView";
|
||||||
import {ThemeSelector} from "Frontend/components/theming/ThemeSelector";
|
import {ThemeSelector} from "Frontend/components/theming/ThemeSelector";
|
||||||
import App from "Frontend/App";
|
import App from "Frontend/App";
|
||||||
|
import AdministrationView from "Frontend/views/AdministrationView";
|
||||||
|
import {LibraryManagement} from "Frontend/components/administration/LibraryManagement";
|
||||||
|
|
||||||
export const routes = protectRoutes([
|
export const routes = protectRoutes([
|
||||||
{
|
{
|
||||||
@@ -26,6 +28,13 @@ export const routes = protectRoutes([
|
|||||||
children: [
|
children: [
|
||||||
{path: 'appearance', element: <ThemeSelector/>}
|
{path: 'appearance', element: <ThemeSelector/>}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'administration',
|
||||||
|
element: <AdministrationView/>,
|
||||||
|
children: [
|
||||||
|
{path: 'libraries', element: <LibraryManagement/>},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import {useEffect, useRef} from "react";
|
||||||
|
|
||||||
|
export default function useUpdateEffect(effect: Function, dependencies?: [any]) {
|
||||||
|
const isInitialMount = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInitialMount.current) {
|
||||||
|
isInitialMount.current = false;
|
||||||
|
} else {
|
||||||
|
return effect();
|
||||||
|
}
|
||||||
|
}, dependencies);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {Outlet, useNavigate} from "react-router-dom";
|
||||||
|
import {Envelope, GameController, Users} from "@phosphor-icons/react";
|
||||||
|
import {Listbox, ListboxItem} from "@nextui-org/react";
|
||||||
|
|
||||||
|
export default function AdministrationView() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
title: "Libraries",
|
||||||
|
key: "libraries",
|
||||||
|
icon: <GameController/>,
|
||||||
|
action: () => navigate('libraries')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Users",
|
||||||
|
key: "users",
|
||||||
|
icon: <Users/>,
|
||||||
|
action: () => navigate('users')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Notifications",
|
||||||
|
icon: <Envelope/>,
|
||||||
|
key: "notifications",
|
||||||
|
action: () => navigate('notifications')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row">
|
||||||
|
<div className="flex flex-col pr-8">
|
||||||
|
<Listbox className="min-w-60">
|
||||||
|
{menuItems.map((i) => (
|
||||||
|
<ListboxItem key={i.key} onPress={i.action} startContent={i.icon}>
|
||||||
|
{i.title}
|
||||||
|
</ListboxItem>
|
||||||
|
))}
|
||||||
|
</Listbox>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col flex-grow">
|
||||||
|
<Outlet/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import {useState} from "react";
|
|||||||
|
|
||||||
export default function ProfileView() {
|
export default function ProfileView() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [selectedKeys, setSelectedKeys] = useState(new Set(["profile"]));
|
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.grimsi.gameyfin.config
|
package de.grimsi.gameyfin.config
|
||||||
|
|
||||||
import com.vaadin.hilla.Endpoint
|
import com.vaadin.hilla.Endpoint
|
||||||
|
import de.grimsi.gameyfin.config.dto.ConfigEntryDto
|
||||||
import de.grimsi.gameyfin.meta.Roles
|
import de.grimsi.gameyfin.meta.Roles
|
||||||
import jakarta.annotation.security.RolesAllowed
|
import jakarta.annotation.security.RolesAllowed
|
||||||
|
|
||||||
@@ -10,12 +11,16 @@ class ConfigController(
|
|||||||
private val appConfigService: ConfigService
|
private val appConfigService: ConfigService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
fun getConfigs(prefix: String?): List<ConfigEntryDto> {
|
||||||
|
return appConfigService.getAllConfigValues(prefix)
|
||||||
|
}
|
||||||
|
|
||||||
fun getConfig(key: String): String {
|
fun getConfig(key: String): String {
|
||||||
return appConfigService.getConfigValue(key)
|
return appConfigService.getConfigValue(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setConfig(config: Pair<String, String>) {
|
fun setConfig(key: String, value: String) {
|
||||||
appConfigService.setConfigValue(config.first, config.second)
|
appConfigService.setConfigValue(key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resetConfig(key: String) {
|
fun resetConfig(key: String) {
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package de.grimsi.gameyfin.config
|
||||||
|
|
||||||
|
import java.io.Serializable
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
sealed class ConfigProperties<T : Serializable>(
|
||||||
|
val type: KClass<T>,
|
||||||
|
val key: String,
|
||||||
|
val description: String,
|
||||||
|
val default: T? = null
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Libraries */
|
||||||
|
data object LibraryAllowPublicAccess :
|
||||||
|
ConfigProperties<Boolean>(
|
||||||
|
Boolean::class,
|
||||||
|
"library.allow-public-access",
|
||||||
|
"Allow access to game libraries without login",
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
data object LibraryEnableFilesystemWatcher :
|
||||||
|
ConfigProperties<Boolean>(
|
||||||
|
Boolean::class,
|
||||||
|
"library.scan.enable-filesystem-watcher",
|
||||||
|
"Enable automatic library scanning using file system watchers",
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
data object LibraryMetadataUpdateEnabled :
|
||||||
|
ConfigProperties<Boolean>(
|
||||||
|
Boolean::class,
|
||||||
|
"library.metadata.update.enabled",
|
||||||
|
"Enable periodic refresh of video game meta-data",
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
data object LibraryMetadataUpdateSchedule :
|
||||||
|
ConfigProperties<String>(
|
||||||
|
String::class,
|
||||||
|
"library.metadata.update.schedule",
|
||||||
|
"Schedule for periodic metadata refresh in cron format",
|
||||||
|
"0 0 * * 0"
|
||||||
|
)
|
||||||
|
|
||||||
|
data object LibraryGamesPerPage :
|
||||||
|
ConfigProperties<Int>(
|
||||||
|
Int::class,
|
||||||
|
"library.display.games-per-page",
|
||||||
|
"How many games should be displayed per page",
|
||||||
|
25
|
||||||
|
)
|
||||||
|
|
||||||
|
data object LibraryRatingCutoff :
|
||||||
|
ConfigProperties<Float>(
|
||||||
|
Float::class,
|
||||||
|
"library.display.rating-cutoff",
|
||||||
|
"Minimum rating for games to be displayed",
|
||||||
|
4.5f
|
||||||
|
)
|
||||||
|
|
||||||
|
/** User management */
|
||||||
|
data object UsersAllowNewSignUps : ConfigProperties<Boolean>(
|
||||||
|
Boolean::class,
|
||||||
|
"users.sign-ups.allow",
|
||||||
|
"Allow new users to sign up by themselves",
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
data object UsersConfirmNewSignUps :
|
||||||
|
ConfigProperties<Boolean>(
|
||||||
|
Boolean::class,
|
||||||
|
"users.sign-ups.confirm",
|
||||||
|
"Admins need to confirm new sign-ups before they are allowed to log in",
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Notifications */
|
||||||
|
data object NotificationsEmailHost :
|
||||||
|
ConfigProperties<String>(String::class, "notifications.email.host", "URL of the email server")
|
||||||
|
|
||||||
|
data object NotificationsEmailPort :
|
||||||
|
ConfigProperties<String>(String::class, "notifications.email.port", "Port of the email server")
|
||||||
|
|
||||||
|
data object NotificationsEmailUsername :
|
||||||
|
ConfigProperties<String>(String::class, "notifications.email.username", "Username for the email account")
|
||||||
|
|
||||||
|
data object NotificationsEmailPassword :
|
||||||
|
ConfigProperties<String>(String::class, "notifications.email.password", "Password for the email account")
|
||||||
|
}
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
package de.grimsi.gameyfin.config
|
|
||||||
|
|
||||||
import java.io.Serializable
|
|
||||||
import kotlin.reflect.KClass
|
|
||||||
|
|
||||||
sealed class ConfigProperty<T : Serializable>(val type: KClass<T>, val key: String, val default: T? = null) {
|
|
||||||
|
|
||||||
/** Libraries */
|
|
||||||
// Allow access to game libraries without login
|
|
||||||
data object LibraryAllowPublicAccess :
|
|
||||||
ConfigProperty<Boolean>(Boolean::class, "library.allow-public-access", false)
|
|
||||||
|
|
||||||
// Enable automatic library scanning using file system watchers
|
|
||||||
data object LibraryEnableFilesystemWatcher :
|
|
||||||
ConfigProperty<Boolean>(Boolean::class, "library.scan.enable-filesystem-watcher", true)
|
|
||||||
|
|
||||||
// Enable periodic refresh of video game meta-data and set the schedule (default is once per week)
|
|
||||||
data object LibraryMetadataUpdateEnabled :
|
|
||||||
ConfigProperty<Boolean>(Boolean::class, "library.metadata.update.enabled", true)
|
|
||||||
|
|
||||||
data object LibraryMetadataUpdateSchedule :
|
|
||||||
ConfigProperty<String>(String::class, "library.metadata.update.schedule", "0 0 * * 0")
|
|
||||||
|
|
||||||
/** User management */
|
|
||||||
// Allow new users to sign up by themselves
|
|
||||||
data object UsersAllowNewSignUps : ConfigProperty<Boolean>(Boolean::class, "users.sign-ups.allow", false)
|
|
||||||
|
|
||||||
// If an administrator needs to confirm new sign-ups before they are allowed to log in
|
|
||||||
data object UsersConfirmNewSignUps :
|
|
||||||
ConfigProperty<Boolean>(Boolean::class, "users.sign-ups.confirm", false)
|
|
||||||
|
|
||||||
/** Notifications */
|
|
||||||
// Settings for the mail server used by Gameyfin to send notifications
|
|
||||||
data object NotificationsEmailHost : ConfigProperty<String>(String::class, "notifications.email.host")
|
|
||||||
data object NotificationsEmailPort : ConfigProperty<String>(String::class, "notifications.email.port")
|
|
||||||
data object NotificationsEmailUsername : ConfigProperty<String>(String::class, "notifications.email.username")
|
|
||||||
data object NotificationsEmailPassword : ConfigProperty<String>(String::class, "notifications.email.password")
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
package de.grimsi.gameyfin.config
|
package de.grimsi.gameyfin.config
|
||||||
|
|
||||||
|
import de.grimsi.gameyfin.config.dto.ConfigEntryDto
|
||||||
import de.grimsi.gameyfin.config.entities.ConfigEntry
|
import de.grimsi.gameyfin.config.entities.ConfigEntry
|
||||||
import de.grimsi.gameyfin.config.persistence.ConfigRepository
|
import de.grimsi.gameyfin.config.persistence.ConfigRepository
|
||||||
import jakarta.transaction.Transactional
|
import jakarta.transaction.Transactional
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.io.Serializable
|
import java.io.Serializable
|
||||||
import kotlin.reflect.safeCast
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -13,6 +13,33 @@ class ConfigService(
|
|||||||
private val appConfigRepository: ConfigRepository
|
private val appConfigRepository: ConfigRepository
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all known config values.
|
||||||
|
*
|
||||||
|
* @param prefix: Optional prefix to filter the config values
|
||||||
|
* @return A map of all config values
|
||||||
|
*/
|
||||||
|
fun getAllConfigValues(prefix: String?): List<ConfigEntryDto> {
|
||||||
|
var configProperties = ConfigProperties::class.sealedSubclasses.flatMap { subclass ->
|
||||||
|
subclass.objectInstance?.let { listOf(it) } ?: listOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefix != null) {
|
||||||
|
configProperties = configProperties.filter { it.key.startsWith(prefix) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return configProperties.map { configProperty ->
|
||||||
|
val appConfig = appConfigRepository.findById(configProperty.key).orElse(null)
|
||||||
|
ConfigEntryDto(
|
||||||
|
key = configProperty.key,
|
||||||
|
value = appConfig?.value ?: configProperty.default?.toString(),
|
||||||
|
defaultValue = configProperty.default?.toString(),
|
||||||
|
type = configProperty.type.simpleName ?: "Unknown",
|
||||||
|
description = configProperty.description
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current value of a config property in a type-safe way.
|
* Get the current value of a config property in a type-safe way.
|
||||||
* Used internally.
|
* Used internally.
|
||||||
@@ -21,7 +48,7 @@ class ConfigService(
|
|||||||
* @return The current value if set or the default value
|
* @return The current value if set or the default value
|
||||||
* @throws IllegalArgumentException if no value is set and no default value exists
|
* @throws IllegalArgumentException if no value is set and no default value exists
|
||||||
*/
|
*/
|
||||||
fun <T : Serializable> getConfigValue(configProperty: ConfigProperty<T>): T {
|
fun <T : Serializable> getConfigValue(configProperty: ConfigProperties<T>): T {
|
||||||
val appConfig = appConfigRepository.findById(configProperty.key).orElse(null)
|
val appConfig = appConfigRepository.findById(configProperty.key).orElse(null)
|
||||||
return if (appConfig != null) {
|
return if (appConfig != null) {
|
||||||
getValue(appConfig.value, configProperty)
|
getValue(appConfig.value, configProperty)
|
||||||
@@ -61,12 +88,18 @@ class ConfigService(
|
|||||||
fun <T : Serializable> setConfigValue(key: String, value: T) {
|
fun <T : Serializable> setConfigValue(key: String, value: T) {
|
||||||
val configKey = findConfigProperty(key)
|
val configKey = findConfigProperty(key)
|
||||||
|
|
||||||
if (configKey.type.safeCast(value) == null) {
|
// Check if the value can be cast to the type defined for the config property
|
||||||
throw IllegalArgumentException("Type mismatch for key: ${configKey.key}")
|
val castedValue = getValue(value.toString(), configKey)
|
||||||
|
|
||||||
|
var configEntry = appConfigRepository.findById(key).orElse(null)
|
||||||
|
|
||||||
|
if (configEntry == null) {
|
||||||
|
configEntry = ConfigEntry(configKey.key, castedValue.toString())
|
||||||
|
} else {
|
||||||
|
configEntry.value = castedValue.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
val appConfig = ConfigEntry(configKey.key, value.toString())
|
appConfigRepository.save(configEntry)
|
||||||
appConfigRepository.save(appConfig)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,11 +137,11 @@ class ConfigService(
|
|||||||
* Get the value of the config property in a type-safe way.
|
* Get the value of the config property in a type-safe way.
|
||||||
*/
|
*/
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
private fun <T : Serializable> getValue(value: String, configProperty: ConfigProperty<T>): T {
|
private fun <T : Serializable> getValue(value: String, configProperty: ConfigProperties<T>): T {
|
||||||
return when (configProperty.type) {
|
return when (configProperty.type) {
|
||||||
String::class -> value as T
|
String::class -> value as T
|
||||||
Boolean::class -> value.toBoolean() as T
|
Boolean::class -> value.toBoolean() as T
|
||||||
Number::class -> value.toInt() as T
|
Int::class -> value.toInt() as T
|
||||||
Float::class -> value.toFloat() as T
|
Float::class -> value.toFloat() as T
|
||||||
else -> {
|
else -> {
|
||||||
throw RuntimeException("Unknown config type ${configProperty.type}: '$value' for key ${configProperty.key}")
|
throw RuntimeException("Unknown config type ${configProperty.type}: '$value' for key ${configProperty.key}")
|
||||||
@@ -119,9 +152,9 @@ class ConfigService(
|
|||||||
/**
|
/**
|
||||||
* Returns a config property
|
* Returns a config property
|
||||||
*/
|
*/
|
||||||
private fun findConfigProperty(key: String): ConfigProperty<*> {
|
private fun findConfigProperty(key: String): ConfigProperties<*> {
|
||||||
// Use reflection to get all objects defined within ConfigKey
|
// Use reflection to get all objects defined within ConfigKey
|
||||||
val configProperties = ConfigProperty::class.sealedSubclasses.flatMap { subclass ->
|
val configProperties = ConfigProperties::class.sealedSubclasses.flatMap { subclass ->
|
||||||
subclass.objectInstance?.let { listOf(it) } ?: listOf()
|
subclass.objectInstance?.let { listOf(it) } ?: listOf()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package de.grimsi.gameyfin.config.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude
|
||||||
|
import jakarta.annotation.Nonnull
|
||||||
|
|
||||||
|
@JsonInclude(JsonInclude.Include.ALWAYS)
|
||||||
|
data class ConfigEntryDto(
|
||||||
|
@Nonnull val key: String,
|
||||||
|
val value: String?,
|
||||||
|
val defaultValue: String?,
|
||||||
|
@Nonnull val type: String,
|
||||||
|
@Nonnull val description: String
|
||||||
|
)
|
||||||
@@ -11,9 +11,10 @@ import jakarta.validation.constraints.NotNull
|
|||||||
class ConfigEntry(
|
class ConfigEntry(
|
||||||
@Id
|
@Id
|
||||||
@NotNull
|
@NotNull
|
||||||
@Column(unique = true)
|
@Column(name = "`key`", unique = true)
|
||||||
val key: String,
|
val key: String,
|
||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
|
@Column(name = "`value`")
|
||||||
var value: String
|
var value: String
|
||||||
)
|
)
|
||||||
@@ -1,14 +1,22 @@
|
|||||||
package de.grimsi.gameyfin.meta
|
package de.grimsi.gameyfin.meta
|
||||||
|
|
||||||
import de.grimsi.gameyfin.meta.annotations.DynamicAccessInterceptor
|
import de.grimsi.gameyfin.meta.annotations.DynamicAccessInterceptor
|
||||||
|
import de.grimsi.gameyfin.meta.development.DelayInterceptor
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
class WebConfig(val dynamicAccessInterceptor: DynamicAccessInterceptor) : WebMvcConfigurer {
|
class WebConfig(
|
||||||
|
private val dynamicAccessInterceptor: DynamicAccessInterceptor
|
||||||
|
) : WebMvcConfigurer {
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
private var delayInterceptor: DelayInterceptor? = null
|
||||||
|
|
||||||
override fun addInterceptors(registry: InterceptorRegistry) {
|
override fun addInterceptors(registry: InterceptorRegistry) {
|
||||||
registry.addInterceptor(dynamicAccessInterceptor)
|
registry.addInterceptor(dynamicAccessInterceptor)
|
||||||
|
delayInterceptor?.let { registry.addInterceptor(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package de.grimsi.gameyfin.meta.annotations
|
package de.grimsi.gameyfin.meta.annotations
|
||||||
|
|
||||||
import de.grimsi.gameyfin.config.ConfigProperty
|
import de.grimsi.gameyfin.config.ConfigProperties
|
||||||
import de.grimsi.gameyfin.config.ConfigService
|
import de.grimsi.gameyfin.config.ConfigService
|
||||||
import jakarta.servlet.http.HttpServletRequest
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
import jakarta.servlet.http.HttpServletResponse
|
import jakarta.servlet.http.HttpServletResponse
|
||||||
@@ -24,7 +24,7 @@ class DynamicAccessInterceptor(
|
|||||||
// Check if method is annotated with @DynamicPublicAccess
|
// Check if method is annotated with @DynamicPublicAccess
|
||||||
if (method.isAnnotationPresent(DynamicPublicAccess::class.java)) {
|
if (method.isAnnotationPresent(DynamicPublicAccess::class.java)) {
|
||||||
// Check if user is authenticated or public access is enabled
|
// Check if user is authenticated or public access is enabled
|
||||||
if (request.userPrincipal != null || configService.getConfigValue(ConfigProperty.LibraryAllowPublicAccess)) {
|
if (request.userPrincipal != null || configService.getConfigValue(ConfigProperties.LibraryAllowPublicAccess)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package de.grimsi.gameyfin.meta.development
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
|
import jakarta.servlet.http.HttpServletResponse
|
||||||
|
import org.springframework.context.annotation.Profile
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor
|
||||||
|
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@Profile("development")
|
||||||
|
class DelayInterceptor : HandlerInterceptor {
|
||||||
|
|
||||||
|
override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
|
||||||
|
Thread.sleep(2000)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -8,14 +8,14 @@ import jakarta.validation.constraints.NotNull
|
|||||||
@Entity
|
@Entity
|
||||||
@Table(name = "users")
|
@Table(name = "users")
|
||||||
class User(
|
class User(
|
||||||
@NotNull
|
|
||||||
@Column(unique = true)
|
|
||||||
var username: String,
|
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||||
var id: Long? = null,
|
var id: Long? = null,
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Column(unique = true)
|
||||||
|
var username: String,
|
||||||
|
|
||||||
@NotNull
|
@NotNull
|
||||||
var password: String,
|
var password: String,
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user