7 Commits

Author SHA1 Message Date
a287138650 feat: image provider + importer partial backend 2025-08-01 18:33:06 +10:00
545a6b154a Fix #119 (#153) 2025-08-01 16:26:27 +10:00
442f940cc4 Translated using Weblate (English) (#151)
Currently translated at 100.0% (456 of 456 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en/
2025-08-01 14:31:50 +10:00
7d9525084d feat: bump version (#150) 2025-08-01 14:02:44 +10:00
72c972a2a7 Fix for undeleted games from library sources (#148)
* fix: casade delete for games and library sources

* fix: add bug workaround

* fix: lint
2025-08-01 14:00:10 +10:00
b72e1ef7a4 Code-based authorization for Drop clients (#145)
* feat: code-based authorization

* fix: final touches

* fix: require session on code fetch endpoint

* feat: better error handling

* refactor: move auth send to client handler

* fix: lint
2025-08-01 13:11:56 +10:00
786ad0ff82 Translations update from Weblate (#107)
* Translated using Weblate (English (Australia))

Currently translated at 1.6% (6 of 375 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en_AU/

* Translated using Weblate (English (en_PIRATE))

Currently translated at 99.4% (373 of 375 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en_PIRATE/

* Translated using Weblate (English (en_PIRATE))

Currently translated at 99.4% (373 of 375 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en_PIRATE/

* Translated using Weblate (English (en_PIRATE))

Currently translated at 100.0% (375 of 375 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en_PIRATE/

---------

Co-authored-by: Weblate Translation Memory <noreply-mt-weblate-translation-memory@weblate.org>
2025-08-01 10:38:53 +10:00
41 changed files with 840 additions and 204 deletions

View File

@ -2,3 +2,12 @@
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
@config "../tailwind.config.js";
@layer base {
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button,
input[type="number"] {
-webkit-appearance: none;
-moz-appearance: textfield !important;
}
}

View File

@ -16,7 +16,7 @@
</template>
<script setup lang="ts">
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
import type { GameMetadataSearchResult } from "~/server/internal/metadata/content/types";
const { game } = defineProps<{
game: Omit<GameMetadataSearchResult, "year"> & { sourceName?: string };

View File

@ -171,7 +171,7 @@ import {
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
import type { GameMetadataSearchResult } from "~/server/internal/metadata/content/types";
import { FetchError } from "ofetch";
import type { SerializeObject } from "nitropack";
import { XCircleIcon } from "@heroicons/vue/24/solid";

View File

@ -100,6 +100,11 @@ const navigation = computed<NavigationItem[]>(() =>
route: "/account",
prefix: "",
},
{
label: "Authorize client",
route: "/client/code",
prefix: "",
},
].filter((e) => e !== undefined),
);
</script>

View File

@ -1,5 +1,23 @@
{
"setup": {
"welcome": "G'day."
},
"account": {
"devices": {
"subheader": "Manage the devices authorised to access your Drop account."
}
},
"auth": {
"callback": {
"authClient": "Authorise client?",
"authorize": "Authorise",
"authorizedClient": "Drop has successfully authorised the client. You may now close this window."
}
},
"library": {
"collection": {
"subheader": "Add a new collection to organise your games"
},
"subheader": "Organise your games into collections for easy access, and access all your games."
}
}

View File

@ -109,6 +109,25 @@
"listItemPlaceholder": "list item, eh?"
},
"errors": {
"admin": {
"user": {
"delete": {
"desc": "Couldn't make {0} walk the plank!",
"title": "Couldn't make 'em walk the plank"
}
}
},
"auth": {
"disabled": "Ya' angered the Cap'n, give him a holler!",
"invalidInvite": "Boarding pass no longer valid, ya scallywag!",
"invalidPassState": "Cap'n's rolled snake eyes, see how he's doin'.",
"invalidUserOrPass": "Are ya lying to me about your username and password, ya dog?",
"inviteIdRequired": "Ya need to include the ID for your boarding pass, matey!",
"method": {
"signinDisabled": "No entrance through these parts, arr!"
},
"usernameTaken": "We already have a scallywag with that 'ere name of yours."
},
"backHome": "{arrow} Back to yer safe harbor",
"game": {
"banner": {
@ -203,7 +222,8 @@
"discord": "Discord, argh!",
"github": "GitHub, savvy?"
},
"topSellers": "Top Plunderers"
"topSellers": "Top Plunderers",
"version": "Drop {version} {gitRef}"
},
"header": {
"admin": {
@ -401,7 +421,8 @@
"cleanupSessionsDescription": "Cleans up expired sessions to save space and keep ye safe, ye dog!",
"cleanupSessionsName": "Clean up sessions."
},
"viewTask": "View {arrow}"
"viewTask": "View {arrow}",
"weeklyScheduledTitle": "Weekly chores"
}
},
"title": "Drop",
@ -439,6 +460,8 @@
"title": "Passage"
},
"authoptionsHeader": "Passage Options",
"delete": "Scuttle!",
"deleteUser": "Make {0} walk the plank!",
"description": "Manage the crew on yer Drop vessel, and set yer passage methods, savvy?",
"displayNameHeader": "Scallywag Name",
"emailHeader": "Salty Mail",

View File

@ -38,6 +38,10 @@
"requestedAccess": "\"{name}\" has requested access to your Drop account.",
"success": "Successful!"
},
"code": {
"title": "Connect your Drop client",
"description": "Use a code to connect your Drop client if you are unable to open a web browser on your device."
},
"confirmPassword": "Confirm @:auth.password",
"displayName": "Display Name",
"email": "Email",
@ -63,38 +67,6 @@
"signout": "Signout",
"username": "Username"
},
"setup": {
"welcome": "Hey there.",
"welcomeDescription": "Welcome to Drop setup wizard. It will walk you through configuring Drop for the first time, and how it works.",
"finish": "Let's go {arrow}",
"noPage": "no page",
"auth": {
"title": "Authentication",
"description": "Authentication in Drop happens through multiple configured 'providers'. Each one can allow users to sign-in through their method. To get started, have at least one authentication provider enabled, and create an account through it.",
"docs": "Documentation {arrow}",
"enabled": "Enabled?",
"simple": {
"title": "Simple authentication",
"description": "Simple authentication uses username/password to authentication users. It is enabled by default if no other authentication provider is enabled.",
"register": "Register as admin {arrow}"
},
"openid": {
"title": "OpenID Connect",
"description": "OpenID Connect (OIDC) is an OAuth2 extension commonly supported. Drop requires OIDC configuration to be done via environment variables.",
"skip": "I have a user with OIDC"
}
},
"stages": {
"account": {
"name": "Setup your admin account.",
"description": "You need at least one account to start using Drop."
},
"library": {
"name": "Create a library.",
"description": "Add at least one library source to use Drop."
}
}
},
"cancel": "Cancel",
"chars": {
"arrow": "→",
@ -103,6 +75,7 @@
"srComma": ", {0}"
},
"common": {
"add": "Add",
"cannotUndo": "This action cannot be undone.",
"close": "Close",
"create": "Create",
@ -122,8 +95,7 @@
"servers": "Servers",
"srLoading": "Loading...",
"tags": "Tags",
"today": "Today",
"add": "Add"
"today": "Today"
},
"delete": "Delete",
"drop": {
@ -280,9 +252,7 @@
"addToLib": "Add to Library",
"admin": {
"detectedGame": "Drop has detected you have new games to import.",
"detectedVersion": "Drop has detected you have new verions of this game to import.",
"offlineTitle": "Game offline",
"offline": "Drop couldn't access this game.",
"detectedVersion": "Drop has detected you have new versions of this game to import.",
"game": {
"addCarouselNoImages": "No images to add.",
"addDescriptionNoImages": "No images to add.",
@ -303,6 +273,8 @@
},
"gameLibrary": "Game Library",
"import": {
"bulkImportDescription": "When on, this page won't redirect you to the import task, so you can import multiple games in succession.",
"bulkImportTitle": "Bulk import mode",
"import": "Import",
"link": "Import {arrow}",
"loading": "Loading game results...",
@ -313,8 +285,6 @@
"selectGamePlaceholder": "Please select a game...",
"selectGameSearch": "Select game",
"selectPlatform": "Please select a platform...",
"bulkImportTitle": "Bulk import mode",
"bulkImportDescription": "When on, this page won't redirect you to the import task, so you can import multiple games in succession.",
"version": {
"advancedOptions": "Advanced options",
"import": "Import version",
@ -340,16 +310,64 @@
},
"withoutMetadata": "Import without metadata"
},
"metadata": {
"companies": {
"action": "Manage {arrow}",
"addGame": {
"description": "Pick a game to add to the company, and whether it should be listed as a developer, publisher, or both.",
"developer": "Developer?",
"noGames": "No games to add",
"publisher": "Publisher?",
"title": "Connect game to this company"
},
"description": "Companies organize games by who they were developed or published by.",
"editor": {
"action": "Add Game {plus}",
"developed": "Developed",
"libraryDescription": "Add, remove, or customise what this company has developed and/or published.",
"libraryTitle": "Game Library",
"noDescription": "(no description)",
"published": "Published",
"uploadBanner": "Upload banner",
"uploadIcon": "Upload icon"
},
"modals": {
"nameDescription": "Edit the company's name. Used to match to new game imports.",
"nameTitle": "Edit company name",
"shortDeckDescription": "Edit the company's description. Doesn't affect long (markdown) description.",
"shortDeckTitle": "Edit company description",
"websiteDescription": "Edit the company's website. Note: this will be a link, and won't have redirect protection.",
"websiteTitle": "Edit company website"
},
"noCompanies": "No companies",
"noGames": "No games",
"search": "Search companies...",
"searchGames": "Search company games...",
"title": "Companies"
},
"tags": {
"action": "Manage {arrow}",
"create": "Create",
"description": "Tags are automatically created from imported genres. You can add custom tags to add categorisation to your game library.",
"modal": {
"description": "Create a tag to organize your library.",
"title": "Create Tag"
},
"title": "Tags"
}
},
"metadataProvider": "Metadata provider",
"noGames": "No games imported",
"offline": "Drop couldn't access this game.",
"offlineTitle": "Game offline",
"openEditor": "Open in Editor {arrow}",
"openStore": "Open in Store",
"shortDesc": "Short Description",
"sources": {
"create": "Create source",
"edit": "Edit source",
"createDesc": "Drop will use this source to access your game library, and make them available.",
"desc": "Configure your library sources, where Drop will look for new games and versions to import.",
"edit": "Edit source",
"fsDesc": "Imports games from a path on disk. Requires version-based folder structure, and supports archived games.",
"fsFlatDesc": "Imports games from a path on disk, but without a separate version subfolder. Useful when migrating an existing library to Drop.",
"fsPath": "Path",
@ -369,53 +387,7 @@
"noVersions": "You have no versions of this game available.",
"noVersionsAdded": "no versions added"
},
"versionPriority": "Version priority",
"metadata": {
"tags": {
"title": "Tags",
"description": "Tags are automatically created from imported genres. You can add custom tags to add categorisation to your game library.",
"action": "Manage {arrow}",
"create": "Create",
"modal": {
"title": "Create Tag",
"description": "Create a tag to organize your library."
}
},
"companies": {
"title": "Companies",
"description": "Companies organize games by who they were developed or published by.",
"action": "Manage {arrow}",
"search": "Search companies...",
"searchGames": "Search company games...",
"noCompanies": "No companies",
"noGames": "No games",
"editor": {
"libraryTitle": "Game Library",
"libraryDescription": "Add, remove, or customise what this company has developed and/or published.",
"action": "Add Game {plus}",
"published": "Published",
"developed": "Developed",
"uploadIcon": "Upload icon",
"uploadBanner": "Upload banner",
"noDescription": "(no description)"
},
"addGame": {
"title": "Connect game to this company",
"description": "Pick a game to add to the company, and whether it should be listed as a developer, publisher, or both.",
"publisher": "Publisher?",
"developer": "Developer?",
"noGames": "No games to add"
},
"modals": {
"nameTitle": "Edit company name",
"nameDescription": "Edit the company's name. Used to match to new game imports.",
"shortDeckTitle": "Edit company description",
"shortDeckDescription": "Edit the company's description. Doesn't affect long (markdown) description.",
"websiteTitle": "Edit company website",
"websiteDescription": "Edit the company's website. Note: this will be a link, and won't have redirect protection."
}
}
}
"versionPriority": "Version priority"
},
"back": "Back to Library",
"collection": {
@ -486,6 +458,38 @@
"title": "Settings"
}
},
"setup": {
"auth": {
"description": "Authentication in Drop happens through multiple configured 'providers'. Each one can allow users to sign-in through their method. To get started, have at least one authentication provider enabled, and create an account through it.",
"docs": "Documentation {arrow}",
"enabled": "Enabled?",
"openid": {
"description": "OpenID Connect (OIDC) is an OAuth2 extension commonly supported. Drop requires OIDC configuration to be done via environment variables.",
"skip": "I have a user with OIDC",
"title": "OpenID Connect"
},
"simple": {
"description": "Simple authentication uses username/password to authentication users. It is enabled by default if no other authentication provider is enabled.",
"register": "Register as admin {arrow}",
"title": "Simple authentication"
},
"title": "Authentication"
},
"finish": "Let's go {arrow}",
"noPage": "no page",
"stages": {
"account": {
"description": "You need at least one account to start using Drop.",
"name": "Setup your admin account."
},
"library": {
"description": "Add at least one library source to use Drop.",
"name": "Create a library."
}
},
"welcome": "Hey there.",
"welcomeDescription": "Welcome to Drop setup wizard. It will walk you through configuring Drop for the first time, and how it works."
},
"store": {
"about": "About",
"commingSoon": "coming soon",
@ -564,6 +568,7 @@
"admin": {
"adminHeader": "Admin?",
"adminUserLabel": "Admin user",
"authLink": "Authentication {arrow}",
"authentication": {
"configure": "Configure",
"description": "Drop supports a variety of \"authentication mechanisms\". As you enable or disable them, they are shown on the sign in screen for users to select from. Click the dot menu to configure the authentication mechanism.",
@ -575,7 +580,6 @@
"srOpenOptions": "Open options",
"title": "Authentication"
},
"authLink": "Authentication {arrow}",
"authoptionsHeader": "Auth Options",
"delete": "Delete",
"deleteUser": "Delete user {0}",

View File

@ -1,9 +1,12 @@
{
"name": "drop",
"version": "0.3.0",
"version": "0.3.1",
"private": true,
"type": "module",
"license": "AGPL-3.0-or-later",
"engines": {
"node": ">=22.16.0"
},
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",

View File

@ -309,7 +309,7 @@ import {
import { XCircleIcon } from "@heroicons/vue/16/solid";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
import type { GameMetadataSearchResult } from "~/server/internal/metadata/content/types";
definePageMeta({
layout: "admin",

View File

@ -1,7 +1,7 @@
<template>
<div
v-if="completed"
class="min-h-full w-full flex items-center justify-center"
class="min-h-full w-full flex items-center justify-center py-10"
>
<div class="flex flex-col items-center">
<CheckCircleIcon class="h-12 w-12 text-green-600" aria-hidden="true" />
@ -14,7 +14,7 @@
{{ $t("auth.callback.authorizedClient") }}
</p>
<Disclosure v-slot="{ open }" as="div" class="mt-8">
<Disclosure v-if="authToken" v-slot="{ open }" as="div" class="mt-8">
<dt>
<DisclosureButton
class="pb-2 flex w-full items-start justify-between text-left text-zinc-400"
@ -155,38 +155,58 @@ import {
XCircleIcon,
} from "@heroicons/vue/16/solid";
import { LockClosedIcon } from "@heroicons/vue/20/solid";
import { CheckCircleIcon } from "@heroicons/vue/24/outline";
import { CheckCircleIcon, CloudIcon } from "@heroicons/vue/24/outline";
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/vue/24/solid";
import type { FetchError } from "ofetch";
const route = useRoute();
const clientId = route.params.id;
const clientData = await $dropFetch(
`/api/v1/client/auth/callback?id=${clientId}`,
);
const clientData = await $dropFetch(`/api/v1/client/auth?id=${clientId}`);
const completed = ref(false);
const error = ref();
const authToken = ref<string | undefined>();
async function authorize() {
const { redirect, token } = await $dropFetch("/api/v1/client/auth/callback", {
method: "POST",
body: { id: clientId },
switch (clientData.mode) {
case "callback": {
const { redirect, token } = await $dropFetch(
`/api/v1/client/auth/callback`,
{
method: "POST",
body: { id: clientId },
},
);
authToken.value = token;
window.location.replace(redirect);
return;
}
case "code": {
await $dropFetch("/api/v1/client/auth/code", {
method: "POST",
body: { id: clientId },
});
return;
}
}
throw createError({
statusCode: 500,
statusMessage: "Unknown client auth mode: " + clientData.mode,
fatal: true,
});
authToken.value = token;
window.location.replace(redirect);
}
function authorize_wrapper() {
authorize()
.catch((e) => {
const errorMessage = e.statusMessage || "An unknown error occurred.";
error.value = errorMessage;
})
.then(() => {
completed.value = true;
});
async function authorize_wrapper() {
try {
await authorize();
} catch (e) {
const errorMessage =
(e as FetchError)?.statusMessage || "An unknown error occurred.";
error.value = errorMessage;
} finally {
completed.value = true;
}
}
const scopes = [
@ -198,20 +218,27 @@ const scopes = [
icon: ArrowDownTrayIcon,
},
{
name: "Update your status",
description:
"The client will be able to update your status, and affect your playtime.",
href: "/docs/access/status",
icon: UserGroupIcon,
},
clientData.capabilities["peerAPI"] && {
name: "Access the Drop network",
description:
"The client will be able to establish P2P connections with other users to enable features like download aggregation, Remote LAN play and P2P multiplayer.",
href: "/docs/access/network",
icon: LockClosedIcon,
},
{
name: "Manage your account",
clientData.capabilities["cloudSaves"] && {
name: "Upload and sync cloud saves",
description:
"The client will be able to change your account details, and friend statuses on your behalf.",
href: "/docs/access/account",
icon: UserGroupIcon,
"The client will be able to upload new cloud saves, and edit your existing ones.",
href: "/docs/access/cloud",
icon: CloudIcon,
},
];
].filter((e) => e !== undefined);
useHead({
title: "Authorize",

135
pages/client/code/index.vue Normal file
View File

@ -0,0 +1,135 @@
<template>
<div class="w-full h-max min-h-[30vw] flex items-center justify-center">
<div class="text-center">
<h1 class="text-2xl font-bold text-zinc-100">
{{ $t("auth.code.title") }}
</h1>
<p class="mt-1 max-w-sm text-zinc-400 mx-auto">
{{ $t("auth.code.description") }}
</p>
<div v-if="!loading" class="mt-8 flex flex-row gap-4">
<input
v-for="i in codeLength"
ref="codeElements"
:key="i"
v-model="code[i - 1]"
class="uppercase w-16 h-16 appearance-none text-center bg-zinc-900 rounded-xl border-zinc-700 focus:border-blue-600 text-2xl text-bold font-display text-zinc-100"
type="text"
pattern="\d*"
:placeholder="placeholder[i - 1]"
@keydown="(v) => keydown(i - 1, v)"
@input="() => input(i - 1)"
@focusin="() => select(i - 1)"
@paste="(v) => paste(i - 1, v)"
/>
</div>
<div v-else class="mt-8 flex items-center justify-center">
<svg
aria-hidden="true"
class="size-12 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>
</div>
<div
v-if="error"
class="mt-8 rounded-md bg-red-600/10 p-4 max-w-sm mx-auto"
>
<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">
{{ error }}
</h3>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { XCircleIcon } from "@heroicons/vue/24/solid";
import { FetchError } from "ofetch";
const codeLength = 7;
const placeholder = "1A2B3C4";
const codeElements = useTemplateRef("codeElements");
const code = ref<string[]>([]);
const router = useRouter();
const loading = ref(false);
const error = ref<string | undefined>(undefined);
function keydown(index: number, event: KeyboardEvent) {
if (event.key === "Backspace" && !code.value[index] && index > 0) {
codeElements.value![index - 1].focus();
}
}
function input(index: number) {
if (codeElements.value === null) return;
const v = code.value[index] ?? "";
if (v.length > 1) code.value[index] = v[0];
if (!(index + 1 >= codeElements.value.length) && v) {
codeElements.value[index + 1].focus();
}
if (!(index - 1 < 0) && !v) {
codeElements.value[index - 1].focus();
}
console.log(index, codeLength - 1);
if (index == codeLength - 1) {
const assembledCode = code.value.join("");
if (assembledCode.length == codeLength) {
complete(assembledCode);
}
}
}
function select(index: number) {
if (!codeElements.value) return;
if (index >= codeElements.value.length) return;
codeElements.value[index].select();
}
function paste(index: number, event: ClipboardEvent) {
const newCode = event.clipboardData!.getData("text/plain");
for (let i = 0; i < newCode.length && i < codeLength; i++) {
code.value[i] = newCode[i];
codeElements.value![i].focus();
}
event.preventDefault();
}
async function complete(code: string) {
loading.value = true;
try {
const clientId = await $dropFetch(`/api/v1/client/auth/code?code=${code}`);
router.push(`/client/authorize/${clientId}`);
} catch (e) {
if (e instanceof FetchError) {
error.value =
e.statusMessage ?? e.message ?? "An unknown error occurred.";
} else {
error.value = (e as string)?.toString();
}
}
loading.value = false;
}
</script>

View File

@ -0,0 +1,11 @@
-- DropForeignKey
ALTER TABLE "Game" DROP CONSTRAINT "Game_libraryId_fkey";
-- DropIndex
DROP INDEX "GameTag_name_idx";
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));
-- AddForeignKey
ALTER TABLE "Game" ADD CONSTRAINT "Game_libraryId_fkey" FOREIGN KEY ("libraryId") REFERENCES "Library"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,8 @@
-- AlterEnum
ALTER TYPE "MetadataSource" ADD VALUE 'SteamGridDB';
-- DropIndex
DROP INDEX "GameTag_name_idx";
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));

View File

@ -5,6 +5,7 @@ enum MetadataSource {
IGDB
Metacritic
OpenCritic
SteamGridDB
}
model Game {
@ -36,7 +37,7 @@ model Game {
// 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])
library Library? @relation(fields: [libraryId], references: [id], onDelete: Cascade, onUpdate: Cascade)
libraryPath String
collections CollectionEntry[]

View File

@ -0,0 +1,11 @@
import aclManager from "~/server/internal/acls";
import imageHandler from "~/server/internal/metadata/image";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["game:image:import"]);
if (!allowed) throw createError({ statusCode: 403 });
const images = await imageHandler.searchImages("space engineers");
return images;
});

View File

@ -2,7 +2,7 @@ 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 metadataHandler from "~/server/internal/metadata/content";
const ImportGameBody = type({
library: "string",

View File

@ -1,5 +1,5 @@
import aclManager from "~/server/internal/acls";
import metadataHandler from "~/server/internal/metadata";
import metadataHandler from "~/server/internal/metadata/content";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["import:game:read"]);

View File

@ -8,13 +8,19 @@ export default defineEventHandler(async (h3) => {
const body = await readBody(h3);
const clientId = await body.id;
const data = await clientHandler.fetchClientMetadata(clientId);
if (!data)
const client = await clientHandler.fetchClient(clientId);
if (!client)
throw createError({
statusCode: 400,
statusMessage: "Invalid or expired client ID.",
});
if (client.userId != user.userId)
throw createError({
statusCode: 403,
statusMessage: "Not allowed to authorize this client.",
});
const token = await clientHandler.generateAuthToken(clientId);
return {

View File

@ -0,0 +1,21 @@
import clientHandler from "~/server/internal/clients/handler";
import sessionHandler from "~/server/internal/session";
export default defineEventHandler(async (h3) => {
const user = await sessionHandler.getSession(h3);
if (!user) throw createError({ statusCode: 403 });
const query = getQuery(h3);
const code = query.code?.toString()?.toUpperCase();
if (!code)
throw createError({
statusCode: 400,
statusMessage: "Code required in query params.",
});
const clientId = await clientHandler.fetchClientIdByCode(code);
if (!clientId)
throw createError({ statusCode: 400, statusMessage: "Invalid code." });
return clientId;
});

View File

@ -0,0 +1,35 @@
import clientHandler from "~/server/internal/clients/handler";
import sessionHandler from "~/server/internal/session";
export default defineEventHandler(async (h3) => {
const user = await sessionHandler.getSession(h3);
if (!user) throw createError({ statusCode: 403 });
const body = await readBody(h3);
const clientId = await body.id;
const client = await clientHandler.fetchClient(clientId);
if (!client)
throw createError({
statusCode: 400,
statusMessage: "Invalid or expired client ID.",
});
if (client.userId != user.userId)
throw createError({
statusCode: 403,
statusMessage: "Not allowed to authorize this client.",
});
if (!client.peer)
throw createError({
statusCode: 500,
statusMessage: "No client listening for authorization.",
});
const token = await clientHandler.generateAuthToken(clientId);
await clientHandler.sendAuthToken(clientId, token);
return;
});

View File

@ -0,0 +1,25 @@
import type { FetchError } from "ofetch";
import clientHandler from "~/server/internal/clients/handler";
export default defineWebSocketHandler({
async open(peer) {
try {
const h3 = { headers: peer.request?.headers ?? new Headers() };
const code = h3.headers.get("Authorization");
if (!code)
throw createError({
statusCode: 400,
statusMessage: "Code required in Authorization header.",
});
await clientHandler.connectCodeListener(code, peer);
} catch (e) {
peer.send(
JSON.stringify({
type: "error",
value: (e as FetchError)?.statusMessage,
}),
);
peer.close();
}
},
});

View File

@ -13,14 +13,20 @@ export default defineEventHandler(async (h3) => {
statusMessage: "Provide client ID in request params as 'id'",
});
const data = await clientHandler.fetchClientMetadata(providedClientId);
if (!data)
const client = await clientHandler.fetchClient(providedClientId);
if (!client)
throw createError({
statusCode: 404,
statusMessage: "Request not found.",
});
if (client.userId && user.userId !== client.userId)
throw createError({
statusCode: 400,
statusMessage: "Client already claimed.",
});
await clientHandler.attachUserId(providedClientId, user.userId);
return data;
return client.data;
});

View File

@ -1,3 +1,5 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import type {
CapabilityConfiguration,
InternalClientCapability,
@ -5,23 +7,23 @@ import type {
import capabilityManager, {
validCapabilities,
} from "~/server/internal/clients/capabilities";
import clientHandler from "~/server/internal/clients/handler";
import clientHandler, { AuthMode } from "~/server/internal/clients/handler";
import { parsePlatform } from "~/server/internal/utils/parseplatform";
export default defineEventHandler(async (h3) => {
const body = await readBody(h3);
const ClientAuthInitiate = type({
name: "string",
platform: "string",
capabilities: "object",
mode: type.valueOf(AuthMode).default(AuthMode.Callback),
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const body = await readDropValidatedBody(h3, ClientAuthInitiate);
const name = body.name;
const platformRaw = body.platform;
const capabilities: Partial<CapabilityConfiguration> =
body.capabilities ?? {};
if (!name || !platformRaw)
throw createError({
statusCode: 400,
statusMessage: "Missing name or platform in body",
});
const platform = parsePlatform(platformRaw);
if (!platform)
throw createError({
@ -29,12 +31,6 @@ export default defineEventHandler(async (h3) => {
statusMessage: "Invalid or unsupported platform",
});
if (!capabilities || typeof capabilities !== "object")
throw createError({
statusCode: 400,
statusMessage: "Capabilities must be an array",
});
const capabilityIterable = Object.entries(capabilities) as Array<
[InternalClientCapability, object]
>;
@ -64,11 +60,12 @@ export default defineEventHandler(async (h3) => {
statusMessage: "Invalid capability configuration.",
});
const clientId = await clientHandler.initiate({
name,
const result = await clientHandler.initiate({
name: body.name,
platform,
capabilities,
mode: body.mode,
});
return `/client/${clientId}/callback`;
return result;
});

View File

@ -71,6 +71,7 @@ export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = {
"game:version:update": "Update the version order on a game.",
"game:version:delete": "Delete a version for a game.",
"game:image:new": "Upload an image for a game.",
"game:image:import": "Import images from image providers for a game.",
"game:image:delete": "Delete an image for a game.",
"company:read": "Fetch companies.",

View File

@ -65,6 +65,7 @@ export const systemACLs = [
"game:version:update",
"game:version:delete",
"game:image:new",
"game:image:import",
"game:image:delete",
"company:read",

View File

@ -7,11 +7,18 @@ import type {
InternalClientCapability,
} from "./capabilities";
import capabilityManager from "./capabilities";
import type { PeerImpl } from "../tasks";
export enum AuthMode {
Callback = "callback",
Code = "code",
}
export interface ClientMetadata {
name: string;
platform: Platform;
capabilities: Partial<CapabilityConfiguration>;
mode: AuthMode;
}
export class ClientHandler {
@ -22,8 +29,10 @@ export class ClientHandler {
data: ClientMetadata;
userId?: string;
authToken?: string;
peer?: PeerImpl;
}
>();
private codeClientMap = new Map<string, string>();
async initiate(metadata: ClientMetadata) {
const clientId = randomUUID();
@ -32,14 +41,61 @@ export class ClientHandler {
data: metadata,
timeout: setTimeout(
() => {
if (this.temporaryClientTable.has(clientId))
const client = this.temporaryClientTable.get(clientId);
if (client) {
if (client.peer) {
client.peer.send(
JSON.stringify({ type: "error", value: "Request timed out." }),
);
client.peer.close();
}
this.temporaryClientTable.delete(clientId);
}
const code = this.codeClientMap
.entries()
.find(([_, v]) => v === clientId);
if (code) this.codeClientMap.delete(code[0]);
},
1000 * 60 * 10,
), // 10 minutes
});
return clientId;
switch (metadata.mode) {
case AuthMode.Callback:
return `/client/authorize/${clientId}`;
case AuthMode.Code: {
const code = randomUUID()
.replaceAll(/-/g, "")
.slice(0, 7)
.toUpperCase();
this.codeClientMap.set(code, clientId);
return code;
}
}
}
async connectCodeListener(code: string, peer: PeerImpl) {
const clientId = this.codeClientMap.get(code);
if (!clientId)
throw createError({
statusCode: 403,
statusMessage: "Invalid or unknown code.",
});
const metadata = this.temporaryClientTable.get(clientId);
if (!metadata)
throw createError({ statusCode: 500, statusMessage: "Broken code." });
if (metadata.peer)
throw createError({
statusCode: 400,
statusMessage: "Pre-existing listener for this code.",
});
metadata.peer = peer;
this.temporaryClientTable.set(clientId, metadata);
}
async fetchClientIdByCode(code: string) {
return this.codeClientMap.get(code);
}
async fetchClientMetadata(clientId: string) {
@ -68,6 +124,23 @@ export class ClientHandler {
return token;
}
async sendAuthToken(clientId: string, token: string) {
const client = this.temporaryClientTable.get(clientId);
if (!client)
throw createError({
statusCode: 500,
statusMessage: "Corrupted code, please restart the process.",
});
if (!client.peer)
throw createError({
statusCode: 400,
statusMessage: "Client has not connected yet. Please try again later.",
});
await client.peer.send(
JSON.stringify({ type: "token", value: `${clientId}/${token}` }),
);
}
async fetchClientMetadataByToken(token: string) {
return this.temporaryClientTable
.entries()

View File

@ -1 +1 @@
export const DROP_VERSION = "0.3.0";
export const DROP_VERSION = "0.3.1";

View File

@ -0,0 +1,7 @@
# Metadata structure
## Content providers (`MetadataProvider`)
Types & implementation is under `./content`. Providers under `./providers`
## Image providers (`ImageProvider`)
Types & implementation is under `./image`. Providers under `./providers`

View File

@ -1,6 +1,11 @@
/**
* Metadata providers search and download game metadata
* Basically an overkill auto-fill
*/
import type { Prisma } from "~/prisma/client/client";
import { MetadataSource } from "~/prisma/client/enums";
import prisma from "../db/database";
import prisma from "../../db/database";
import type {
_FetchGameMetadataParams,
_FetchCompanyMetadataParams,
@ -10,15 +15,15 @@ import type {
CompanyMetadata,
GameMetadataRating,
} from "./types";
import { ObjectTransactionalHandler } from "../objects/transactional";
import { PriorityListIndexed } from "../utils/prioritylist";
import { systemConfig } from "../config/sys-conf";
import type { TaskRunContext } from "../tasks";
import taskHandler, { wrapTaskContext } from "../tasks";
import { ObjectTransactionalHandler } from "../../objects/transactional";
import { PriorityListIndexed } from "../../utils/prioritylist";
import { systemConfig } from "../../config/sys-conf";
import type { TaskRunContext } from "../../tasks";
import taskHandler, { wrapTaskContext } from "../../tasks";
import { randomUUID } from "crypto";
import { fuzzy } from "fast-fuzzy";
import { logger } from "~/server/internal/logging";
import libraryManager from "../library";
import libraryManager from "../../library";
import type { GameTagModel } from "~/prisma/client/models";
export class MissingMetadataProviderConfig extends Error {

View File

@ -1,6 +1,6 @@
import type { Company, GameRating } from "~/prisma/client";
import type { TransactionDataType } from "../objects/transactional";
import type { ObjectReference } from "../objects/objectHandler";
import type { TransactionDataType } from "../../objects/transactional";
import type { ObjectReference } from "../../objects/objectHandler";
export interface GameMetadataSearchResult {
id: string;

View File

@ -0,0 +1,81 @@
import type { MetadataSource } from "~/prisma/client/enums";
import type { ImageSearchResult } from "./types";
import type { TaskRunContext } from "../../tasks";
import { ObjectTransactionalHandler } from "../../objects/transactional";
import { PriorityListIndexed } from "../../utils/prioritylist";
import { logger } from "../../logging";
export abstract class ImageProvider {
abstract name(): string;
abstract source(): MetadataSource;
abstract searchImages(query: string): Promise<ImageSearchResult[]>;
abstract importImages(
images: ImageSearchResult[],
taskRunContext?: TaskRunContext,
): Promise<string[]>; // List of object IDs
}
/**
* Confusingly, does videos too.
*/
export class ImageHandler {
private providers: PriorityListIndexed<ImageProvider> =
new PriorityListIndexed("source");
private objectHandler: ObjectTransactionalHandler =
new ObjectTransactionalHandler();
addProvider(provider: ImageProvider, priority: number = 0) {
this.providers.push(provider, priority);
}
/**
* Returns provider IDs, used to save to applicationConfig
* @returns The provider IDs in order, missing manual
*/
fetchProviderIdsInOrder() {
return this.providers
.values()
.map((e) => e.source())
.filter((e) => e !== "Manual");
}
async searchImages(query: string) {
const providers = this.providers.values();
const promises = [];
for (const provider of providers) {
const localFetch = async () => {
try {
const providerResults = await provider.searchImages(query);
return providerResults.map((result) => ({ ...result, provider: provider.source() }));
} catch (e) {
throw `${provider.name()}: ${e}`;
}
};
promises.push(localFetch());
}
const result = await Promise.allSettled(promises);
const fails = result.filter((e) => e.status === "rejected");
if (fails.length > 0) {
const failText = fails
.map((e) => e.reason)
.map((e) => "" + e)
.join("\n");
logger.warn(
`Failed to fetch some images from providers. Errors:\n${failText}`,
);
}
const successes = result
.filter((e) => e.status === "fulfilled")
.map((e) => e.value)
.flat();
return successes;
}
}
export const imageHandler = new ImageHandler();
export default imageHandler;

View File

@ -0,0 +1,10 @@
export type ImageSearchResultType = "image" | "video";
export interface ImageSearchResult {
id: string; // internal identifier for the result
url: string;
type: ImageSearchResultType;
name?: string;
size?: number;
}

View File

@ -1,7 +1,7 @@
import type { CompanyModel } from "~/prisma/client/models";
import { MetadataSource } from "~/prisma/client/enums";
import type { MetadataProvider } from ".";
import { MissingMetadataProviderConfig } from ".";
import type { MetadataProvider } from "../content";
import { MissingMetadataProviderConfig } from "../content";
import type {
GameMetadataSearchResult,
_FetchGameMetadataParams,
@ -9,11 +9,11 @@ import type {
_FetchCompanyMetadataParams,
CompanyMetadata,
GameMetadataRating,
} from "./types";
} from "../content/types";
import axios, { type AxiosRequestConfig } from "axios";
import TurndownService from "turndown";
import { DateTime } from "luxon";
import type { TaskRunContext } from "../tasks";
import type { TaskRunContext } from "../../tasks";
interface GiantBombResponseType<T> {
error: "OK" | string;
@ -277,6 +277,7 @@ export class GiantBombProvider implements MetadataProvider {
return metadata;
}
async fetchCompany({
query,
createObject,

View File

@ -1,19 +1,19 @@
import type { CompanyModel } from "~/prisma/client/models";
import { MetadataSource } from "~/prisma/client/enums";
import type { MetadataProvider } from ".";
import { MissingMetadataProviderConfig } from ".";
import type { MetadataProvider } from "../content";
import { MissingMetadataProviderConfig } from "../content";
import type {
GameMetadataSearchResult,
_FetchGameMetadataParams,
GameMetadata,
_FetchCompanyMetadataParams,
CompanyMetadata,
} from "./types";
} from "../content/types";
import type { AxiosRequestConfig } from "axios";
import axios from "axios";
import { DateTime } from "luxon";
import * as jdenticon from "jdenticon";
import type { TaskRunContext } from "../tasks";
import type { TaskRunContext } from "../../tasks";
import { logger } from "~/server/internal/logging";
type IGDBID = number;

View File

@ -1,11 +1,11 @@
import { MetadataSource } from "~/prisma/client/enums";
import type { MetadataProvider } from ".";
import type { MetadataProvider } from "../content";
import type {
_FetchGameMetadataParams,
GameMetadata,
_FetchCompanyMetadataParams,
CompanyMetadata,
} from "./types";
} from "../content/types";
import * as jdenticon from "jdenticon";
export class ManualMetadataProvider implements MetadataProvider {

View File

@ -1,6 +1,6 @@
import type { CompanyModel } from "~/prisma/client/models";
import { MetadataSource } from "~/prisma/client/enums";
import type { MetadataProvider } from ".";
import type { MetadataProvider } from "../content";
import type {
GameMetadataSearchResult,
_FetchGameMetadataParams,
@ -8,14 +8,14 @@ import type {
_FetchCompanyMetadataParams,
CompanyMetadata,
GameMetadataRating,
} from "./types";
} from "../content/types";
import type { AxiosRequestConfig } from "axios";
import axios from "axios";
import * as jdenticon from "jdenticon";
import { DateTime } from "luxon";
import * as cheerio from "cheerio";
import { type } from "arktype";
import type { TaskRunContext } from "../tasks";
import type { TaskRunContext } from "../../tasks";
import { logger } from "~/server/internal/logging";
interface PCGamingWikiParseRawPage {

View File

@ -0,0 +1,112 @@
import { MetadataSource } from "~/prisma/client/enums";
import type { TaskRunContext } from "../../tasks";
import type { ImageProvider } from "../image";
import { ImageSearchResultType, type ImageSearchResult } from "../image/types";
import { MissingMetadataProviderConfig } from "../content";
import type { NitroFetchOptions } from "nitropack";
interface SearchResult {
id: string;
name: string;
release_date: number;
}
interface SteamGridResponse<T> {
success: boolean;
data: Array<T>;
}
interface Grid {
id: number;
nsfw: boolean;
humor: boolean;
url: string;
thumb: string;
}
export class SteamGridDB implements ImageProvider {
private apikey: string;
constructor() {
const apikey = process.env.STEAMGRID_API_KEY;
if (!apikey)
throw new MissingMetadataProviderConfig("STEAMGRID_API_KEY", this.name());
this.apikey = apikey;
}
private async request<T>(
path: string,
opts?: NitroFetchOptions<
string,
| "get"
| "head"
| "patch"
| "post"
| "put"
| "delete"
| "connect"
| "options"
| "trace"
>,
) {
const fullPath = `https://www.steamgriddb.com/api/v2${path}`;
const response = await $fetch<T>(fullPath, {
...opts,
headers: { ...opts?.headers, Authorization: `Bearer ${this.apikey}` },
});
return response;
}
name(): string {
return "SteamGridDB";
}
source(): MetadataSource {
return MetadataSource.SteamGridDB;
}
async searchImages(query: string): Promise<ImageSearchResult[]> {
const games = await this.request<SteamGridResponse<SearchResult>>(
`/search/autocomplete/${encodeURIComponent(query)}`,
);
if (!games.success)
throw createError({
statusCode: 500,
statusMessage: "[SteamGridDB] Search indicated failed response.",
});
const firstGame = games.data.at(0);
if (!firstGame)
throw createError({
statusCode: 404,
statusMessage: "No results found.",
});
const grids = await this.request<SteamGridResponse<Grid>>(
`/grids/game/${firstGame.id}&nsfw=false&humor=false`,
);
if (!grids.success)
throw createError({
statusCode: 500,
statusMessage: "Failed to fetch grids for result.",
});
const results = grids.data.map(
(e) =>
({
id: e.id.toString(),
url: e.thumb,
type: "image",
}) satisfies ImageSearchResult,
);
return results;
}
importImages(
images: ImageSearchResult[],
taskRunContext?: TaskRunContext,
): Promise<string[]> {
throw new Error("Method not implemented.");
}
}

View File

@ -445,6 +445,7 @@ export type TaskMessage = {
export type PeerImpl = {
send: (message: string) => void;
close: () => void;
};
export interface BuildTask {

View File

@ -1,20 +1,23 @@
import { applicationSettings } from "../internal/config/application-configuration";
import type { MetadataProvider } from "../internal/metadata";
import metadataHandler from "../internal/metadata";
import { GiantBombProvider } from "../internal/metadata/giantbomb";
import { IGDBProvider } from "../internal/metadata/igdb";
import { ManualMetadataProvider } from "../internal/metadata/manual";
import { PCGamingWikiProvider } from "../internal/metadata/pcgamingwiki";
import type { MetadataProvider } from "../internal/metadata/content";
import metadataHandler from "../internal/metadata/content";
import type { ImageProvider } from "../internal/metadata/image";
import imageHandler from "../internal/metadata/image";
import { GiantBombProvider } from "../internal/metadata/providers/giantbomb";
import { IGDBProvider } from "../internal/metadata/providers/igdb";
import { ManualMetadataProvider } from "../internal/metadata/providers/manual";
import { PCGamingWikiProvider } from "../internal/metadata/providers/pcgamingwiki";
import { logger } from "~/server/internal/logging";
import { SteamGridDB } from "../internal/metadata/providers/steamgriddb";
export default defineNitroPlugin(async (_nitro) => {
const metadataProviders = [
GiantBombProvider,
PCGamingWikiProvider,
IGDBProvider,
SteamGridDB,
];
const providers = new Map<string, MetadataProvider>();
const providers = new Map<string, MetadataProvider | ImageProvider>();
for (const provider of metadataProviders) {
try {
@ -22,37 +25,25 @@ export default defineNitroPlugin(async (_nitro) => {
const id = prov.source();
providers.set(id, prov);
logger.info(`enabled metadata provider: ${prov.name()}`);
logger.info(`created metadata/image provider: ${prov.name()}`);
} catch (e) {
logger.warn(`skipping metadata provider setup: ${e}`);
}
}
// Add providers based on their position in the application settings
const configuredProviderList =
await applicationSettings.get("metadataProviders");
const max = configuredProviderList.length;
for (const [index, providerId] of configuredProviderList.entries()) {
const max = metadataProviders.length;
for (const [index, provider] of providers.entries().map((e, i) => [i, e[1]] as const)) {
const priority = max * 2 - index; // Offset by the length --- (max - index) + max
const provider = providers.get(providerId);
if (!provider) {
logger.warn(`failed to add existing metadata provider: ${providerId}`);
continue;
}
metadataHandler.addProvider(provider, priority);
providers.delete(providerId);
}
// Add the rest with no position
for (const [, provider] of providers.entries()) {
metadataHandler.addProvider(provider);
if ((provider as MetadataProvider)["search"]) {
logger.info(`added ${provider.name()} as metadata provider`);
metadataHandler.addProvider(provider as MetadataProvider, priority);
}
if((provider as ImageProvider)["searchImages"]){
logger.info(`added ${provider.name()} as image provider`);
imageHandler.addProvider(provider as ImageProvider, priority);
}
}
metadataHandler.addProvider(new ManualMetadataProvider(), -1000);
// Update the applicatonConfig
await applicationSettings.set(
"metadataProviders",
metadataHandler.fetchProviderIdsInOrder(),
);
});

View File

@ -61,6 +61,14 @@ export default defineNitroPlugin(async () => {
});
}
// 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

@ -2898,7 +2898,7 @@
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.18.tgz#529f24a88d3ed678d50fd5c07455841fbe8ac95e"
integrity sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA==
"@vueuse/core@13.6.0", "@vueuse/core@^13.6.0":
"@vueuse/core@13.6.0":
version "13.6.0"
resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-13.6.0.tgz#4137f63dc4cef2ff8ae74ee146d6b6070d707878"
integrity sha512-DJbD5fV86muVmBgS9QQPddVX7d9hWYswzlf4bIyUD2dj8GC46R1uNClZhVAmsdVts4xb2jwp1PbpuiA50Qee1A==