mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-11 04:52:06 +10:00
completed game importing; partial work on version importing
This commit is contained in:
24
components/GameSearchResultWidget.vue
Normal file
24
components/GameSearchResultWidget.vue
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-row items-center gap-x-2">
|
||||||
|
<img :src="game.icon" class="w-12 h-12 rounded-sm object-cover" />
|
||||||
|
<div class="flex flex-col items-left">
|
||||||
|
<h1 class="font-semibold font-display text-lg text-zinc-100">
|
||||||
|
{{ game.name }}
|
||||||
|
<span
|
||||||
|
v-if="game.sourceName"
|
||||||
|
class="inline-flex items-center rounded-md bg-blue-600/50 px-2 py-1 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-700/10"
|
||||||
|
>{{ game.sourceName }}</span
|
||||||
|
>
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-zinc-400">{{ game.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
game: GameMetadataSearchResult & { sourceName?: string };
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
@ -20,7 +20,6 @@ function initWs() {
|
|||||||
ws = new WebSocket(url);
|
ws = new WebSocket(url);
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e) => {
|
||||||
const msg = JSON.parse(e.data) as TaskMessage;
|
const msg = JSON.parse(e.data) as TaskMessage;
|
||||||
console.log(msg);
|
|
||||||
const taskStates = useTaskStates();
|
const taskStates = useTaskStates();
|
||||||
const state = taskStates.value[msg.id];
|
const state = taskStates.value[msg.id];
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
|
|||||||
95
error.vue
Normal file
95
error.vue
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { NuxtError } from "#app";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
error: Object as () => NuxtError,
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const user = useUser();
|
||||||
|
|
||||||
|
async function signIn() {
|
||||||
|
clearError({
|
||||||
|
redirect: `/signin?redirect=${encodeURIComponent(route.fullPath)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: `${props.error?.statusCode ?? "An unknown error occurred"} | Drop`,
|
||||||
|
});
|
||||||
|
console.log(props.error);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="grid min-h-screen grid-cols-1 grid-rows-[1fr,auto,1fr] bg-zinc-950 lg:grid-cols-[max(50%,36rem),1fr]"
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
class="mx-auto w-full max-w-7xl px-6 pt-6 sm:pt-10 lg:col-span-2 lg:col-start-1 lg:row-start-1 lg:px-8"
|
||||||
|
>
|
||||||
|
<Logo class="h-10 w-auto sm:h-12" />
|
||||||
|
</header>
|
||||||
|
<main
|
||||||
|
class="mx-auto w-full max-w-7xl px-6 py-24 sm:py-32 lg:col-span-2 lg:col-start-1 lg:row-start-2 lg:px-8"
|
||||||
|
>
|
||||||
|
<div class="max-w-lg">
|
||||||
|
<p class="text-base font-semibold leading-8 text-blue-600">
|
||||||
|
{{ error?.statusCode }}
|
||||||
|
</p>
|
||||||
|
<h1
|
||||||
|
class="mt-4 text-3xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl"
|
||||||
|
>
|
||||||
|
Oh no!
|
||||||
|
</h1>
|
||||||
|
<p class="mt-6 text-base leading-7 text-zinc-400">
|
||||||
|
An error occurred while responding to your request. If you believe
|
||||||
|
this to be a bug, please report it.
|
||||||
|
</p>
|
||||||
|
<div class="mt-10">
|
||||||
|
<!-- full app reload to fix errors -->
|
||||||
|
<a
|
||||||
|
v-if="user"
|
||||||
|
href="/"
|
||||||
|
class="text-sm font-semibold leading-7 text-blue-600"
|
||||||
|
><span aria-hidden="true">←</span> Back to home</a
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
@click="signIn"
|
||||||
|
class="text-sm font-semibold leading-7 text-blue-600"
|
||||||
|
>
|
||||||
|
Sign in <span aria-hidden="true">→</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<footer class="self-end lg:col-span-2 lg:col-start-1 lg:row-start-3">
|
||||||
|
<div class="border-t border-zinc-700 bg-zinc-900 py-10">
|
||||||
|
<nav
|
||||||
|
class="mx-auto flex w-full max-w-7xl items-center gap-x-4 px-6 text-sm leading-7 text-zinc-400 lg:px-8"
|
||||||
|
>
|
||||||
|
<NuxtLink href="/docs">Documentation</NuxtLink>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 2 2"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-0.5 w-0.5 fill-zinc-600"
|
||||||
|
>
|
||||||
|
<circle cx="1" cy="1" r="1" />
|
||||||
|
</svg>
|
||||||
|
<a href="https://discord.gg/NHx46XKJWA" target="_blank"
|
||||||
|
>Support Discord</a
|
||||||
|
>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
<div
|
||||||
|
class="hidden lg:relative lg:col-start-2 lg:row-start-1 lg:row-end-4 lg:block"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/wallpapers/error-wallpaper.jpg"
|
||||||
|
alt=""
|
||||||
|
class="absolute inset-0 h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -49,11 +49,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main class="lg:pl-20 min-h-screen bg-zinc-900">
|
<main class="lg:pl-20 min-h-screen bg-zinc-900">
|
||||||
<div class="xl:pl-96">
|
<div class="px-4 py-10 sm:px-6 lg:px-8 lg:py-6">
|
||||||
<div class="px-4 py-10 sm:px-6 lg:px-8 lg:py-6">
|
<!-- Main area -->
|
||||||
<!-- Main area -->
|
<NuxtPage />
|
||||||
<NuxtPage />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@ -71,13 +69,14 @@ import {
|
|||||||
} from "@heroicons/vue/24/outline";
|
} from "@heroicons/vue/24/outline";
|
||||||
import type { NavigationItem } from "~/composables/types";
|
import type { NavigationItem } from "~/composables/types";
|
||||||
import { useCurrentNavigationIndex } from "~/composables/current-page-engine";
|
import { useCurrentNavigationIndex } from "~/composables/current-page-engine";
|
||||||
|
import { ArrowLeftIcon } from "@heroicons/vue/16/solid";
|
||||||
|
|
||||||
const navigation: Array<NavigationItem & { icon: Component }> = [
|
const navigation: Array<NavigationItem & { icon: Component }> = [
|
||||||
{ label: "Home", route: "/admin", prefix: "/admin", icon: HomeIcon },
|
{ label: "Home", route: "/admin", prefix: "/admin", icon: HomeIcon },
|
||||||
{
|
{
|
||||||
label: "Libraries",
|
label: "Library",
|
||||||
route: "/admin/libraries",
|
route: "/admin/library",
|
||||||
prefix: "/admin/libraries",
|
prefix: "/admin/library",
|
||||||
icon: ServerStackIcon,
|
icon: ServerStackIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -90,13 +89,19 @@ const navigation: Array<NavigationItem & { icon: Component }> = [
|
|||||||
label: "Feature Flags",
|
label: "Feature Flags",
|
||||||
route: "/admin/features",
|
route: "/admin/features",
|
||||||
prefix: "/admin/features",
|
prefix: "/admin/features",
|
||||||
icon: FlagIcon
|
icon: FlagIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Settings",
|
label: "Settings",
|
||||||
route: "/admin/settings",
|
route: "/admin/settings",
|
||||||
prefix: "/admin/settings",
|
prefix: "/admin/settings",
|
||||||
icon: Cog6ToothIcon
|
icon: Cog6ToothIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Back",
|
||||||
|
route: "/",
|
||||||
|
prefix: ".",
|
||||||
|
icon: ArrowLeftIcon
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
<UserHeader />
|
<UserHeader />
|
||||||
<div class="grow flex">
|
<div class="grow flex">
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
{{ test }}
|
|
||||||
</div>
|
</div>
|
||||||
<UserFooter />
|
<UserFooter />
|
||||||
</content>
|
</content>
|
||||||
@ -16,6 +15,4 @@ useHead({
|
|||||||
return `Drop`;
|
return `Drop`;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const test = useTask("test");
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
const whitelistedPrefixes = ["/signin", "/register", "/api"];
|
const whitelistedPrefixes = ["/signin", "/register", "/api"];
|
||||||
|
const requireAdmin = ["/admin"];
|
||||||
|
|
||||||
export default defineNuxtRouteMiddleware(async (to, from) => {
|
export default defineNuxtRouteMiddleware(async (to, from) => {
|
||||||
if (import.meta.server) return;
|
if (import.meta.server) return;
|
||||||
|
const error = useError();
|
||||||
|
if (error.value !== undefined) return;
|
||||||
if (whitelistedPrefixes.findIndex((e) => to.fullPath.startsWith(e)) != -1)
|
if (whitelistedPrefixes.findIndex((e) => to.fullPath.startsWith(e)) != -1)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@ -12,4 +15,10 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
|
|||||||
if (!user.value) {
|
if (!user.value) {
|
||||||
return navigateTo({ path: "/signin", query: { redirect: to.fullPath } });
|
return navigateTo({ path: "/signin", query: { redirect: to.fullPath } });
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
requireAdmin.findIndex((e) => to.fullPath.startsWith(e)) != -1 &&
|
||||||
|
!user.value.admin
|
||||||
|
) {
|
||||||
|
return navigateTo({ path: "/" });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -10,15 +10,16 @@
|
|||||||
"postinstall": "nuxt prepare"
|
"postinstall": "nuxt prepare"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@drop/droplet": "^0.3.2",
|
"@drop/droplet": "^0.4.1",
|
||||||
"@headlessui/vue": "^1.7.23",
|
"@headlessui/vue": "^1.7.23",
|
||||||
"@heroicons/vue": "^2.1.5",
|
"@heroicons/vue": "^2.1.5",
|
||||||
"@prisma/client": "5.20.0",
|
"@prisma/client": "5.20.0",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
|
"fast-fuzzy": "^1.12.0",
|
||||||
"file-type-mime": "^0.4.3",
|
"file-type-mime": "^0.4.3",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"nuxt": "^3.13.0",
|
"nuxt": "^3.13.2",
|
||||||
"prisma": "^5.20.0",
|
"prisma": "^5.20.0",
|
||||||
"sanitize-filename": "^1.6.3",
|
"sanitize-filename": "^1.6.3",
|
||||||
"stream": "^0.0.3",
|
"stream": "^0.0.3",
|
||||||
@ -39,5 +40,9 @@
|
|||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"sass": "^1.79.4",
|
"sass": "^1.79.4",
|
||||||
"tailwindcss": "^3.4.13"
|
"tailwindcss": "^3.4.13"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@drop/droplet-linux-x64-gnu": "^0.4.1",
|
||||||
|
"@drop/droplet-win32-x64-msvc": "^0.4.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +0,0 @@
|
|||||||
<template></template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
definePageMeta({
|
|
||||||
layout: "admin",
|
|
||||||
});
|
|
||||||
|
|
||||||
useHead({
|
|
||||||
title: "Libraries"
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
109
pages/admin/library/[id]/import.vue
Normal file
109
pages/admin/library/[id]/import.vue
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<Listbox
|
||||||
|
as="div"
|
||||||
|
class="max-w-md"
|
||||||
|
v-on:update:model-value="(value) => updateCurrentlySelectedVersion(value)"
|
||||||
|
:model-value="currentlySelectedVersion"
|
||||||
|
>
|
||||||
|
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100"
|
||||||
|
>Select version to import</ListboxLabel
|
||||||
|
>
|
||||||
|
<div class="relative mt-2">
|
||||||
|
<ListboxButton
|
||||||
|
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||||
|
>
|
||||||
|
<span v-if="currentlySelectedVersion != -1" class="block truncate">{{
|
||||||
|
versions[currentlySelectedVersion]
|
||||||
|
}}</span>
|
||||||
|
<span v-else class="block truncate text-zinc-600"
|
||||||
|
>Please select a directory...</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
||||||
|
>
|
||||||
|
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</ListboxButton>
|
||||||
|
|
||||||
|
<transition
|
||||||
|
leave-active-class="transition ease-in duration-100"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<ListboxOptions
|
||||||
|
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-zinc-800 focus:outline-none sm:text-sm"
|
||||||
|
>
|
||||||
|
<ListboxOption
|
||||||
|
as="template"
|
||||||
|
v-for="(version, versionIdx) in versions"
|
||||||
|
:key="version"
|
||||||
|
:value="versionIdx"
|
||||||
|
v-slot="{ active, selected }"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
:class="[
|
||||||
|
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
|
||||||
|
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
selected ? 'font-semibold' : 'font-normal',
|
||||||
|
'block truncate',
|
||||||
|
]"
|
||||||
|
>{{ version }}</span
|
||||||
|
>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="selected"
|
||||||
|
:class="[
|
||||||
|
active ? 'text-white' : 'text-blue-600',
|
||||||
|
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<CheckIcon class="h-5 w-5" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ListboxOption>
|
||||||
|
</ListboxOptions>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</Listbox>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Listbox,
|
||||||
|
ListboxButton,
|
||||||
|
ListboxLabel,
|
||||||
|
ListboxOption,
|
||||||
|
ListboxOptions,
|
||||||
|
} from "@headlessui/vue";
|
||||||
|
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: "admin",
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const headers = useRequestHeaders(["cookie"]);
|
||||||
|
const gameId = route.params.id.toString();
|
||||||
|
const versions = await $fetch(
|
||||||
|
`/api/v1/admin/import/version?id=${encodeURIComponent(gameId)}`,
|
||||||
|
{
|
||||||
|
headers,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const currentlySelectedVersion = ref(-1);
|
||||||
|
|
||||||
|
async function updateCurrentlySelectedVersion(value: number) {
|
||||||
|
if (currentlySelectedVersion.value == value) return;
|
||||||
|
currentlySelectedVersion.value = value;
|
||||||
|
const version = versions[currentlySelectedVersion.value];
|
||||||
|
const results = await $fetch(
|
||||||
|
`/api/v1/admin/import/version/preload?id=${encodeURIComponent(
|
||||||
|
gameId
|
||||||
|
)}&version=${encodeURIComponent(version)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
254
pages/admin/library/import.vue
Normal file
254
pages/admin/library/import.vue
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-y-4">
|
||||||
|
<Listbox
|
||||||
|
as="div"
|
||||||
|
class="max-w-md"
|
||||||
|
v-on:update:model-value="(value) => updateSelectedGame(value)"
|
||||||
|
:model="currentlySelectedGame"
|
||||||
|
>
|
||||||
|
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100"
|
||||||
|
>Select game to import</ListboxLabel
|
||||||
|
>
|
||||||
|
<div class="relative mt-2">
|
||||||
|
<ListboxButton
|
||||||
|
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||||
|
>
|
||||||
|
<span v-if="currentlySelectedGame != -1" class="block truncate">{{
|
||||||
|
games.unimportedGames[currentlySelectedGame]
|
||||||
|
}}</span>
|
||||||
|
<span v-else class="block truncate text-zinc-600"
|
||||||
|
>Please select a directory...</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
||||||
|
>
|
||||||
|
<ChevronUpDownIcon
|
||||||
|
class="h-5 w-5 text-gray-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</ListboxButton>
|
||||||
|
|
||||||
|
<transition
|
||||||
|
leave-active-class="transition ease-in duration-100"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<ListboxOptions
|
||||||
|
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-zinc-800 focus:outline-none sm:text-sm"
|
||||||
|
>
|
||||||
|
<ListboxOption
|
||||||
|
as="template"
|
||||||
|
v-for="(game, gameIdx) in games.unimportedGames"
|
||||||
|
:key="game"
|
||||||
|
:value="gameIdx"
|
||||||
|
v-slot="{ active, selected }"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
:class="[
|
||||||
|
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
|
||||||
|
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
selected ? 'font-semibold' : 'font-normal',
|
||||||
|
'block truncate',
|
||||||
|
]"
|
||||||
|
>{{ game }}</span
|
||||||
|
>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="selected"
|
||||||
|
:class="[
|
||||||
|
active ? 'text-white' : 'text-blue-600',
|
||||||
|
'absolute inset-y-0 right-0 flex items-center pr-4',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<CheckIcon class="h-5 w-5" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ListboxOption>
|
||||||
|
</ListboxOptions>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</Listbox>
|
||||||
|
|
||||||
|
<Listbox
|
||||||
|
as="div"
|
||||||
|
class="max-w-md"
|
||||||
|
v-if="metadataResults"
|
||||||
|
v-model="currentlySelectedMetadata"
|
||||||
|
>
|
||||||
|
<ListboxLabel class="block text-sm font-medium leading-6 text-zinc-100"
|
||||||
|
>Select game</ListboxLabel
|
||||||
|
>
|
||||||
|
<div class="relative mt-2">
|
||||||
|
<ListboxButton
|
||||||
|
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||||
|
>
|
||||||
|
<GameSearchResultWidget
|
||||||
|
v-if="currentlySelectedMetadata != -1"
|
||||||
|
:game="metadataResults[currentlySelectedMetadata]"
|
||||||
|
/>
|
||||||
|
<span v-else class="block truncate text-zinc-600"
|
||||||
|
>Please select a game...</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
|
||||||
|
>
|
||||||
|
<ChevronUpDownIcon
|
||||||
|
class="h-5 w-5 text-gray-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</ListboxButton>
|
||||||
|
|
||||||
|
<transition
|
||||||
|
leave-active-class="transition ease-in duration-100"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<ListboxOptions
|
||||||
|
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
|
||||||
|
>
|
||||||
|
<ListboxOption
|
||||||
|
as="template"
|
||||||
|
v-for="(result, resultIdx) in metadataResults"
|
||||||
|
:key="result.id"
|
||||||
|
:value="resultIdx"
|
||||||
|
v-slot="{ active, selected }"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
:class="[
|
||||||
|
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
|
||||||
|
'relative cursor-default select-none py-2 pl-3 pr-9',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<GameSearchResultWidget :game="result" />
|
||||||
|
</li>
|
||||||
|
</ListboxOption>
|
||||||
|
</ListboxOptions>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</Listbox>
|
||||||
|
<div
|
||||||
|
v-else-if="currentlySelectedGame != -1"
|
||||||
|
role="status"
|
||||||
|
class="inline-flex text-zinc-100 font-display font-semibold items-center gap-x-4"
|
||||||
|
>
|
||||||
|
Loading game results...
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class="w-6 h-6 text-transparent animate-spin fill-white"
|
||||||
|
viewBox="0 0 100 101"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||||
|
fill="currentFill"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Loading...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="currentlySelectedGame !== -1 && currentlySelectedMetadata !== -1">
|
||||||
|
<LoadingButton
|
||||||
|
@click="() => importGame_wrapper()"
|
||||||
|
class="w-fit"
|
||||||
|
:loading="importLoading"
|
||||||
|
>Import</LoadingButton
|
||||||
|
>
|
||||||
|
|
||||||
|
<div v-if="importError" class="mt-4 w-fit rounded-md bg-red-600/10 p-4">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-red-600">
|
||||||
|
{{ importError }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ metadataResults }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Listbox,
|
||||||
|
ListboxButton,
|
||||||
|
ListboxLabel,
|
||||||
|
ListboxOption,
|
||||||
|
ListboxOptions,
|
||||||
|
} from "@headlessui/vue";
|
||||||
|
import { XCircleIcon } from "@heroicons/vue/16/solid";
|
||||||
|
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
|
||||||
|
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: "admin",
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = useRequestHeaders(["cookie"]);
|
||||||
|
const games = await $fetch("/api/v1/admin/library/game/import", { headers });
|
||||||
|
|
||||||
|
const currentlySelectedGame = ref(-1);
|
||||||
|
|
||||||
|
async function updateSelectedGame(value: number) {
|
||||||
|
if (currentlySelectedGame.value == value) return;
|
||||||
|
currentlySelectedGame.value = value;
|
||||||
|
if (currentlySelectedGame.value == -1) return;
|
||||||
|
const game = games.unimportedGames[currentlySelectedGame.value];
|
||||||
|
if (!game) return;
|
||||||
|
|
||||||
|
metadataResults.value = undefined;
|
||||||
|
currentlySelectedMetadata.value = -1;
|
||||||
|
|
||||||
|
const results = await $fetch(
|
||||||
|
`/api/v1/admin/import/game/search?q=${encodeURIComponent(game)}`
|
||||||
|
);
|
||||||
|
metadataResults.value = results;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataResults = ref<Array<GameMetadataSearchResult> | undefined>();
|
||||||
|
const currentlySelectedMetadata = ref(-1);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const importLoading = ref(false);
|
||||||
|
const importError = ref<string | undefined>();
|
||||||
|
async function importGame() {
|
||||||
|
if (!metadataResults.value) return;
|
||||||
|
|
||||||
|
const game = await $fetch("/api/v1/admin/import/game", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
path: games.unimportedGames[currentlySelectedGame.value],
|
||||||
|
metadata: metadataResults.value[currentlySelectedMetadata.value],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push(`/admin/library/${game.id}`);
|
||||||
|
}
|
||||||
|
function importGame_wrapper() {
|
||||||
|
importLoading.value = true;
|
||||||
|
importError.value = undefined;
|
||||||
|
importGame()
|
||||||
|
.catch((error) => {
|
||||||
|
importError.value = error?.statusMessage || "An unknown error occurred.";
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
importLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
122
pages/admin/library/index.vue
Normal file
122
pages/admin/library/index.vue
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="libraryState.unimportedGames.length > 0"
|
||||||
|
class="rounded-md bg-blue-600/10 p-4 mb-4"
|
||||||
|
>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<InformationCircleIcon
|
||||||
|
class="h-5 w-5 text-blue-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 flex-1 md:flex md:justify-between">
|
||||||
|
<p class="text-sm text-blue-400">
|
||||||
|
Drop has detected you have new games to import.
|
||||||
|
</p>
|
||||||
|
<p class="mt-3 text-sm md:ml-6 md:mt-0">
|
||||||
|
<NuxtLink
|
||||||
|
href="/admin/library/import"
|
||||||
|
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
<span aria-hidden="true"> →</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
role="list"
|
||||||
|
class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="{ game, status } in libraryState.games"
|
||||||
|
:key="game.id"
|
||||||
|
class="col-span-1 flex flex-col justify-center divide-y divide-zinc-700 rounded-lg bg-zinc-950/20 text-left shadow"
|
||||||
|
>
|
||||||
|
<div class="flex flex-1 flex-row p-4 gap-x-4">
|
||||||
|
<img
|
||||||
|
class="mx-auto h-16 w-16 flex-shrink-0 rounded-md"
|
||||||
|
:src="useObject(game.mIconId)"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<div class="flex-1 flex-col">
|
||||||
|
<h3 class="text-sm font-medium text-zinc-100 font-display">
|
||||||
|
{{ game.mName }}
|
||||||
|
</h3>
|
||||||
|
<dl class="mt-1 flex flex-grow flex-col justify-between">
|
||||||
|
<dt class="sr-only">Short Description</dt>
|
||||||
|
<dd class="text-sm text-zinc-400">{{ game.mShortDescription }}</dd>
|
||||||
|
<dt class="sr-only">Metadata provider</dt>
|
||||||
|
<dd class="mt-3">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full bg-blue-600/10 px-2 py-1 text-xs font-medium text-blue-600 ring-1 ring-inset ring-blue-600/20"
|
||||||
|
>{{ game.metadataSource }}</span
|
||||||
|
>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-y-2 p-2">
|
||||||
|
<div
|
||||||
|
v-if="status.unimportedVersions"
|
||||||
|
class="rounded-md bg-blue-600/10 p-4"
|
||||||
|
>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<InformationCircleIcon
|
||||||
|
class="h-5 w-5 text-blue-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 flex-1 md:flex md:justify-between">
|
||||||
|
<p class="text-sm text-blue-400">
|
||||||
|
Drop has detected you have new verions of this game to import.
|
||||||
|
</p>
|
||||||
|
<p class="mt-3 text-sm md:ml-6 md:mt-0">
|
||||||
|
<NuxtLink
|
||||||
|
:href="`/admin/library/${game.id}/import`"
|
||||||
|
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
<span aria-hidden="true"> →</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="status.noVersions" class="rounded-md bg-yellow-600/10 p-4">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<ExclamationTriangleIcon
|
||||||
|
class="h-5 w-5 text-yellow-600"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-yellow-600">
|
||||||
|
You have no versions of this game available.
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ExclamationTriangleIcon } from "@heroicons/vue/16/solid";
|
||||||
|
import { InformationCircleIcon } from "@heroicons/vue/20/solid";
|
||||||
|
definePageMeta({
|
||||||
|
layout: "admin",
|
||||||
|
});
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: "Libraries",
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = useRequestHeaders(["cookie"]);
|
||||||
|
const libraryState = await $fetch("/api/v1/admin/library", { headers });
|
||||||
|
</script>
|
||||||
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
{{ user ?? "no user" }}
|
{{ games }}
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@ -7,5 +7,5 @@ useHead({
|
|||||||
title: "Home",
|
title: "Home",
|
||||||
});
|
});
|
||||||
|
|
||||||
const user = useUser();
|
const games = await $fetch("/api/v1/games/front");
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[libraryBasePath]` on the table `Game` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- Added the required column `libraryBasePath` to the `Game` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `versionOrder` to the `Game` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Game" ADD COLUMN "libraryBasePath" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "versionOrder" TEXT NOT NULL;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "GameVersion" (
|
||||||
|
"gameId" TEXT NOT NULL,
|
||||||
|
"versionName" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "GameVersion_pkey" PRIMARY KEY ("gameId","versionName")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Game_libraryBasePath_key" ON "Game"("libraryBasePath");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "GameVersion" ADD CONSTRAINT "GameVersion_gameId_fkey" FOREIGN KEY ("gameId") REFERENCES "Game"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
25
prisma/migrations/20241010095344_various_fixes/migration.sql
Normal file
25
prisma/migrations/20241010095344_various_fixes/migration.sql
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- The `versionOrder` column on the `Game` table would be dropped and recreated. This will lead to data loss if there is data in the column.
|
||||||
|
- Changed the type of `platform` on the `Client` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
|
||||||
|
- Added the required column `launchCommand` to the `GameVersion` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `platform` to the `GameVersion` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `setupCommand` to the `GameVersion` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Platform" AS ENUM ('windows', 'linux');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Client" DROP COLUMN "platform",
|
||||||
|
ADD COLUMN "platform" "Platform" NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Game" DROP COLUMN "versionOrder",
|
||||||
|
ADD COLUMN "versionOrder" TEXT[];
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "GameVersion" ADD COLUMN "launchCommand" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "platform" "Platform" NOT NULL,
|
||||||
|
ADD COLUMN "setupCommand" TEXT NOT NULL;
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Added the required column `metadataOriginalQuery` to the `Developer` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `metadataOriginalQuery` to the `Publisher` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Developer" ADD COLUMN "metadataOriginalQuery" TEXT NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Publisher" ADD COLUMN "metadataOriginalQuery" TEXT NOT NULL;
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[metadataSource,metadataId,metadataOriginalQuery]` on the table `Developer` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- A unique constraint covering the columns `[metadataSource,metadataId,metadataOriginalQuery]` on the table `Publisher` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Developer_metadataSource_metadataId_key";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Publisher_metadataSource_metadataId_key";
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Developer_metadataSource_metadataId_metadataOriginalQuery_key" ON "Developer"("metadataSource", "metadataId", "metadataOriginalQuery");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Publisher_metadataSource_metadataId_metadataOriginalQuery_key" ON "Publisher"("metadataSource", "metadataId", "metadataOriginalQuery");
|
||||||
@ -45,6 +45,11 @@ enum ClientCapabilities {
|
|||||||
DownloadAggregation
|
DownloadAggregation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum Platform {
|
||||||
|
Windows @map("windows")
|
||||||
|
Linux @map("linux")
|
||||||
|
}
|
||||||
|
|
||||||
// References a device
|
// References a device
|
||||||
model Client {
|
model Client {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
@ -55,7 +60,7 @@ model Client {
|
|||||||
capabilities ClientCapabilities[]
|
capabilities ClientCapabilities[]
|
||||||
|
|
||||||
name String
|
name String
|
||||||
platform String
|
platform Platform
|
||||||
lastConnected DateTime
|
lastConnected DateTime
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,9 +91,9 @@ model Game {
|
|||||||
mArt String[] // linked to objects in s3
|
mArt String[] // linked to objects in s3
|
||||||
mScreenshots String[] // linked to objects in s3
|
mScreenshots String[] // linked to objects in s3
|
||||||
|
|
||||||
versionOrder String
|
versionOrder String[]
|
||||||
versions GameVersion[]
|
versions GameVersion[]
|
||||||
libraryBasePath String // Base dir for all the game versions
|
libraryBasePath String @unique // Base dir for all the game versions
|
||||||
|
|
||||||
@@unique([metadataSource, metadataId], name: "metadataKey")
|
@@unique([metadataSource, metadataId], name: "metadataKey")
|
||||||
}
|
}
|
||||||
@ -99,7 +104,9 @@ model GameVersion {
|
|||||||
game Game @relation(fields: [gameId], references: [id])
|
game Game @relation(fields: [gameId], references: [id])
|
||||||
versionName String // Sub directory for the game files
|
versionName String // Sub directory for the game files
|
||||||
|
|
||||||
|
platform Platform
|
||||||
|
launchCommand String // Command to run to start. Platform-specific. Windows games on Linux will wrap this command in Proton/Wine
|
||||||
|
setupCommand String // Command to setup game (dependencies and such)
|
||||||
|
|
||||||
@@id([gameId, versionName])
|
@@id([gameId, versionName])
|
||||||
}
|
}
|
||||||
@ -107,8 +114,9 @@ model GameVersion {
|
|||||||
model Developer {
|
model Developer {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
|
|
||||||
metadataSource MetadataSource
|
metadataSource MetadataSource
|
||||||
metadataId String
|
metadataId String
|
||||||
|
metadataOriginalQuery String
|
||||||
|
|
||||||
mName String
|
mName String
|
||||||
mShortDescription String
|
mShortDescription String
|
||||||
@ -119,14 +127,15 @@ model Developer {
|
|||||||
|
|
||||||
games Game[]
|
games Game[]
|
||||||
|
|
||||||
@@unique([metadataSource, metadataId], name: "metadataKey")
|
@@unique([metadataSource, metadataId, metadataOriginalQuery], name: "metadataKey")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Publisher {
|
model Publisher {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
|
|
||||||
metadataSource MetadataSource
|
metadataSource MetadataSource
|
||||||
metadataId String
|
metadataId String
|
||||||
|
metadataOriginalQuery String
|
||||||
|
|
||||||
mName String
|
mName String
|
||||||
mShortDescription String
|
mShortDescription String
|
||||||
@ -137,5 +146,5 @@ model Publisher {
|
|||||||
|
|
||||||
games Game[]
|
games Game[]
|
||||||
|
|
||||||
@@unique([metadataSource, metadataId], name: "metadataKey")
|
@@unique([metadataSource, metadataId, metadataOriginalQuery], name: "metadataKey")
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/wallpapers/error-wallpaper.jpg
Normal file
BIN
public/wallpapers/error-wallpaper.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 MiB |
9
server/api/v1/admin/import/game/index.get.ts
Normal file
9
server/api/v1/admin/import/game/index.get.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import libraryManager from "~/server/internal/library";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (h3) => {
|
||||||
|
const user = await h3.context.session.getAdminUser(h3);
|
||||||
|
if (!user) throw createError({ statusCode: 403 });
|
||||||
|
|
||||||
|
const unimportedGames = await libraryManager.fetchAllUnimportedGames();
|
||||||
|
return { unimportedGames };
|
||||||
|
});
|
||||||
36
server/api/v1/admin/import/game/index.post.ts
Normal file
36
server/api/v1/admin/import/game/index.post.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import libraryManager from "~/server/internal/library";
|
||||||
|
import {
|
||||||
|
GameMetadataSearchResult,
|
||||||
|
GameMetadataSource,
|
||||||
|
} from "~/server/internal/metadata/types";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (h3) => {
|
||||||
|
const user = await h3.context.session.getAdminUser(h3);
|
||||||
|
if (!user) throw createError({ statusCode: 403 });
|
||||||
|
|
||||||
|
const body = await readBody(h3);
|
||||||
|
|
||||||
|
const path = body.path;
|
||||||
|
const metadata = body.metadata as GameMetadataSearchResult &
|
||||||
|
GameMetadataSource;
|
||||||
|
if (!path)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "Path missing from body",
|
||||||
|
});
|
||||||
|
if (!metadata.id || !metadata.sourceId)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "Metadata IDs missing from body",
|
||||||
|
});
|
||||||
|
|
||||||
|
const validPath = await libraryManager.checkUnimportedGamePath(path);
|
||||||
|
if (!validPath)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "Invalid unimported game path",
|
||||||
|
});
|
||||||
|
|
||||||
|
const game = await h3.context.metadataHandler.createGame(metadata, path);
|
||||||
|
return game;
|
||||||
|
});
|
||||||
13
server/api/v1/admin/import/game/search.get.ts
Normal file
13
server/api/v1/admin/import/game/search.get.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import libraryManager from "~/server/internal/library";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (h3) => {
|
||||||
|
const user = await h3.context.session.getAdminUser(h3);
|
||||||
|
if (!user) throw createError({ statusCode: 403 });
|
||||||
|
|
||||||
|
const query = getQuery(h3);
|
||||||
|
const search = query.q?.toString();
|
||||||
|
if (!search)
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "Invalid search" });
|
||||||
|
|
||||||
|
return await h3.context.metadataHandler.search(search);
|
||||||
|
});
|
||||||
22
server/api/v1/admin/import/version/index.get.ts
Normal file
22
server/api/v1/admin/import/version/index.get.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import libraryManager from "~/server/internal/library";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (h3) => {
|
||||||
|
const user = await h3.context.session.getAdminUser(h3);
|
||||||
|
if (!user) throw createError({ statusCode: 403 });
|
||||||
|
|
||||||
|
const query = await getQuery(h3);
|
||||||
|
const gameId = query.id?.toString();
|
||||||
|
if (!gameId)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "Missing id in request params",
|
||||||
|
});
|
||||||
|
|
||||||
|
const unimportedVersions = await libraryManager.fetchUnimportedVersions(
|
||||||
|
gameId
|
||||||
|
);
|
||||||
|
if (!unimportedVersions)
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "Invalid game ID" });
|
||||||
|
|
||||||
|
return unimportedVersions;
|
||||||
|
});
|
||||||
27
server/api/v1/admin/import/version/preload.get.ts
Normal file
27
server/api/v1/admin/import/version/preload.get.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import libraryManager from "~/server/internal/library";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (h3) => {
|
||||||
|
const user = await h3.context.session.getAdminUser(h3);
|
||||||
|
if (!user) throw createError({ statusCode: 403 });
|
||||||
|
|
||||||
|
const query = await getQuery(h3);
|
||||||
|
const gameId = query.id?.toString();
|
||||||
|
const versionName = query.version?.toString();
|
||||||
|
if (!gameId || !versionName)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "Missing id or version in request params",
|
||||||
|
});
|
||||||
|
|
||||||
|
const preload = await libraryManager.fetchUnimportedVersionInformation(
|
||||||
|
gameId,
|
||||||
|
versionName
|
||||||
|
);
|
||||||
|
if (!preload)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "Invalid game or version id/name",
|
||||||
|
});
|
||||||
|
|
||||||
|
return preload;
|
||||||
|
});
|
||||||
6
server/api/v1/admin/index.get.ts
Normal file
6
server/api/v1/admin/index.get.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default defineEventHandler(async (h3) => {
|
||||||
|
const user = await h3.context.session.getUser(h3);
|
||||||
|
if (!user)
|
||||||
|
throw createError({ statusCode: 403, statusMessage: "Not authenticated" });
|
||||||
|
return { admin: user.admin };
|
||||||
|
});
|
||||||
13
server/api/v1/admin/library/index.get.ts
Normal file
13
server/api/v1/admin/library/index.get.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import libraryManager from "~/server/internal/library";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (h3) => {
|
||||||
|
const user = await h3.context.session.getAdminUser(h3);
|
||||||
|
if (!user) throw createError({ statusCode: 403 });
|
||||||
|
|
||||||
|
const unimportedGames = await libraryManager.fetchAllUnimportedGames();
|
||||||
|
const games = await libraryManager.fetchGamesWithStatus();
|
||||||
|
|
||||||
|
// Fetch other library data here
|
||||||
|
|
||||||
|
return { unimportedGames, games };
|
||||||
|
});
|
||||||
@ -24,7 +24,9 @@ export default defineEventHandler(async (h3) => {
|
|||||||
|
|
||||||
const userId = uuidv4();
|
const userId = uuidv4();
|
||||||
|
|
||||||
const profilePictureObject = await h3.context.objects.createFromSource(
|
const profilePictureId = uuidv4();
|
||||||
|
await h3.context.objects.createFromSource(
|
||||||
|
profilePictureId,
|
||||||
() =>
|
() =>
|
||||||
$fetch<Readable>("https://avatars.githubusercontent.com/u/64579723?v=4", {
|
$fetch<Readable>("https://avatars.githubusercontent.com/u/64579723?v=4", {
|
||||||
responseType: "stream",
|
responseType: "stream",
|
||||||
@ -32,18 +34,12 @@ export default defineEventHandler(async (h3) => {
|
|||||||
{},
|
{},
|
||||||
[`anonymous:read`, `${userId}:write`]
|
[`anonymous:read`, `${userId}:write`]
|
||||||
);
|
);
|
||||||
if (!profilePictureObject)
|
|
||||||
throw createError({
|
|
||||||
statusCode: 500,
|
|
||||||
statusMessage: "Unable to import profile picture",
|
|
||||||
});
|
|
||||||
|
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
username,
|
username,
|
||||||
displayName: "DecDuck",
|
displayName: "DecDuck",
|
||||||
email: "",
|
email: "",
|
||||||
profilePicture: profilePictureObject,
|
profilePicture: profilePictureId,
|
||||||
admin: true,
|
admin: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,17 +1,25 @@
|
|||||||
import clientHandler from "~/server/internal/clients/handler";
|
import clientHandler from "~/server/internal/clients/handler";
|
||||||
|
import { parsePlatform } from "~/server/internal/utils/parseplatform";
|
||||||
|
|
||||||
export default defineEventHandler(async (h3) => {
|
export default defineEventHandler(async (h3) => {
|
||||||
const body = await readBody(h3);
|
const body = await readBody(h3);
|
||||||
|
|
||||||
const name = body.name;
|
const name = body.name;
|
||||||
const platform = body.platform;
|
const platformRaw = body.platform;
|
||||||
|
|
||||||
if (!name || !platform)
|
if (!name || !platformRaw)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
statusMessage: "Missing name or platform in body",
|
statusMessage: "Missing name or platform in body",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const platform = parsePlatform(platformRaw);
|
||||||
|
if (!platform)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "Invalid or unsupported platform",
|
||||||
|
});
|
||||||
|
|
||||||
const clientId = await clientHandler.initiate({ name, platform });
|
const clientId = await clientHandler.initiate({ name, platform });
|
||||||
|
|
||||||
return `/client/${clientId}/callback`;
|
return `/client/${clientId}/callback`;
|
||||||
|
|||||||
36
server/api/v1/games/front.get.ts
Normal file
36
server/api/v1/games/front.get.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import prisma from "~/server/internal/db/database";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (h3) => {
|
||||||
|
const userId = await h3.context.session.getUserId(h3);
|
||||||
|
if (!userId) throw createError({ statusCode: 403 });
|
||||||
|
|
||||||
|
const rawGames = await prisma.game.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
mName: true,
|
||||||
|
mShortDescription: true,
|
||||||
|
mBannerId: true,
|
||||||
|
mDevelopers: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
mName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mPublishers: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
mName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
versions: {
|
||||||
|
select: {
|
||||||
|
platform: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const games = rawGames.map((e) => ({...e, platforms: e.versions.map((e) => e.platform).filter((e, _, r) => !r.includes(e))}))
|
||||||
|
|
||||||
|
return games;
|
||||||
|
});
|
||||||
@ -1,10 +1,11 @@
|
|||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { CertificateBundle } from "./ca";
|
import { CertificateBundle } from "./ca";
|
||||||
import prisma from "../db/database";
|
import prisma from "../db/database";
|
||||||
|
import { Platform } from "@prisma/client";
|
||||||
|
|
||||||
export interface ClientMetadata {
|
export interface ClientMetadata {
|
||||||
name: string;
|
name: string;
|
||||||
platform: string;
|
platform: Platform;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ClientHandler {
|
export class ClientHandler {
|
||||||
|
|||||||
@ -10,6 +10,6 @@ declare const globalThis: {
|
|||||||
|
|
||||||
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
|
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
|
||||||
|
|
||||||
export default prisma
|
export default prisma;
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma
|
if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma
|
||||||
194
server/internal/library/index.ts
Normal file
194
server/internal/library/index.ts
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* The Library Manager keeps track of games in Drop's library and their various states.
|
||||||
|
* It uses path relative to the library, so it can moved without issue
|
||||||
|
*
|
||||||
|
* It also provides the endpoints with information about unmatched games
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import prisma from "../db/database";
|
||||||
|
import { GameVersion, Platform } from "@prisma/client";
|
||||||
|
import { fuzzy } from "fast-fuzzy";
|
||||||
|
import { recursivelyReaddir } from "../utils/recursivedirs";
|
||||||
|
|
||||||
|
class LibraryManager {
|
||||||
|
private basePath: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.basePath = process.env.LIBRARY ?? "./.data/library";
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchAllUnimportedGames() {
|
||||||
|
const dirs = fs.readdirSync(this.basePath).filter((e) => {
|
||||||
|
const fullDir = path.join(this.basePath, e);
|
||||||
|
return fs.lstatSync(fullDir).isDirectory();
|
||||||
|
});
|
||||||
|
|
||||||
|
const validGames = await prisma.game.findMany({
|
||||||
|
where: {
|
||||||
|
libraryBasePath: { in: dirs },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
libraryBasePath: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const validGameDirs = validGames.map((e) => e.libraryBasePath);
|
||||||
|
|
||||||
|
const unregisteredGames = dirs.filter((e) => !validGameDirs.includes(e));
|
||||||
|
|
||||||
|
return unregisteredGames;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchUnimportedGameVersions(
|
||||||
|
libraryBasePath: string,
|
||||||
|
versions: Array<GameVersion>
|
||||||
|
) {
|
||||||
|
const gameDir = path.join(this.basePath, libraryBasePath);
|
||||||
|
const versionsDirs = fs.readdirSync(gameDir);
|
||||||
|
const importedVersionDirs = versions.map((e) => e.versionName);
|
||||||
|
const unimportedVersions = versionsDirs.filter(
|
||||||
|
(e) => !importedVersionDirs.includes(e)
|
||||||
|
);
|
||||||
|
|
||||||
|
return unimportedVersions;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchGamesWithStatus() {
|
||||||
|
const games = await prisma.game.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
versions: true,
|
||||||
|
mName: true,
|
||||||
|
mShortDescription: true,
|
||||||
|
metadataSource: true,
|
||||||
|
mDevelopers: true,
|
||||||
|
mPublishers: true,
|
||||||
|
mIconId: true,
|
||||||
|
libraryBasePath: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return await Promise.all(
|
||||||
|
games.map(async (e) => ({
|
||||||
|
game: e,
|
||||||
|
status: {
|
||||||
|
noVersions: e.versions.length == 0,
|
||||||
|
unimportedVersions: await this.fetchUnimportedGameVersions(
|
||||||
|
e.libraryBasePath,
|
||||||
|
e.versions
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchUnimportedVersions(gameId: string) {
|
||||||
|
const game = await prisma.game.findUnique({
|
||||||
|
where: { id: gameId },
|
||||||
|
select: {
|
||||||
|
versions: {
|
||||||
|
select: {
|
||||||
|
versionName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
libraryBasePath: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!game) return undefined;
|
||||||
|
const targetDir = path.join(this.basePath, game.libraryBasePath);
|
||||||
|
if (!fs.existsSync(targetDir))
|
||||||
|
throw new Error(
|
||||||
|
"Game in database, but no physical directory? Something is very very wrong..."
|
||||||
|
);
|
||||||
|
const versions = fs.readdirSync(targetDir);
|
||||||
|
const currentVersions = game.versions.map((e) => e.versionName);
|
||||||
|
|
||||||
|
const unimportedVersions = versions.filter(
|
||||||
|
(e) => !currentVersions.includes(e)
|
||||||
|
);
|
||||||
|
return unimportedVersions;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchUnimportedVersionInformation(gameId: string, versionName: string) {
|
||||||
|
const game = await prisma.game.findUnique({
|
||||||
|
where: { id: gameId },
|
||||||
|
select: { libraryBasePath: true, mName: true },
|
||||||
|
});
|
||||||
|
if (!game) return undefined;
|
||||||
|
const targetDir = path.join(
|
||||||
|
this.basePath,
|
||||||
|
game.libraryBasePath,
|
||||||
|
versionName
|
||||||
|
);
|
||||||
|
if (!fs.existsSync(targetDir)) return undefined;
|
||||||
|
|
||||||
|
const fileExts: { [key: string]: string[] } = {
|
||||||
|
Linux: [
|
||||||
|
// Ext for Unity games
|
||||||
|
".x86_64",
|
||||||
|
// No extension is common for Linux binaries
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
Windows: [
|
||||||
|
// Pretty much the only one
|
||||||
|
".exe",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const options: Array<{
|
||||||
|
filename: string;
|
||||||
|
platform: string;
|
||||||
|
match: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const files = recursivelyReaddir(targetDir);
|
||||||
|
for (const file of files) {
|
||||||
|
const filename = path.basename(file);
|
||||||
|
const dotLocation = file.lastIndexOf(".");
|
||||||
|
const ext = dotLocation == -1 ? "" : file.slice(dotLocation);
|
||||||
|
for (const [platform, checkExts] of Object.entries(fileExts)) {
|
||||||
|
for (const checkExt of checkExts) {
|
||||||
|
if (checkExt != ext) continue;
|
||||||
|
const fuzzyValue = fuzzy(filename, game.mName);
|
||||||
|
options.push({
|
||||||
|
filename: file,
|
||||||
|
platform: platform,
|
||||||
|
match: fuzzyValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedOptions = options.sort((a, b) => b.match - a.match);
|
||||||
|
let startupGuess = "";
|
||||||
|
let platformGuess = "";
|
||||||
|
if (sortedOptions.length > 0) {
|
||||||
|
const finalChoice = sortedOptions[0];
|
||||||
|
const finalChoiceRelativePath = path.relative(
|
||||||
|
targetDir,
|
||||||
|
finalChoice.filename
|
||||||
|
);
|
||||||
|
startupGuess = finalChoiceRelativePath;
|
||||||
|
platformGuess = finalChoice.platform;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { startupGuess, platformGuess };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checks are done in least to most expensive order
|
||||||
|
async checkUnimportedGamePath(targetPath: string) {
|
||||||
|
const targetDir = path.join(this.basePath, targetPath);
|
||||||
|
if (!fs.existsSync(targetDir)) return false;
|
||||||
|
|
||||||
|
const hasGame =
|
||||||
|
(await prisma.game.count({ where: { libraryBasePath: targetPath } })) > 0;
|
||||||
|
if (hasGame) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const libraryManager = new LibraryManager();
|
||||||
|
export default libraryManager;
|
||||||
@ -50,9 +50,10 @@ interface GameResult {
|
|||||||
|
|
||||||
interface CompanySearchResult {
|
interface CompanySearchResult {
|
||||||
guid: string,
|
guid: string,
|
||||||
deck: string,
|
deck: string | null,
|
||||||
description: string,
|
description: string | null,
|
||||||
name: string,
|
name: string,
|
||||||
|
website: string | null,
|
||||||
|
|
||||||
image: {
|
image: {
|
||||||
icon_url: string,
|
icon_url: string,
|
||||||
@ -191,8 +192,9 @@ export class GiantBombProvider implements MetadataProvider {
|
|||||||
const metadata: PublisherMetadata = {
|
const metadata: PublisherMetadata = {
|
||||||
id: company.guid,
|
id: company.guid,
|
||||||
name: company.name,
|
name: company.name,
|
||||||
shortDescription: company.deck,
|
shortDescription: company.deck ?? "",
|
||||||
description: longDescription,
|
description: longDescription ?? "",
|
||||||
|
website: company.website ?? "",
|
||||||
|
|
||||||
logo: createObject(company.image.icon_url),
|
logo: createObject(company.image.icon_url),
|
||||||
banner: createObject(company.image.screen_large_url),
|
banner: createObject(company.image.screen_large_url),
|
||||||
|
|||||||
@ -1,162 +1,215 @@
|
|||||||
import { Developer, MetadataSource, PrismaClient, Publisher } from "@prisma/client";
|
import {
|
||||||
|
Developer,
|
||||||
|
MetadataSource,
|
||||||
|
PrismaClient,
|
||||||
|
Publisher,
|
||||||
|
} from "@prisma/client";
|
||||||
import prisma from "../db/database";
|
import prisma from "../db/database";
|
||||||
import { _FetchDeveloperMetadataParams, _FetchGameMetadataParams, _FetchPublisherMetadataParams, DeveloperMetadata, GameMetadata, GameMetadataSearchResult, InternalGameMetadataResult, PublisherMetadata } from "./types";
|
import {
|
||||||
|
_FetchDeveloperMetadataParams,
|
||||||
|
_FetchGameMetadataParams,
|
||||||
|
_FetchPublisherMetadataParams,
|
||||||
|
DeveloperMetadata,
|
||||||
|
GameMetadata,
|
||||||
|
GameMetadataSearchResult,
|
||||||
|
InternalGameMetadataResult,
|
||||||
|
PublisherMetadata,
|
||||||
|
} from "./types";
|
||||||
import { ObjectTransactionalHandler } from "../objects/transactional";
|
import { ObjectTransactionalHandler } from "../objects/transactional";
|
||||||
import { PriorityList, PriorityListIndexed } from "../utils/prioritylist";
|
import { PriorityList, PriorityListIndexed } from "../utils/prioritylist";
|
||||||
|
|
||||||
export abstract class MetadataProvider {
|
export abstract class MetadataProvider {
|
||||||
abstract id(): string;
|
abstract id(): string;
|
||||||
abstract name(): string;
|
abstract name(): string;
|
||||||
abstract source(): MetadataSource;
|
abstract source(): MetadataSource;
|
||||||
|
|
||||||
abstract search(query: string): Promise<GameMetadataSearchResult[]>;
|
abstract search(query: string): Promise<GameMetadataSearchResult[]>;
|
||||||
abstract fetchGame(params: _FetchGameMetadataParams): Promise<GameMetadata>;
|
abstract fetchGame(params: _FetchGameMetadataParams): Promise<GameMetadata>;
|
||||||
abstract fetchPublisher(params: _FetchPublisherMetadataParams): Promise<PublisherMetadata>;
|
abstract fetchPublisher(
|
||||||
abstract fetchDeveloper(params: _FetchDeveloperMetadataParams): Promise<DeveloperMetadata>;
|
params: _FetchPublisherMetadataParams
|
||||||
|
): Promise<PublisherMetadata>;
|
||||||
|
abstract fetchDeveloper(
|
||||||
|
params: _FetchDeveloperMetadataParams
|
||||||
|
): Promise<DeveloperMetadata>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MetadataHandler {
|
export class MetadataHandler {
|
||||||
// Ordered by priority
|
// Ordered by priority
|
||||||
private providers: PriorityListIndexed<MetadataProvider> = new PriorityListIndexed("id");
|
private providers: PriorityListIndexed<MetadataProvider> =
|
||||||
private objectHandler: ObjectTransactionalHandler = new ObjectTransactionalHandler();
|
new PriorityListIndexed("id");
|
||||||
|
private objectHandler: ObjectTransactionalHandler =
|
||||||
|
new ObjectTransactionalHandler();
|
||||||
|
|
||||||
addProvider(provider: MetadataProvider, priority: number = 0) {
|
addProvider(provider: MetadataProvider, priority: number = 0) {
|
||||||
this.providers.push(provider, priority);
|
this.providers.push(provider, priority);
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(query: string) {
|
async search(query: string) {
|
||||||
const promises: Promise<InternalGameMetadataResult[]>[] = [];
|
const promises: Promise<InternalGameMetadataResult[]>[] = [];
|
||||||
for (const provider of this.providers.values()) {
|
for (const provider of this.providers.values()) {
|
||||||
const queryTransformationPromise = new Promise<InternalGameMetadataResult[]>(async (resolve, reject) => {
|
const queryTransformationPromise = new Promise<
|
||||||
const results = await provider.search(query);
|
InternalGameMetadataResult[]
|
||||||
const mappedResults: InternalGameMetadataResult[] = results.map((result) => Object.assign(
|
>(async (resolve, reject) => {
|
||||||
{},
|
const results = await provider.search(query);
|
||||||
result,
|
const mappedResults: InternalGameMetadataResult[] = results.map(
|
||||||
{
|
(result) =>
|
||||||
sourceId: provider.id(),
|
Object.assign({}, result, {
|
||||||
sourceName: provider.name()
|
sourceId: provider.id(),
|
||||||
}
|
sourceName: provider.name(),
|
||||||
));
|
|
||||||
resolve(mappedResults);
|
|
||||||
});
|
|
||||||
promises.push(queryTransformationPromise);
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.allSettled(promises);
|
|
||||||
const successfulResults = results.filter((result) => result.status === 'fulfilled').map((result) => result.value).flat();
|
|
||||||
|
|
||||||
return successfulResults;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchGame(result: InternalGameMetadataResult) {
|
|
||||||
const provider = this.providers.get(result.sourceId);
|
|
||||||
if (!provider) throw new Error(`Invalid metadata provider for ID "${result.sourceId}"`);
|
|
||||||
|
|
||||||
const existing = await prisma.game.findUnique({
|
|
||||||
where: {
|
|
||||||
metadataKey: {
|
|
||||||
metadataSource: provider.source(),
|
|
||||||
metadataId: provider.id(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (existing) return existing;
|
|
||||||
|
|
||||||
const [createObject, pullObjects, dumpObjects] = this.objectHandler.new();
|
|
||||||
|
|
||||||
let metadata;
|
|
||||||
try {
|
|
||||||
metadata = await provider.fetchGame({
|
|
||||||
id: result.id,
|
|
||||||
publisher: this.fetchPublisher,
|
|
||||||
developer: this.fetchDeveloper,
|
|
||||||
createObject,
|
|
||||||
})
|
})
|
||||||
} catch (e) {
|
);
|
||||||
dumpObjects();
|
resolve(mappedResults);
|
||||||
throw e;
|
});
|
||||||
}
|
promises.push(queryTransformationPromise);
|
||||||
|
|
||||||
await pullObjects();
|
|
||||||
const game = await prisma.game.create({
|
|
||||||
data: {
|
|
||||||
metadataSource: provider.source(),
|
|
||||||
metadataId: metadata.id,
|
|
||||||
|
|
||||||
mName: metadata.name,
|
|
||||||
mShortDescription: metadata.shortDescription,
|
|
||||||
mDescription: metadata.description,
|
|
||||||
mDevelopers: {
|
|
||||||
connect: metadata.developers
|
|
||||||
},
|
|
||||||
mPublishers: {
|
|
||||||
connect: metadata.publishers,
|
|
||||||
},
|
|
||||||
|
|
||||||
mReviewCount: metadata.reviewCount,
|
|
||||||
mReviewRating: metadata.reviewRating,
|
|
||||||
|
|
||||||
mIconId: metadata.icon,
|
|
||||||
mBannerId: metadata.banner,
|
|
||||||
mArt: metadata.art,
|
|
||||||
mScreenshots: metadata.screenshots,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return game;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchDeveloper(query: string) {
|
const results = await Promise.allSettled(promises);
|
||||||
return await this.fetchDeveloperPublisher(query, "fetchDeveloper", "developer") as Developer;
|
const successfulResults = results
|
||||||
|
.filter((result) => result.status === "fulfilled")
|
||||||
|
.map((result) => result.value)
|
||||||
|
.flat();
|
||||||
|
|
||||||
|
return successfulResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createGame(
|
||||||
|
result: InternalGameMetadataResult,
|
||||||
|
libraryBasePath: string
|
||||||
|
) {
|
||||||
|
const provider = this.providers.get(result.sourceId);
|
||||||
|
if (!provider)
|
||||||
|
throw new Error(`Invalid metadata provider for ID "${result.sourceId}"`);
|
||||||
|
|
||||||
|
const existing = await prisma.game.findUnique({
|
||||||
|
where: {
|
||||||
|
metadataKey: {
|
||||||
|
metadataSource: provider.source(),
|
||||||
|
metadataId: provider.id(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const [createObject, pullObjects, dumpObjects] = this.objectHandler.new(
|
||||||
|
{},
|
||||||
|
["internal:read"]
|
||||||
|
);
|
||||||
|
|
||||||
|
let metadata;
|
||||||
|
try {
|
||||||
|
metadata = await provider.fetchGame({
|
||||||
|
id: result.id,
|
||||||
|
// wrap in anonymous functions to keep references to this
|
||||||
|
publisher: (name: string) => this.fetchPublisher(name),
|
||||||
|
developer: (name: string) => this.fetchDeveloper(name),
|
||||||
|
createObject,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
dumpObjects();
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchPublisher(query: string) {
|
await pullObjects();
|
||||||
return await this.fetchDeveloperPublisher(query, "fetchPublisher", "publisher") as Publisher;
|
const game = await prisma.game.create({
|
||||||
|
data: {
|
||||||
|
metadataSource: provider.source(),
|
||||||
|
metadataId: metadata.id,
|
||||||
|
|
||||||
|
mName: metadata.name,
|
||||||
|
mShortDescription: metadata.shortDescription,
|
||||||
|
mDescription: metadata.description,
|
||||||
|
mDevelopers: {
|
||||||
|
connect: metadata.developers,
|
||||||
|
},
|
||||||
|
mPublishers: {
|
||||||
|
connect: metadata.publishers,
|
||||||
|
},
|
||||||
|
|
||||||
|
mReviewCount: metadata.reviewCount,
|
||||||
|
mReviewRating: metadata.reviewRating,
|
||||||
|
|
||||||
|
mIconId: metadata.icon,
|
||||||
|
mBannerId: metadata.banner,
|
||||||
|
mArt: metadata.art,
|
||||||
|
mScreenshots: metadata.screenshots,
|
||||||
|
|
||||||
|
versionOrder: [],
|
||||||
|
libraryBasePath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchDeveloper(query: string) {
|
||||||
|
return (await this.fetchDeveloperPublisher(
|
||||||
|
query,
|
||||||
|
"fetchDeveloper",
|
||||||
|
"developer"
|
||||||
|
)) as Developer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchPublisher(query: string) {
|
||||||
|
return (await this.fetchDeveloperPublisher(
|
||||||
|
query,
|
||||||
|
"fetchPublisher",
|
||||||
|
"publisher"
|
||||||
|
)) as Publisher;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Careful with this function, it has no typechecking
|
||||||
|
// TODO: fix typechecking
|
||||||
|
private async fetchDeveloperPublisher(
|
||||||
|
query: string,
|
||||||
|
functionName: any,
|
||||||
|
databaseName: any
|
||||||
|
) {
|
||||||
|
const existing = await (prisma as any)[databaseName].findFirst({
|
||||||
|
where: {
|
||||||
|
metadataOriginalQuery: query,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
for (const provider of this.providers.values() as any) {
|
||||||
|
const [createObject, pullObjects, dumpObjects] = this.objectHandler.new(
|
||||||
|
{},
|
||||||
|
["internal:read"]
|
||||||
|
);
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await provider[functionName]({ query, createObject });
|
||||||
|
} catch(e) {
|
||||||
|
console.warn(e);
|
||||||
|
dumpObjects();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're successful
|
||||||
|
await pullObjects();
|
||||||
|
|
||||||
|
const object = await (prisma as any)[databaseName].create({
|
||||||
|
data: {
|
||||||
|
metadataSource: provider.source(),
|
||||||
|
metadataId: provider.id(),
|
||||||
|
metadataOriginalQuery: query,
|
||||||
|
|
||||||
|
mName: result.name,
|
||||||
|
mShortDescription: result.shortDescription,
|
||||||
|
mDescription: result.description,
|
||||||
|
mLogo: result.logo,
|
||||||
|
mBanner: result.banner,
|
||||||
|
mWebsite: result.website,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return object;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Careful with this function, it has no typechecking
|
throw new Error(
|
||||||
// TODO: fix typechecking
|
`No metadata provider found a ${databaseName} for "${query}"`
|
||||||
private async fetchDeveloperPublisher(query: string, functionName: any, databaseName: any) {
|
);
|
||||||
const existing = await (prisma as any)[databaseName].findFirst({
|
}
|
||||||
where: {
|
|
||||||
mName: query,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (existing) return existing;
|
|
||||||
|
|
||||||
for (const provider of this.providers.values() as any) {
|
|
||||||
const [createObject, pullObjects, dumpObjects] = this.objectHandler.new();
|
|
||||||
let result;
|
|
||||||
try {
|
|
||||||
result = await provider[functionName]({ query, createObject });
|
|
||||||
} catch {
|
|
||||||
dumpObjects();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we're successful
|
|
||||||
await pullObjects();
|
|
||||||
|
|
||||||
const object = await (prisma as any)[databaseName].create({
|
|
||||||
data: {
|
|
||||||
metadataSource: provider.source(),
|
|
||||||
metadataId: provider.id(),
|
|
||||||
|
|
||||||
mName: result.name,
|
|
||||||
mShortDescription: result.shortDescription,
|
|
||||||
mDescription: result.description,
|
|
||||||
mLogo: result.logo,
|
|
||||||
mBanner: result.banner,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return object;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`No metadata provider found a ${databaseName} for "${query}"`);
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new MetadataHandler();
|
export default new MetadataHandler();
|
||||||
|
|||||||
1
server/internal/metadata/types.d.ts
vendored
1
server/internal/metadata/types.d.ts
vendored
@ -45,6 +45,7 @@ export interface PublisherMetadata {
|
|||||||
|
|
||||||
logo: ObjectReference;
|
logo: ObjectReference;
|
||||||
banner: ObjectReference;
|
banner: ObjectReference;
|
||||||
|
website: String;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeveloperMetadata = PublisherMetadata;
|
export type DeveloperMetadata = PublisherMetadata;
|
||||||
|
|||||||
@ -45,10 +45,10 @@ export class FsObjectBackend extends ObjectBackend {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
async create(
|
async create(
|
||||||
|
id: string,
|
||||||
source: Source,
|
source: Source,
|
||||||
metadata: ObjectMetadata
|
metadata: ObjectMetadata
|
||||||
): Promise<ObjectReference | undefined> {
|
): Promise<ObjectReference | undefined> {
|
||||||
const id = uuidv4();
|
|
||||||
const objectPath = path.join(this.baseObjectPath, sanitize(id));
|
const objectPath = path.join(this.baseObjectPath, sanitize(id));
|
||||||
const metadataPath = path.join(
|
const metadataPath = path.join(
|
||||||
this.baseMetadataPath,
|
this.baseMetadataPath,
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
import { parse as getMimeTypeBuffer } from "file-type-mime";
|
import { parse as getMimeTypeBuffer } from "file-type-mime";
|
||||||
import { Readable } from "stream";
|
import { Readable } from "stream";
|
||||||
import { getMimeType as getMimeTypeStream } from "stream-mime-type";
|
import { getMimeType as getMimeTypeStream } from "stream-mime-type";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
export type ObjectReference = string;
|
export type ObjectReference = string;
|
||||||
export type ObjectMetadata = {
|
export type ObjectMetadata = {
|
||||||
@ -46,6 +47,7 @@ export abstract class ObjectBackend {
|
|||||||
abstract fetch(id: ObjectReference): Promise<Source | undefined>;
|
abstract fetch(id: ObjectReference): Promise<Source | undefined>;
|
||||||
abstract write(id: ObjectReference, source: Source): Promise<boolean>;
|
abstract write(id: ObjectReference, source: Source): Promise<boolean>;
|
||||||
abstract create(
|
abstract create(
|
||||||
|
id: string,
|
||||||
source: Source,
|
source: Source,
|
||||||
metadata: ObjectMetadata
|
metadata: ObjectMetadata
|
||||||
): Promise<ObjectReference | undefined>;
|
): Promise<ObjectReference | undefined>;
|
||||||
@ -59,6 +61,7 @@ export abstract class ObjectBackend {
|
|||||||
): Promise<boolean>;
|
): Promise<boolean>;
|
||||||
|
|
||||||
async createFromSource(
|
async createFromSource(
|
||||||
|
id: string,
|
||||||
sourceFetcher: () => Promise<Source>,
|
sourceFetcher: () => Promise<Source>,
|
||||||
metadata: { [key: string]: string },
|
metadata: { [key: string]: string },
|
||||||
permissions: Array<string>
|
permissions: Array<string>
|
||||||
@ -83,13 +86,11 @@ export abstract class ObjectBackend {
|
|||||||
if (!mime)
|
if (!mime)
|
||||||
throw new Error("Unable to calculate MIME type - is the source empty?");
|
throw new Error("Unable to calculate MIME type - is the source empty?");
|
||||||
|
|
||||||
const objectId = this.create(source, {
|
await this.create(id, source, {
|
||||||
permissions,
|
permissions,
|
||||||
userMetadata: metadata,
|
userMetadata: metadata,
|
||||||
mime,
|
mime,
|
||||||
});
|
});
|
||||||
|
|
||||||
return objectId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchWithPermissions(id: ObjectReference, userId?: string) {
|
async fetchWithPermissions(id: ObjectReference, userId?: string) {
|
||||||
|
|||||||
@ -2,7 +2,9 @@
|
|||||||
The purpose of this class is to hold references to remote objects (like images) until they're actually needed
|
The purpose of this class is to hold references to remote objects (like images) until they're actually needed
|
||||||
This is used as a utility in metadata handling, so we only fetch the objects if we're actually creating a database record.
|
This is used as a utility in metadata handling, so we only fetch the objects if we're actually creating a database record.
|
||||||
*/
|
*/
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { Readable } from "stream";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { GlobalObjectHandler } from "~/server/plugins/objects";
|
||||||
|
|
||||||
type TransactionTable = { [key: string]: string }; // ID to URL
|
type TransactionTable = { [key: string]: string }; // ID to URL
|
||||||
type GlobalTransactionRecord = { [key: string]: TransactionTable }; // Transaction ID to table
|
type GlobalTransactionRecord = { [key: string]: TransactionTable }; // Transaction ID to table
|
||||||
@ -12,27 +14,38 @@ type Pull = () => Promise<void>;
|
|||||||
type Dump = () => void;
|
type Dump = () => void;
|
||||||
|
|
||||||
export class ObjectTransactionalHandler {
|
export class ObjectTransactionalHandler {
|
||||||
private record: GlobalTransactionRecord = {};
|
private record: GlobalTransactionRecord = {};
|
||||||
|
|
||||||
new(): [Register, Pull, Dump] {
|
new(
|
||||||
const transactionId = uuidv4();
|
metadata: { [key: string]: string },
|
||||||
|
permissions: Array<string>
|
||||||
|
): [Register, Pull, Dump] {
|
||||||
|
const transactionId = uuidv4();
|
||||||
|
|
||||||
const register = (url: string) => {
|
this.record[transactionId] ??= {};
|
||||||
const objectId = uuidv4();
|
|
||||||
this.record[transactionId][objectId] = url;
|
|
||||||
|
|
||||||
return objectId;
|
const register = (url: string) => {
|
||||||
}
|
const objectId = uuidv4();
|
||||||
|
this.record[transactionId][objectId] = url;
|
||||||
|
|
||||||
const pull = async () => {
|
return objectId;
|
||||||
// Dummy function
|
};
|
||||||
dump();
|
|
||||||
}
|
|
||||||
|
|
||||||
const dump = () => {
|
const pull = async () => {
|
||||||
delete this.record[transactionId];
|
for (const [id, url] of Object.entries(this.record[transactionId])) {
|
||||||
}
|
await GlobalObjectHandler.createFromSource(
|
||||||
|
id,
|
||||||
|
() => $fetch<Readable>(url, { responseType: "stream" }),
|
||||||
|
metadata,
|
||||||
|
permissions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return [register, pull, dump];
|
const dump = () => {
|
||||||
}
|
delete this.record[transactionId];
|
||||||
}
|
};
|
||||||
|
|
||||||
|
return [register, pull, dump];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -13,52 +13,71 @@ const userSessionKey = "_userSession";
|
|||||||
const userIdKey = "_userId";
|
const userIdKey = "_userId";
|
||||||
|
|
||||||
export class SessionHandler {
|
export class SessionHandler {
|
||||||
private sessionProvider: SessionProvider;
|
private sessionProvider: SessionProvider;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Create a new provider
|
// Create a new provider
|
||||||
this.sessionProvider = createMemorySessionProvider();
|
this.sessionProvider = createMemorySessionProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSession<T extends Session>(h3: H3Event) {
|
||||||
|
const data = await this.sessionProvider.getSession<{ [userSessionKey]: T }>(
|
||||||
|
h3
|
||||||
|
);
|
||||||
|
if (!data) return undefined;
|
||||||
|
|
||||||
|
return data[userSessionKey];
|
||||||
|
}
|
||||||
|
async setSession(h3: H3Event, data: any, expend = false) {
|
||||||
|
const result = await this.sessionProvider.updateSession(
|
||||||
|
h3,
|
||||||
|
userSessionKey,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
if (!result) {
|
||||||
|
const toCreate = { [userSessionKey]: data };
|
||||||
|
await this.sessionProvider.setSession(h3, toCreate, expend);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
async clearSession(h3: H3Event) {
|
||||||
|
await this.sessionProvider.clearSession(h3);
|
||||||
|
}
|
||||||
|
|
||||||
async getSession<T extends Session>(h3: H3Event) {
|
async getUserId(h3: H3Event) {
|
||||||
const data = await this.sessionProvider.getSession<{ [userSessionKey]: T }>(h3);
|
const session = await this.sessionProvider.getSession<{
|
||||||
if (!data) return undefined;
|
[userIdKey]: string | undefined;
|
||||||
|
}>(h3);
|
||||||
|
if (!session) return undefined;
|
||||||
|
|
||||||
return data[userSessionKey];
|
return session[userIdKey];
|
||||||
}
|
}
|
||||||
async setSession(h3: H3Event, data: any, expend = false) {
|
|
||||||
const result = await this.sessionProvider.updateSession(h3, userSessionKey, data);
|
async getUser(h3: H3Event) {
|
||||||
if (!result) {
|
const userId = await this.getUserId(h3);
|
||||||
const toCreate = { [userSessionKey]: data };
|
if (!userId) return undefined;
|
||||||
await this.sessionProvider.setSession(h3, toCreate, expend);
|
|
||||||
}
|
const user = await prisma.user.findFirst({ where: { id: userId } });
|
||||||
}
|
return user;
|
||||||
async clearSession(h3: H3Event) {
|
}
|
||||||
await this.sessionProvider.clearSession(h3);
|
|
||||||
|
async setUserId(h3: H3Event, userId: string, extend = false) {
|
||||||
|
const result = await this.sessionProvider.updateSession(
|
||||||
|
h3,
|
||||||
|
userIdKey,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
if (!result) {
|
||||||
|
const toCreate = { [userIdKey]: userId };
|
||||||
|
await this.sessionProvider.setSession(h3, toCreate, extend);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getUserId(h3: H3Event) {
|
async getAdminUser(h3: H3Event) {
|
||||||
const session = await this.sessionProvider.getSession<{ [userIdKey]: string | undefined }>(h3);
|
const user = await this.getUser(h3);
|
||||||
if (!session) return undefined;
|
if (!user) return undefined;
|
||||||
|
if (!user.admin) return undefined;
|
||||||
return session[userIdKey];
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUser(h3: H3Event) {
|
|
||||||
const userId = await this.getUserId(h3);
|
|
||||||
if (!userId) return undefined;
|
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({ where: { id: userId } });
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
async setUserId(h3: H3Event, userId: string, extend = false) {
|
|
||||||
const result = await this.sessionProvider.updateSession(h3, userIdKey, userId);
|
|
||||||
if (!result) {
|
|
||||||
const toCreate = { [userIdKey]: userId };
|
|
||||||
await this.sessionProvider.setSession(h3, toCreate, extend);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new SessionHandler();
|
export default new SessionHandler();
|
||||||
|
|||||||
14
server/internal/utils/parseplatform.ts
Normal file
14
server/internal/utils/parseplatform.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { Platform } from "@prisma/client";
|
||||||
|
|
||||||
|
export function parsePlatform(platform: string) {
|
||||||
|
switch (platform) {
|
||||||
|
case "linux":
|
||||||
|
case "Linux":
|
||||||
|
return Platform.Linux;
|
||||||
|
case "windows":
|
||||||
|
case "Windows":
|
||||||
|
return Platform.Windows;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
20
server/internal/utils/recursivedirs.ts
Normal file
20
server/internal/utils/recursivedirs.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export function recursivelyReaddir(dir: string) {
|
||||||
|
const result: Array<string> = [];
|
||||||
|
const files = fs.readdirSync(dir);
|
||||||
|
for (const file of files) {
|
||||||
|
const targetDir = path.join(dir, file);
|
||||||
|
const stat = fs.lstatSync(targetDir);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
const subdirs = recursivelyReaddir(targetDir);
|
||||||
|
const subdirsWithBase = subdirs.map((e) => path.join(dir, e));
|
||||||
|
result.push(...subdirsWithBase);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.push(targetDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@ -1,11 +1,10 @@
|
|||||||
import { FsObjectBackend } from "../internal/objects/fsBackend";
|
import { FsObjectBackend } from "../internal/objects/fsBackend";
|
||||||
|
|
||||||
|
// To-do insert logic surrounding deciding what object backend to use
|
||||||
|
export const GlobalObjectHandler = new FsObjectBackend();
|
||||||
|
|
||||||
export default defineNitroPlugin((nitro) => {
|
export default defineNitroPlugin((nitro) => {
|
||||||
const currentObjectHandler = new FsObjectBackend();
|
|
||||||
|
|
||||||
// To-do insert logic surrounding deciding what object backend to use
|
|
||||||
|
|
||||||
nitro.hooks.hook("request", (h3) => {
|
nitro.hooks.hook("request", (h3) => {
|
||||||
h3.context.objects = currentObjectHandler;
|
h3.context.objects = GlobalObjectHandler;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
68
yarn.lock
68
yarn.lock
@ -296,23 +296,23 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
mime "^3.0.0"
|
mime "^3.0.0"
|
||||||
|
|
||||||
"@drop/droplet-linux-x64-gnu@0.3.2":
|
"@drop/droplet-linux-x64-gnu@0.4.1", "@drop/droplet-linux-x64-gnu@^0.4.1":
|
||||||
version "0.3.2"
|
version "0.4.1"
|
||||||
resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet-linux-x64-gnu/-/@drop/droplet-linux-x64-gnu-0.3.2.tgz#9be12f1e61df67837bb225ec67cc98f5af8f703b"
|
resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet-linux-x64-gnu/-/@drop/droplet-linux-x64-gnu-0.4.1.tgz#24f9ccebf7349bec450b855571b300284fb3731f"
|
||||||
integrity sha1-m+EvHmHfZ4N7siXsZ8yY9a+PcDs=
|
integrity sha1-JPnM6/c0m+xFC4VVcbMAKE+zcx8=
|
||||||
|
|
||||||
"@drop/droplet-win32-x64-msvc@0.3.2":
|
"@drop/droplet-win32-x64-msvc@0.4.1", "@drop/droplet-win32-x64-msvc@^0.4.1":
|
||||||
version "0.3.2"
|
version "0.4.1"
|
||||||
resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet-win32-x64-msvc/-/@drop/droplet-win32-x64-msvc-0.3.2.tgz#dc5d0fa8334bf211666e99ca365c900d363f7823"
|
resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet-win32-x64-msvc/-/@drop/droplet-win32-x64-msvc-0.4.1.tgz#58238faca15b36abb02162354c2f39526bc213a1"
|
||||||
integrity sha1-3F0PqDNL8hFmbpnKNlyQDTY/eCM=
|
integrity sha1-WCOPrKFbNquwIWI1TC85UmvCE6E=
|
||||||
|
|
||||||
"@drop/droplet@^0.3.2":
|
"@drop/droplet@^0.4.1":
|
||||||
version "0.3.2"
|
version "0.4.1"
|
||||||
resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet/-/@drop/droplet-0.3.2.tgz#f57cf35e50dfd448b7837f9b4712543ff160e769"
|
resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet/-/@drop/droplet-0.4.1.tgz#d4f3a7950fad2a95487ce4c014e1c782c2fcc3c7"
|
||||||
integrity sha1-9XzzXlDf1Ei3g3+bRxJUP/Fg52k=
|
integrity sha1-1POnlQ+tKpVIfOTAFOHHgsL8w8c=
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
"@drop/droplet-linux-x64-gnu" "0.3.2"
|
"@drop/droplet-linux-x64-gnu" "0.4.1"
|
||||||
"@drop/droplet-win32-x64-msvc" "0.3.2"
|
"@drop/droplet-win32-x64-msvc" "0.4.1"
|
||||||
|
|
||||||
"@esbuild/aix-ppc64@0.20.2":
|
"@esbuild/aix-ppc64@0.20.2":
|
||||||
version "0.20.2"
|
version "0.20.2"
|
||||||
@ -2709,6 +2709,13 @@ fast-fifo@^1.2.0, fast-fifo@^1.3.2:
|
|||||||
resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.3.2.tgz#286e31de96eb96d38a97899815740ba2a4f3640c"
|
resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.3.2.tgz#286e31de96eb96d38a97899815740ba2a4f3640c"
|
||||||
integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==
|
integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==
|
||||||
|
|
||||||
|
fast-fuzzy@^1.12.0:
|
||||||
|
version "1.12.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/fast-fuzzy/-/fast-fuzzy-1.12.0.tgz#f900a8165bffbb7dd5c013a1bb96ee22179ba406"
|
||||||
|
integrity sha512-sXxGgHS+ubYpsdLnvOvJ9w5GYYZrtL9mkosG3nfuD446ahvoWEsSKBP7ieGmWIKVLnaxRDgUJkZMdxRgA2Ni+Q==
|
||||||
|
dependencies:
|
||||||
|
graphemesplit "^2.4.1"
|
||||||
|
|
||||||
fast-glob@^3.2.7, fast-glob@^3.3.0, fast-glob@^3.3.2:
|
fast-glob@^3.2.7, fast-glob@^3.3.0, fast-glob@^3.3.2:
|
||||||
version "3.3.2"
|
version "3.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129"
|
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129"
|
||||||
@ -2983,6 +2990,14 @@ graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.9:
|
|||||||
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
|
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
|
||||||
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
|
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
|
||||||
|
|
||||||
|
graphemesplit@^2.4.1:
|
||||||
|
version "2.4.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/graphemesplit/-/graphemesplit-2.4.4.tgz#6d325c61e928efdaec2189f54a9b87babf89b75a"
|
||||||
|
integrity sha512-lKrpp1mk1NH26USxC/Asw4OHbhSQf5XfrWZ+CDv/dFVvd1j17kFgMotdJvOesmHkbFX9P9sBfpH8VogxOWLg8w==
|
||||||
|
dependencies:
|
||||||
|
js-base64 "^3.6.0"
|
||||||
|
unicode-trie "^2.0.0"
|
||||||
|
|
||||||
gzip-size@^7.0.0:
|
gzip-size@^7.0.0:
|
||||||
version "7.0.0"
|
version "7.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-7.0.0.tgz#9f9644251f15bc78460fccef4055ae5a5562ac60"
|
resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-7.0.0.tgz#9f9644251f15bc78460fccef4055ae5a5562ac60"
|
||||||
@ -3306,6 +3321,11 @@ jiti@^2.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.0.0.tgz#ccaab6ce73a73cbf04e187645c614b3a3d41b653"
|
resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.0.0.tgz#ccaab6ce73a73cbf04e187645c614b3a3d41b653"
|
||||||
integrity sha512-CJ7e7Abb779OTRv3lomfp7Mns/Sy1+U4pcAx5VbjxCZD5ZM/VJaXPpPjNKjtSvWQy/H86E49REXR34dl1JEz9w==
|
integrity sha512-CJ7e7Abb779OTRv3lomfp7Mns/Sy1+U4pcAx5VbjxCZD5ZM/VJaXPpPjNKjtSvWQy/H86E49REXR34dl1JEz9w==
|
||||||
|
|
||||||
|
js-base64@^3.6.0:
|
||||||
|
version "3.7.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-3.7.7.tgz#e51b84bf78fbf5702b9541e2cb7bfcb893b43e79"
|
||||||
|
integrity sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==
|
||||||
|
|
||||||
js-tokens@^4.0.0:
|
js-tokens@^4.0.0:
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
|
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
|
||||||
@ -3838,7 +3858,7 @@ nuxi@^3.13.2:
|
|||||||
resolved "https://registry.yarnpkg.com/nuxi/-/nuxi-3.14.0.tgz#697a1e8b4f0d92fb8b30aa355af9295fa8c2cb4e"
|
resolved "https://registry.yarnpkg.com/nuxi/-/nuxi-3.14.0.tgz#697a1e8b4f0d92fb8b30aa355af9295fa8c2cb4e"
|
||||||
integrity sha512-MhG4QR6D95jQxhnwKfdKXulZ8Yqy1nbpwbotbxY5IcabOzpEeTB8hYn2BFkmYdMUB0no81qpv2ldZmVCT9UsnQ==
|
integrity sha512-MhG4QR6D95jQxhnwKfdKXulZ8Yqy1nbpwbotbxY5IcabOzpEeTB8hYn2BFkmYdMUB0no81qpv2ldZmVCT9UsnQ==
|
||||||
|
|
||||||
nuxt@^3.13.0:
|
nuxt@^3.13.2:
|
||||||
version "3.13.2"
|
version "3.13.2"
|
||||||
resolved "https://registry.yarnpkg.com/nuxt/-/nuxt-3.13.2.tgz#af43a1fb5ccaaf98be0aaeca1bee504eeee24135"
|
resolved "https://registry.yarnpkg.com/nuxt/-/nuxt-3.13.2.tgz#af43a1fb5ccaaf98be0aaeca1bee504eeee24135"
|
||||||
integrity sha512-Bjc2qRsipfBhjXsBEJCN+EUAukhdgFv/KoIR5HFB2hZOYRSqXBod3oWQs78k3ja1nlIhAEdBG533898KJxUtJw==
|
integrity sha512-Bjc2qRsipfBhjXsBEJCN+EUAukhdgFv/KoIR5HFB2hZOYRSqXBod3oWQs78k3ja1nlIhAEdBG533898KJxUtJw==
|
||||||
@ -4006,6 +4026,11 @@ package-manager-detector@^0.2.0:
|
|||||||
resolved "https://registry.yarnpkg.com/package-manager-detector/-/package-manager-detector-0.2.0.tgz#160395cd5809181f5a047222319262b8c2d8aaea"
|
resolved "https://registry.yarnpkg.com/package-manager-detector/-/package-manager-detector-0.2.0.tgz#160395cd5809181f5a047222319262b8c2d8aaea"
|
||||||
integrity sha512-E385OSk9qDcXhcM9LNSe4sdhx8a9mAPrZ4sMLW+tmxl5ZuGtPUcdFu+MPP2jbgiWAZ6Pfe5soGFMd+0Db5Vrog==
|
integrity sha512-E385OSk9qDcXhcM9LNSe4sdhx8a9mAPrZ4sMLW+tmxl5ZuGtPUcdFu+MPP2jbgiWAZ6Pfe5soGFMd+0Db5Vrog==
|
||||||
|
|
||||||
|
pako@^0.2.5:
|
||||||
|
version "0.2.9"
|
||||||
|
resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75"
|
||||||
|
integrity sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==
|
||||||
|
|
||||||
parse-git-config@^3.0.0:
|
parse-git-config@^3.0.0:
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/parse-git-config/-/parse-git-config-3.0.0.tgz#4a2de08c7b74a2555efa5ae94d40cd44302a6132"
|
resolved "https://registry.yarnpkg.com/parse-git-config/-/parse-git-config-3.0.0.tgz#4a2de08c7b74a2555efa5ae94d40cd44302a6132"
|
||||||
@ -5085,6 +5110,11 @@ through2@4.0.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
readable-stream "3"
|
readable-stream "3"
|
||||||
|
|
||||||
|
tiny-inflate@^1.0.0:
|
||||||
|
version "1.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz#122715494913a1805166aaf7c93467933eea26c4"
|
||||||
|
integrity sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==
|
||||||
|
|
||||||
tiny-invariant@^1.1.0:
|
tiny-invariant@^1.1.0:
|
||||||
version "1.3.3"
|
version "1.3.3"
|
||||||
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127"
|
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127"
|
||||||
@ -5220,6 +5250,14 @@ unhead@1.11.6, unhead@^1.11.5:
|
|||||||
"@unhead/shared" "1.11.6"
|
"@unhead/shared" "1.11.6"
|
||||||
hookable "^5.5.3"
|
hookable "^5.5.3"
|
||||||
|
|
||||||
|
unicode-trie@^2.0.0:
|
||||||
|
version "2.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/unicode-trie/-/unicode-trie-2.0.0.tgz#8fd8845696e2e14a8b67d78fa9e0dd2cad62fec8"
|
||||||
|
integrity sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==
|
||||||
|
dependencies:
|
||||||
|
pako "^0.2.5"
|
||||||
|
tiny-inflate "^1.0.0"
|
||||||
|
|
||||||
unicorn-magic@^0.1.0:
|
unicorn-magic@^0.1.0:
|
||||||
version "0.1.0"
|
version "0.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/unicorn-magic/-/unicorn-magic-0.1.0.tgz#1bb9a51c823aaf9d73a8bfcd3d1a23dde94b0ce4"
|
resolved "https://registry.yarnpkg.com/unicorn-magic/-/unicorn-magic-0.1.0.tgz#1bb9a51c823aaf9d73a8bfcd3d1a23dde94b0ce4"
|
||||||
|
|||||||
Reference in New Issue
Block a user