diff --git a/components/Import/Redist.vue b/components/Import/Redist.vue index d52b579..e84e088 100644 --- a/components/Import/Redist.vue +++ b/components/Import/Redist.vue @@ -129,7 +129,7 @@ {{ $t("library.admin.import.import") }} @@ -178,6 +178,8 @@ const platform = ref<{ name: string; icon: string; fileExts: string[] }>({ fileExts: [], }); +const buttonDisabled = computed(() => !(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; diff --git a/error.vue b/error.vue index 94c0cf1..ac86a7a 100644 --- a/error.vue +++ b/error.vue @@ -8,6 +8,8 @@ const props = defineProps({ }, }); +await updateUser(); + const { t } = useI18n(); const route = useRoute(); const user = useUser(); diff --git a/i18n/locales/en_us.json b/i18n/locales/en_us.json index 287ebad..d2178cf 100644 --- a/i18n/locales/en_us.json +++ b/i18n/locales/en_us.json @@ -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" diff --git a/package.json b/package.json index 5bd82ce..747778f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pages/admin/library/import.vue b/pages/admin/library/import.vue index 36ad682..08142ef 100644 --- a/pages/admin/library/import.vue +++ b/pages/admin/library/import.vue @@ -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, diff --git a/pages/admin/library/index.vue b/pages/admin/library/index.vue index ab011f5..763458f 100644 --- a/pages/admin/library/index.vue +++ b/pages/admin/library/index.vue @@ -71,41 +71,59 @@ aria-hidden="true" /> + +
+
+ Game +
+ Redistributable +
  • -
    +
    +
    +

    - {{ game.mName }} + {{ entry.mName }}

    {{ $t("library.admin.shortDesc") }}
    - {{ game.mShortDescription }} + {{ entry.mShortDescription }}
    {{ $t("library.admin.metadataProvider") }} @@ -140,7 +158,7 @@
    -
    +
    + +
    +

    + {{ entry.mName }} + {{ entry.library.name }} +

    +
    +
    {{ $t("library.admin.shortDesc") }}
    +
    + {{ entry.mShortDescription }} +
    +
    +
    + + {{ entry.platform.platformName }} +
    +
    + + + + + + +
    +
    +
    +
    +
    @@ -180,7 +261,7 @@

    @@ -216,7 +297,7 @@
    @@ -236,14 +317,14 @@
  • {{ $t("common.noResults") }}

    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( + values: Array, + 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); diff --git a/prisma/migrations/20250827022520_remove_optional_library_for_game/migration.sql b/prisma/migrations/20250827022520_remove_optional_library_for_game/migration.sql new file mode 100644 index 0000000..37c0bdd --- /dev/null +++ b/prisma/migrations/20250827022520_remove_optional_library_for_game/migration.sql @@ -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)); diff --git a/prisma/models/metadata.prisma b/prisma/models/metadata.prisma index 343928f..627aceb 100644 --- a/prisma/models/metadata.prisma +++ b/prisma/models/metadata.prisma @@ -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[] diff --git a/server/api/v1/admin/import/redist/index.post.ts b/server/api/v1/admin/import/redist/index.post.ts index d36c002..8454f27 100644 --- a/server/api/v1/admin/import/redist/index.post.ts +++ b/server/api/v1/admin/import/redist/index.post.ts @@ -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) @@ -48,7 +48,7 @@ export default defineEventHandler(async (h3) => { let svgContent = ""; if (options.platform) { - // This logic is duplicated on the client to make viewing there possible. + // 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); @@ -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({ diff --git a/server/api/v1/admin/library/index.get.ts b/server/api/v1/admin/library/index.get.ts index 5df358b..398188e 100644 --- a/server/api/v1/admin/library/index.get.ts +++ b/server/api/v1/admin/library/index.get.ts @@ -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, + }; }); diff --git a/server/api/v1/admin/redist/[id]/index.delete.ts b/server/api/v1/admin/redist/[id]/index.delete.ts new file mode 100644 index 0000000..7da5b11 --- /dev/null +++ b/server/api/v1/admin/redist/[id]/index.delete.ts @@ -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; +}); diff --git a/server/api/v1/admin/redist/index.get.ts b/server/api/v1/admin/redist/index.get.ts new file mode 100644 index 0000000..a62f622 --- /dev/null +++ b/server/api/v1/admin/redist/index.get.ts @@ -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, + }, + }); +}); diff --git a/server/internal/acls/descriptions.ts b/server/internal/acls/descriptions.ts index cf8d4a7..5fab2f0 100644 --- a/server/internal/acls/descriptions.ts +++ b/server/internal/acls/descriptions.ts @@ -73,6 +73,10 @@ export const systemACLDescriptions: ObjectFromList = { "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.", diff --git a/server/internal/acls/index.ts b/server/internal/acls/index.ts index 3942774..5bfba75 100644 --- a/server/internal/acls/index.ts +++ b/server/internal/acls/index.ts @@ -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", diff --git a/server/internal/library/index.ts b/server/internal/library/index.ts index 062ef41..dec3692 100644 --- a/server/internal/library/index.ts +++ b/server/internal/library/index.ts @@ -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( + objects: Array< + { + libraryId: string; + libraryPath: string; + versions: Array; + } & 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 diff --git a/server/plugins/05.library-init.ts b/server/plugins/05.library-init.ts index 9891b51..04a1349 100644 --- a/server/plugins/05.library-init.ts +++ b/server/plugins/05.library-init.ts @@ -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 { diff --git a/yarn.lock b/yarn.lock index ceebda0..7e2450b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"