Rearchitecture for v0.4.0 (#197)

* feat: database redist support

* feat: rearchitecture of database schemas, migration reset, and #180

* feat: import redists

* fix: giantbomb logging bug

* feat: partial user platform support + statusMessage -> message

* feat: add user platform filters to store view

* fix: sanitize svg uploads

... copilot suggested this

I feel dirty.

* feat: beginnings of platform & redist management

* feat: add server side redist patching

* fix: update drop-base commit

* feat: import of custom platforms & file extensions

* fix: redelete platform

* fix: remove platform

* feat: uninstall commands, new R UI

* checkpoint: before migrating to nuxt v4

* update to nuxt 4

* fix: fixes for Nuxt v4 update

* fix: remaining type issues

* feat: initial feedback to import other kinds of versions

* working commit

* fix: lint

* feat: redist import
This commit is contained in:
DecDuck
2025-11-10 10:36:13 +11:00
committed by GitHub
parent dfa30c8a65
commit 251ddb8ff8
465 changed files with 8029 additions and 7509 deletions

View File

@ -1,8 +1,8 @@
import aclManager from "~/server/internal/acls";
import libraryManager from "~/server/internal/library";
import aclManager from "~~/server/internal/acls";
import libraryManager from "~~/server/internal/library";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["import:game:read"]);
const allowed = await aclManager.allowSystemACL(h3, ["import:game:read", "import:redist:read"]);
if (!allowed) throw createError({ statusCode: 403 });
const unimportedGames = await libraryManager.fetchUnimportedGames();

View File

@ -1,8 +1,8 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls";
import libraryManager from "~/server/internal/library";
import metadataHandler from "~/server/internal/metadata";
import { readDropValidatedBody, throwingArktype } from "~~/server/arktype";
import aclManager from "~~/server/internal/acls";
import libraryManager from "~~/server/internal/library";
import metadataHandler from "~~/server/internal/metadata";
const ImportGameBody = type({
library: "string",
@ -27,14 +27,14 @@ export default defineEventHandler<{ body: typeof ImportGameBody.infer }>(
if (!path)
throw createError({
statusCode: 400,
statusMessage: "Path missing from body",
message: "Path missing from body",
});
const valid = await libraryManager.checkUnimportedGamePath(library, path);
if (!valid)
throw createError({
statusCode: 400,
statusMessage: "Invalid library or game.",
message: "Invalid library or game.",
});
const taskId = metadata
@ -44,7 +44,7 @@ export default defineEventHandler<{ body: typeof ImportGameBody.infer }>(
if (!taskId)
throw createError({
statusCode: 400,
statusMessage:
message:
"Duplicate metadata import. Please chose a different game or metadata provider.",
});

View File

@ -1,5 +1,5 @@
import aclManager from "~/server/internal/acls";
import metadataHandler from "~/server/internal/metadata";
import aclManager from "~~/server/internal/acls";
import metadataHandler from "~~/server/internal/metadata";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["import:game:read"]);
@ -8,14 +8,14 @@ export default defineEventHandler(async (h3) => {
const query = getQuery(h3);
const search = query.q?.toString();
if (!search)
throw createError({ statusCode: 400, statusMessage: "Invalid search" });
throw createError({ statusCode: 400, message: "Invalid search" });
const results = await metadataHandler.search(search);
if (results.length == 0)
throw createError({
statusCode: 404,
statusMessage: "No metadata provider returned search results.",
message: "No metadata provider returned search results.",
});
return results;

View File

@ -0,0 +1,3 @@
import handler from "../game/index.get";
export default handler;

View File

@ -0,0 +1,92 @@
import { ArkErrors, type } from "arktype";
import aclManager from "~~/server/internal/acls";
import { handleFileUpload } from "~~/server/internal/utils/handlefileupload";
import * as jdenticon from "jdenticon";
import prisma from "~~/server/internal/db/database";
import libraryManager from "~~/server/internal/library";
import jsdom from "jsdom";
export const ImportRedist = type({
library: "string",
path: "string",
name: "string",
description: "string",
"platform?": type({
name: "string",
icon: "string",
fileExts: type("string").pipe.try((s) => JSON.parse(s), type("string.alphanumeric").array()),
}),
});
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["import:redist:new"]);
if (!allowed) throw createError({ statusCode: 403 });
const body = await handleFileUpload(h3, {}, ["internal:read"], 1);
if (!body) throw createError({ statusCode: 400, message: "Body required." });
const [ids, rawOptions, pull, , add] = body;
const id = ids.at(0);
const options = ImportRedist(rawOptions);
if (options instanceof ArkErrors)
throw createError({ statusCode: 400, message: options.summary });
const valid = await libraryManager.checkUnimportedGamePath(
options.library,
options.path,
);
if (!valid)
throw createError({
statusCode: 400,
message: "Invalid library or game.",
});
const icon = id ?? add(jdenticon.toPng(options.name, 512));
let svgContent = "";
if (options.platform) {
// This logic is duplicated on the client to make viewing there possible.
// TODO?: refactor into a single function. Not totally sure if this is a good idea though,
// because they do different things
const dom = new jsdom.JSDOM(options.platform.icon);
const svg = dom.window.document.getElementsByTagName("svg").item(0);
if (!svg)
throw createError({
statusCode: 400,
statusMessage: "No SVG in uploaded image.",
});
svg.removeAttribute("width");
svg.removeAttribute("height");
svgContent = svg.outerHTML;
}
const redist = await prisma.redist.create({
data: {
libraryId: options.library,
libraryPath: options.path,
mName: options.name,
mShortDescription: options.description,
mIconObjectId: icon,
platform: {
...(options.platform
? {
create: {
platformName: options.platform.name,
iconSvg: svgContent,
fileExtensions: options.platform.fileExts.map((v) => `.${v}`),
},
}
: undefined),
},
},
});
await pull();
return redist;
});

View File

@ -1,32 +1,36 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library";
import { ArkErrors, type } from "arktype";
import aclManager from "~~/server/internal/acls";
import prisma from "~~/server/internal/db/database";
import libraryManager, { VersionImportModes } from "~~/server/internal/library";
export const PreloadQuery = type({
id: "string",
mode: type.enumerated(...VersionImportModes),
});
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["import:version:read"]);
if (!allowed) 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 rawQuery = await getQuery(h3);
const query = PreloadQuery(rawQuery);
if (query instanceof ArkErrors)
throw createError({ statusCode: 400, message: query.summary });
const game = await prisma.game.findUnique({
where: { id: gameId },
select: { libraryId: true, libraryPath: true },
});
if (!game || !game.libraryId)
throw createError({ statusCode: 404, statusMessage: "Game not found" });
const value: { libraryId: string; libraryPath: string } | undefined =
await // eslint-disable-next-line @typescript-eslint/no-explicit-any
(prisma[query.mode] as any).findUnique({
where: { id: query.id },
select: { libraryId: true, libraryPath: true },
});
if (!value) throw createError({ statusCode: 404, message: "Not found" });
const unimportedVersions = await libraryManager.fetchUnimportedGameVersions(
game.libraryId,
game.libraryPath,
value.libraryId,
value.libraryPath,
);
if (!unimportedVersions)
throw createError({ statusCode: 400, statusMessage: "Invalid game ID" });
throw createError({ statusCode: 400, message: "Invalid game ID" });
return unimportedVersions;
});

View File

@ -1,88 +1,71 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library";
import { parsePlatform } from "~/server/internal/utils/parseplatform";
import { readDropValidatedBody, throwingArktype } from "~~/server/arktype";
import aclManager from "~~/server/internal/acls";
import libraryManager from "~~/server/internal/library";
const ImportVersion = type({
export const LaunchCommands = type({
name: "string > 0",
description: "string = ''",
launchCommand: "string > 0",
launchArgs: "string = ''",
}).array();
const ImportVersionBase = type({
id: "string",
version: "string",
name: "string?",
platform: "string",
launch: "string = ''",
launchArgs: "string = ''",
setup: "string = ''",
setupArgs: "string = ''",
onlySetup: "boolean = false",
delta: "boolean = false",
});
const ImportGameVersion = type({
mode: "'game'",
onlySetup: "boolean = false",
umuId: "string = ''",
}).configure(throwingArktype);
install: "string?",
installArgs: "string?",
launches: LaunchCommands,
uninstall: "string?",
uninstallArgs: "string?",
});
const ImportRedistVersion = type({
mode: "'redist'",
install: "string?",
installArgs: "string?",
launches: LaunchCommands,
uninstall: "string?",
uninstallArgs: "string?",
});
export const ImportVersion = ImportVersionBase.and(
ImportGameVersion.or(ImportRedistVersion),
).configure(throwingArktype);
export type ImportGameVersion = typeof ImportVersionBase.infer &
typeof ImportGameVersion.infer;
export type ImportRedistVersion = typeof ImportVersionBase.infer &
typeof ImportRedistVersion.infer;
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["import:version:new"]);
if (!allowed) throw createError({ statusCode: 403 });
const {
id,
version,
platform,
launch,
launchArgs,
setup,
setupArgs,
onlySetup,
delta,
umuId,
} = await readDropValidatedBody(h3, ImportVersion);
const platformParsed = parsePlatform(platform);
if (!platformParsed)
throw createError({ statusCode: 400, statusMessage: "Invalid platform." });
if (delta) {
const validOverlayVersions = await prisma.gameVersion.count({
where: { gameId: id, platform: platformParsed, delta: false },
});
if (validOverlayVersions == 0)
throw createError({
statusCode: 400,
statusMessage:
"Update mode requires a pre-existing version for this platform.",
});
}
if (onlySetup) {
if (!setup)
throw createError({
statusCode: 400,
statusMessage: 'Setup required in "setup mode".',
});
} else {
if (!delta && !launch)
throw createError({
statusCode: 400,
statusMessage: "Launch executable is required for non-update versions",
});
}
const body = await readDropValidatedBody(h3, ImportVersion);
// startup & delta require more complex checking logic
const taskId = await libraryManager.importVersion(id, version, {
platform,
onlySetup,
launch,
launchArgs,
setup,
setupArgs,
umuId,
delta,
});
const taskId = await libraryManager.importVersion(
body.id,
body.version,
body,
);
if (!taskId)
throw createError({
statusCode: 400,
statusMessage: "Invalid options for import",
message: "Invalid options for import",
});
return { taskId: taskId };

View File

@ -1,27 +1,31 @@
import aclManager from "~/server/internal/acls";
import libraryManager from "~/server/internal/library";
import { ArkErrors, type } from "arktype";
import aclManager from "~~/server/internal/acls";
import libraryManager, { VersionImportModes } from "~~/server/internal/library";
export const PreloadQuery = type({
id: "string",
version: "string",
mode: type.enumerated(...VersionImportModes),
});
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["import:version:read"]);
if (!allowed) 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 rawQuery = await getQuery(h3);
const query = PreloadQuery(rawQuery);
if (query instanceof ArkErrors)
throw createError({ statusCode: 400, message: query.summary });
const preload = await libraryManager.fetchUnimportedVersionInformation(
gameId,
versionName,
query.id,
query.mode,
query.version,
);
if (!preload)
throw createError({
statusCode: 400,
statusMessage: "Invalid game or version id/name",
message: "Invalid game or version id/name",
});
return preload;