feat: beginnings of platform & redist management

This commit is contained in:
DecDuck
2025-08-27 19:52:36 +10:00
parent d323816b9e
commit ca7a89bbcf
17 changed files with 286 additions and 125 deletions

View File

@ -129,7 +129,7 @@
<LoadingButton
class="w-fit"
:loading="props.loading"
:disabled="!(name && description && currentFileObjectUrl)"
:disabled="buttonDisabled"
@click="() => importRedist()"
>
{{ $t("library.admin.import.import") }}
@ -178,6 +178,8 @@ const platform = ref<{ name: string; icon: string; fileExts: string[] }>({
fileExts: [],
});
const buttonDisabled = computed<boolean>(() => !(name.value && description.value && currentFileObjectUrl.value && (!isPlatform.value || (platform.value.name && platform.value.icon))))
function addFile(event: Event) {
const file = (event.target as HTMLInputElement)?.files?.[0];
if (!file) return;

View File

@ -8,6 +8,8 @@ const props = defineProps({
},
});
await updateUser();
const { t } = useI18n();
const route = useRoute();
const user = useUser();

View File

@ -281,8 +281,8 @@
"addGames": "All Games",
"addToLib": "Add to Library",
"admin": {
"detectedGame": "Drop has detected you have new games to import.",
"detectedVersion": "Drop has detected you have new versions of this game to import.",
"detectedGame": "Drop has detected you have new items to import.",
"detectedVersion": "Drop has detected you have new versions to import.",
"game": {
"addCarouselNoImages": "No images to add.",
"addDescriptionNoImages": "No images to add.",
@ -429,7 +429,7 @@
"title": "Libraries",
"version": {
"delta": "Upgrade mode",
"noVersions": "You have no versions of this game available.",
"noVersions": "No versions available.",
"noVersionsAdded": "no versions added"
},
"versionPriority": "Version priority"

View File

@ -37,7 +37,6 @@
"bcryptjs": "^3.0.2",
"cheerio": "^1.0.0",
"cookie-es": "^2.0.0",
"dompurify": "^3.2.6",
"fast-fuzzy": "^1.12.0",
"file-type-mime": "^0.4.3",
"jdenticon": "^3.3.0",

View File

@ -318,12 +318,15 @@ async function importRedist(data: object, platform: object | undefined) {
for (const [key, value] of Object.entries(data)) {
formData.append(
key,
typeof value === "object" ? JSON.stringify(value) : value,
value,
);
}
if (platform) {
for (const [key, value] of Object.entries(platform)) {
// Because we know there will be no file, and we need to handle more complex objects for
// the platform, we do this.
// Maybe we shouldn't.
formData.append(
`platform.${key}`,
typeof value === "object" ? JSON.stringify(value) : value,

View File

@ -71,41 +71,59 @@
aria-hidden="true"
/>
</div>
<div class="flex gap-x-4 text-zinc-300 font-bold uppercase font-display text-sm">
<span class="inline-flex items-center gap-x-1"
><div class="size-2 rounded-full bg-blue-600" />
Game</span
>
<span class="inline-flex items-center gap-x-1"
><div class="size-2 rounded-full bg-emerald-600" />
Redistributable</span
>
</div>
<ul
role="list"
class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4"
>
<li
v-for="game in filteredLibraryGames"
:key="game.id"
v-for="entry in filteredLibrary"
:key="entry.id"
class="relative overflow-hidden col-span-1 flex flex-col justify-center divide-y divide-zinc-800 rounded-xl bg-zinc-950/30 text-left shadow-md border hover:scale-102 hover:shadow-xl hover:bg-zinc-950/70 border-zinc-800 transition-all duration-200 group"
>
<div class="flex flex-1 flex-row p-4 gap-x-4">
<div
v-if="entry.type === 'game'"
class="relative flex flex-1 flex-row p-4 gap-x-4"
>
<div
class="absolute top-0 right-0 w-10 bg-blue-600 h-4 rotate-[45deg] translate-x-1/2"
/>
<img
class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
:src="useObject(game.mIconObjectId)"
:src="useObject(entry.mIconObjectId)"
alt=""
/>
<div class="flex flex-col">
<h3
class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display"
>
{{ game.mName }}
{{ entry.mName }}
<button
type="button"
:class="[
'rounded-full p-1 shadow-xs focus-visible:outline-2 focus-visible:outline-offset-2',
game.featured
entry.featured
? 'bg-yellow-400 hover:bg-yellow-300 focus-visible:outline-yellow-400 text-zinc-900'
: 'bg-zinc-800 hover:bg-zinc-700 focus-visible:outline-zinc-400 text-white',
]"
@click="() => featureGame(game.id)"
@click="() => featureGame(entry.id)"
>
<svg
v-if="gameFeatureLoading[game.id]"
v-if="gameFeatureLoading[entry.id]"
aria-hidden="true"
:class="[
game.featured ? ' fill-zinc-900' : 'fill-zinc-100',
entry.featured ? ' fill-zinc-900' : 'fill-zinc-100',
'size-3 text-transparent animate-spin',
]"
viewBox="0 0 100 101"
@ -126,13 +144,13 @@
</button>
<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.library!.name }}</span
>{{ entry.library.name }}</span
>
</h3>
<dl class="mt-1 flex flex-col justify-between">
<dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt>
<dd class="text-sm text-zinc-400">
{{ game.mShortDescription }}
{{ entry.mShortDescription }}
</dd>
<dt class="sr-only">
{{ $t("library.admin.metadataProvider") }}
@ -140,7 +158,7 @@
</dl>
<div class="mt-4 flex flex-col gap-y-1">
<NuxtLink
:href="`/admin/library/g/${game.id}`"
:href="`/admin/library/g/${entry.id}`"
class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
@ -155,16 +173,79 @@
</NuxtLink>
<button
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
@click="() => deleteGame(game.id)"
@click="() => deleteGame(entry.id)"
>
{{ $t("delete") }}
</button>
</div>
</div>
</div>
<div v-if="game.hasNotifications" class="flex flex-col gap-y-2 p-2">
<div
v-else-if="entry.type === 'redist'"
class="relative flex flex-1 flex-row p-4 gap-x-4"
>
<div
v-if="game.notifications.toImport"
class="absolute top-0 right-0 w-10 bg-emerald-600 h-4 rotate-[45deg] translate-x-1/2"
/>
<img
class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
:src="useObject(entry.mIconObjectId)"
alt=""
/>
<div class="flex flex-col">
<h3
class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display"
>
{{ entry.mName }}
<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"
>{{ entry.library.name }}</span
>
</h3>
<dl class="mt-1 flex flex-col justify-between">
<dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt>
<dd class="text-sm text-zinc-400">
{{ entry.mShortDescription }}
</dd>
</dl>
<dl
v-if="entry.platform"
class="mt-2 flex items-center text-zinc-200 font-semibold text-sm gap-x-1 p-2 bg-zinc-900 rounded-xl"
>
<IconsPlatform
:platform="entry.platform.id"
:fallback="entry.platform.iconSvg"
class="size-6 text-blue-600"
/>
<span>{{ entry.platform.platformName }}</span>
</dl>
<div class="mt-4 flex flex-col gap-y-1">
<NuxtLink
:href="`/admin/library/r/${entry.id}`"
class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
keypath="library.admin.openEditor"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
<button
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
@click="() => deleteRedist(entry.id)"
>
{{ $t("delete") }}
</button>
</div>
</div>
</div>
<div v-if="entry.hasNotifications" class="flex flex-col gap-y-2 p-2">
<div
v-if="entry.notifications.toImport"
class="rounded-md bg-blue-600/10 p-4"
>
<div class="flex">
@ -180,7 +261,7 @@
</p>
<p class="mt-3 text-sm md:ml-6 md:mt-0">
<NuxtLink
:href="`/admin/library/g/${game.id}/import`"
:href="`/admin/library/g/${entry.id}/import`"
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
>
<i18n-t
@ -198,7 +279,7 @@
</div>
</div>
<div
v-if="game.notifications.noVersions"
v-if="entry.notifications.noVersions"
class="rounded-md bg-yellow-600/10 p-4"
>
<div class="flex">
@ -216,7 +297,7 @@
</div>
</div>
<div
v-if="game.notifications.offline"
v-if="entry.notifications.offline"
class="rounded-md bg-red-600/10 p-4"
>
<div class="flex">
@ -236,14 +317,14 @@
</div>
</li>
<p
v-if="filteredLibraryGames.length == 0 && libraryGames.length != 0"
v-if="filteredLibrary.length == 0 && libraryGames.length != 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
{{ $t("common.noResults") }}
</p>
<p
v-if="
filteredLibraryGames.length == 0 &&
filteredLibrary.length == 0 &&
libraryGames.length == 0 &&
libraryState.hasLibraries
"
@ -305,29 +386,33 @@ useHead({
const searchQuery = ref("");
const libraryState = await $dropFetch("/api/v1/admin/library");
type LibraryStateGame = (typeof libraryState.games)[number]["game"];
const toImport = ref(
Object.values(libraryState.unimportedGames).flat().length > 0,
);
const libraryGames = ref<
Array<
LibraryStateGame & {
status: "online" | "offline";
hasNotifications?: boolean;
notifications: {
noVersions?: boolean;
toImport?: boolean;
offline?: boolean;
};
}
>
>(
libraryState.games.map((e) => {
// Potentially make a server-side transformation to make the client lighter
function clientSideTransformation<T, V extends keyof T, K extends string>(
values: Array<T & { status: (typeof libraryState.games)[number]["status"] }>,
expand: V,
type: K,
): Array<
T[V] & {
status: "online" | "offline";
type: K;
hasNotifications?: boolean;
notifications: {
noVersions?: boolean;
toImport?: boolean;
offline?: boolean;
};
}
> {
return values.map((e) => {
if (e.status == "offline") {
return {
...e.game,
...e[expand],
type: type,
status: "offline" as const,
hasNotifications: true,
notifications: {
@ -340,7 +425,8 @@ const libraryGames = ref<
const toImport = e.status.unimportedVersions.length > 0;
return {
...e.game,
...e[expand],
type: type,
notifications: {
noVersions,
toImport,
@ -348,13 +434,18 @@ const libraryGames = ref<
hasNotifications: noVersions || toImport,
status: "online" as const,
};
}),
});
}
const libraryGames = ref(
clientSideTransformation(libraryState.games, "value", "game"),
);
const libraryRedists = ref(
clientSideTransformation(libraryState.redists, "value", "redist"),
);
const filteredLibraryGames = computed(() =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore excessively deep ts
libraryGames.value.filter((e) => {
const filteredLibrary = computed(() =>
[...libraryGames.value, ...libraryRedists.value].filter((e) => {
if (!searchQuery.value) return true;
const searchQueryLower = searchQuery.value.toLowerCase();
if (e.mName.toLowerCase().includes(searchQueryLower)) return true;
@ -374,6 +465,16 @@ async function deleteGame(id: string) {
toImport.value = true;
}
async function deleteRedist(id: string) {
await $dropFetch(`/api/v1/admin/redist/${id}`, {
method: "DELETE",
failTitle: "Failed to delete game",
});
const index = libraryRedists.value.findIndex((e) => e.id === id);
libraryRedists.value.splice(index, 1);
toImport.value = true;
}
const gameFeatureLoading = ref<{ [key: string]: boolean }>({});
async function featureGame(id: string) {
const gameIndex = libraryGames.value.findIndex((e) => e.id === id);

View File

@ -0,0 +1,14 @@
/*
Warnings:
- Made the column `libraryId` on table `Game` required. This step will fail if there are existing NULL values in that column.
*/
-- DropIndex
DROP INDEX "public"."GameTag_name_idx";
-- AlterTable
ALTER TABLE "public"."Game" ALTER COLUMN "libraryId" SET NOT NULL;
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "public"."GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));

View File

@ -34,10 +34,8 @@ model Game {
versions Version[]
mods Mod[]
// These fields will not be optional in the next version
// Any game without a library ID will be assigned one at startup, based on the defaults
libraryId String?
library Library? @relation(fields: [libraryId], references: [id], onDelete: Cascade, onUpdate: Cascade)
libraryId String
library Library @relation(fields: [libraryId], references: [id], onDelete: Cascade, onUpdate: Cascade)
libraryPath String
collections CollectionEntry[]

View File

@ -5,7 +5,6 @@ import * as jdenticon from "jdenticon";
import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library";
import jsdom from "jsdom";
import DOMPurify from 'dompurify';
export const ImportRedist = type({
library: "string",
@ -28,7 +27,8 @@ export default defineEventHandler(async (h3) => {
const body = await handleFileUpload(h3, {}, ["internal:read"], 1);
if (!body) throw createError({ statusCode: 400, message: "Body required." });
const [[id], rawOptions, pull, , add] = body;
const [ids, rawOptions, pull, , add] = body;
const id = ids.at(0);
const options = ImportRedist(rawOptions);
if (options instanceof ArkErrors)
@ -60,7 +60,7 @@ export default defineEventHandler(async (h3) => {
});
svg.removeAttribute("width");
svg.removeAttribute("height");
svgContent = DOMPurify.sanitize(svg.outerHTML, {USE_PROFILES: {svg: true, svgFilters: true}});
svgContent = svg.outerHTML;
}
const redist = await prisma.redist.create({

View File

@ -7,9 +7,15 @@ export default defineEventHandler(async (h3) => {
const unimportedGames = await libraryManager.fetchUnimportedGames();
const games = await libraryManager.fetchGamesWithStatus();
const redists = await libraryManager.fetchRedistsWithStatus();
const libraries = await libraryManager.fetchLibraries();
// Fetch other library data here
return { unimportedGames, games, hasLibraries: libraries.length > 0 };
return {
unimportedGames,
games,
redists,
hasLibraries: libraries.length > 0,
};
});

View File

@ -0,0 +1,19 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["redist:delete"]);
if (!allowed) throw createError({ statusCode: 403 });
const id = getRouterParam(h3, "id")!;
const { count } = await prisma.redist.deleteMany({
where: {
id,
},
});
if (count == 0) throw createError({ statusCode: 404 });
return;
});

View File

@ -0,0 +1,16 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["redist:read"]);
if (!allowed) throw createError({ statusCode: 403 });
return await prisma.redist.findMany({
select: {
id: true,
mName: true,
mShortDescription: true,
mIconObjectId: true,
},
});
});

View File

@ -73,6 +73,10 @@ export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = {
"game:image:new": "Upload an image for a game.",
"game:image:delete": "Delete an image for a game.",
"redist:read": "Fetch redistributables on this instance.",
"redist:update": "Update redistributables on this instance.",
"redist:delete": "Delete redistributables on this instance.",
"company:read": "Fetch companies.",
"company:create": "Create a new company.",
"company:update": "Update existing companies.",

View File

@ -67,6 +67,10 @@ export const systemACLs = [
"game:image:new",
"game:image:delete",
"redist:read",
"redist:update",
"redist:delete",
"company:read",
"company:update",
"company:create",

View File

@ -128,29 +128,23 @@ class LibraryManager {
}
}
async fetchGamesWithStatus() {
const games = await prisma.game.findMany({
include: {
versions: {
select: {
versionName: true,
},
},
library: true,
},
orderBy: {
mName: "asc",
},
});
async fetchLibraryObjectWithStatus<T>(
objects: Array<
{
libraryId: string;
libraryPath: string;
versions: Array<unknown>;
} & T
>,
) {
return await Promise.all(
games.map(async (e) => {
objects.map(async (e) => {
const versions = await this.fetchUnimportedGameVersions(
e.libraryId ?? "",
e.libraryPath,
);
return {
game: e,
value: e,
status: versions
? {
noVersions: e.versions.length == 0,
@ -162,6 +156,55 @@ class LibraryManager {
);
}
async fetchGamesWithStatus() {
const games = await prisma.game.findMany({
include: {
versions: {
select: {
versionId: true,
versionName: true,
},
},
library: {
select: {
id: true,
name: true,
},
},
},
orderBy: {
mName: "asc",
},
});
return await this.fetchLibraryObjectWithStatus(games);
}
async fetchRedistsWithStatus() {
const redists = await prisma.redist.findMany({
include: {
versions: {
select: {
versionId: true,
versionName: true,
},
},
library: {
select: {
id: true,
name: true,
},
},
platform: true,
},
orderBy: {
mName: "asc",
},
});
return await this.fetchLibraryObjectWithStatus(redists);
}
/**
* Fetches recommendations and extra data about the version. Doesn't actually check if it's been imported.
* @param gameId

View File

@ -1,11 +1,9 @@
import { LibraryBackend } from "~/prisma/client/enums";
import type { LibraryBackend } from "~/prisma/client/enums";
import prisma from "../internal/db/database";
import type { JsonValue } from "@prisma/client/runtime/library";
import type { LibraryProvider } from "../internal/library/provider";
import type { FilesystemProviderConfig } from "../internal/library/providers/filesystem";
import { FilesystemProvider } from "../internal/library/providers/filesystem";
import libraryManager from "../internal/library";
import path from "path";
import { FlatFilesystemProvider } from "../internal/library/providers/flat";
import { logger } from "~/server/internal/logging";
@ -33,42 +31,6 @@ export default defineNitroPlugin(async () => {
let successes = 0;
const libraries = await prisma.library.findMany({});
// Add migration handler
const legacyPath = process.env.LIBRARY;
if (legacyPath && libraries.length == 0) {
const options: typeof FilesystemProviderConfig.infer = {
baseDir: path.resolve(legacyPath),
};
const library = await prisma.library.create({
data: {
name: "Auto-created",
backend: LibraryBackend.Filesystem,
options,
},
});
libraries.push(library);
// Update all existing games
await prisma.game.updateMany({
where: {
libraryId: null,
},
data: {
libraryId: library.id,
},
});
}
// Delete all games that don't have a library provider after the legacy handler
// (leftover from a bug)
await prisma.game.deleteMany({
where: {
libraryId: null,
},
});
for (const library of libraries) {
const constructor = libraryConstructors[library.backend];
try {

View File

@ -2497,11 +2497,6 @@
resolved "https://registry.yarnpkg.com/@types/triple-beam/-/triple-beam-1.3.5.tgz#74fef9ffbaa198eb8b588be029f38b00299caa2c"
integrity sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==
"@types/trusted-types@^2.0.7":
version "2.0.7"
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==
"@types/turndown@^5.0.5":
version "5.0.5"
resolved "https://registry.yarnpkg.com/@types/turndown/-/turndown-5.0.5.tgz#614de24fc9ace4d8c0d9483ba81dc8c1976dd26f"
@ -4272,13 +4267,6 @@ domhandler@^5.0.2, domhandler@^5.0.3:
dependencies:
domelementtype "^2.3.0"
dompurify@^3.2.6:
version "3.2.6"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.2.6.tgz#ca040a6ad2b88e2a92dc45f38c79f84a714a1cad"
integrity sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==
optionalDependencies:
"@types/trusted-types" "^2.0.7"
domutils@^3.0.1, domutils@^3.2.1, domutils@^3.2.2:
version "3.2.2"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78"