In-app store, torrential backend, locales (#332)
* feat: add store nav and fixes * fix: reduce password requirement & new task error ui * fix: client webtoken fix * fix: delta versions and dockerfile * fix: use setup platforms for filter & display * fix: setup not accounted when returning valid options * feat: tighter delta version support * feat: dl/disk size * feat: offload manifest generation to torrential * fix: bump torrential * feat: remove droplet * feat: bump torrential * feat: convert locales
This commit is contained in:
@@ -35,3 +35,5 @@ deploy-template/*
|
|||||||
# generated prisma client
|
# generated prisma client
|
||||||
/prisma/client
|
/prisma/client
|
||||||
/prisma/validate
|
/prisma/validate
|
||||||
|
|
||||||
|
/server/internal/proto
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ drop-base/
|
|||||||
# file is fully managed by pnpm, no reason to break it
|
# file is fully managed by pnpm, no reason to break it
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
|
|
||||||
torrential/
|
/torrential/
|
||||||
.data/**
|
.data/**
|
||||||
**/.data/**
|
**/.data/**
|
||||||
|
|||||||
Vendored
+1
-1
@@ -33,7 +33,7 @@
|
|||||||
"username": "drop"
|
"username": "drop"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"typescript.experimental.useTsgo": true,
|
"typescript.experimental.useTsgo": false,
|
||||||
// prioritize ArkType's "type" for autoimports
|
// prioritize ArkType's "type" for autoimports
|
||||||
"typescript.preferences.autoImportSpecifierExcludeRegexes": ["^(node:)?os$"]
|
"typescript.preferences.autoImportSpecifierExcludeRegexes": ["^(node:)?os$"]
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -68,6 +68,7 @@ COPY --from=torrential-build /build/target/release/torrential /usr/bin/
|
|||||||
ENV LIBRARY="/library"
|
ENV LIBRARY="/library"
|
||||||
ENV DATA="/data"
|
ENV DATA="/data"
|
||||||
ENV NGINX_CONFIG="/nginx.conf"
|
ENV NGINX_CONFIG="/nginx.conf"
|
||||||
ENV NUXT_PORT=4000
|
# NGINX's port
|
||||||
|
ENV PORT=4000
|
||||||
|
|
||||||
CMD ["sh", "/app/startup/launch.sh"]
|
CMD ["sh", "/app/startup/launch.sh"]
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
version: v1
|
||||||
|
plugins:
|
||||||
|
- plugin: es
|
||||||
|
out: server/internal/proto
|
||||||
|
opt: target=ts
|
||||||
@@ -90,7 +90,7 @@ import {
|
|||||||
startAuthentication,
|
startAuthentication,
|
||||||
browserSupportsWebAuthn,
|
browserSupportsWebAuthn,
|
||||||
} from "@simplewebauthn/browser";
|
} from "@simplewebauthn/browser";
|
||||||
import type { FetchError } from "ofetch";
|
import { FetchError } from "ofetch";
|
||||||
|
|
||||||
const username = ref("");
|
const username = ref("");
|
||||||
const password = ref("");
|
const password = ref("");
|
||||||
@@ -141,16 +141,19 @@ const router = useRouter();
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
function signin_wrapper() {
|
async function signin_wrapper() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
signin()
|
try {
|
||||||
.catch((response) => {
|
await signin();
|
||||||
const message = response.message || t("errors.unknown");
|
} catch (e) {
|
||||||
error.value = message;
|
if (e instanceof FetchError) {
|
||||||
})
|
error.value = e.data.message || t("errors.unknown");
|
||||||
.finally(() => {
|
} else {
|
||||||
loading.value = false;
|
error.value = e as string;
|
||||||
});
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function signin() {
|
async function signin() {
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
<div v-if="game && unimportedVersions" class="px-4 sm:px-6 lg:px-8 py-8">
|
<div v-if="game && unimportedVersions" class="px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div class="sm:flex sm:items-center">
|
<div class="sm:flex sm:items-center">
|
||||||
<div class="sm:flex-auto">
|
<div class="sm:flex-auto">
|
||||||
<h1 class="text-base font-semibold text-white">Versions</h1>
|
<h1 class="text-base font-semibold text-white">
|
||||||
|
{{ $t("library.admin.version.title") }}
|
||||||
|
</h1>
|
||||||
<p class="mt-2 text-sm text-gray-300">
|
<p class="mt-2 text-sm text-gray-300">
|
||||||
Versions versions version, versions versions. Versions.
|
{{ $t("library.admin.version.description") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
||||||
@@ -36,28 +38,28 @@
|
|||||||
scope="col"
|
scope="col"
|
||||||
class="py-3 pr-3 pl-4 text-left text-xs font-medium tracking-wide text-gray-400 uppercase sm:pl-0"
|
class="py-3 pr-3 pl-4 text-left text-xs font-medium tracking-wide text-gray-400 uppercase sm:pl-0"
|
||||||
>
|
>
|
||||||
Name (ID)
|
{{ $t("library.admin.version.table.name") }}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
class="px-3 py-3 text-left text-xs font-medium tracking-wide text-gray-400 uppercase"
|
class="px-3 py-3 text-left text-xs font-medium tracking-wide text-gray-400 uppercase"
|
||||||
>
|
>
|
||||||
Path
|
{{ $t("library.admin.version.table.path") }}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
class="px-3 py-3 text-left text-xs font-medium tracking-wide text-gray-400 uppercase"
|
class="px-3 py-3 text-left text-xs font-medium tracking-wide text-gray-400 uppercase"
|
||||||
>
|
>
|
||||||
Setup Configurations
|
{{ $t("library.admin.version.table.setup") }}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
class="px-3 py-3 text-left text-xs font-medium tracking-wide text-gray-400 uppercase"
|
class="px-3 py-3 text-left text-xs font-medium tracking-wide text-gray-400 uppercase"
|
||||||
>
|
>
|
||||||
Launch Configurations
|
{{ $t("library.admin.version.table.launch") }}
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" class="py-3 pr-4 pl-3 sm:pr-0">
|
<th scope="col" class="py-3 pr-4 pl-3 sm:pr-0">
|
||||||
<span class="sr-only">Edit</span>
|
<span class="sr-only">{{ $t("common.edit") }}</span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -100,13 +102,13 @@
|
|||||||
v-if="version.setups.length == 0"
|
v-if="version.setups.length == 0"
|
||||||
class="text-xs uppercase font-display text-zinc-700 font-semibold"
|
class="text-xs uppercase font-display text-zinc-700 font-semibold"
|
||||||
>
|
>
|
||||||
No setups configured.
|
{{ $t("library.admin.version.noSetups") }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-4 text-sm whitespace-nowrap text-gray-400">
|
<td class="px-3 py-4 text-sm whitespace-nowrap text-gray-400">
|
||||||
<div v-if="version.onlySetup">
|
<div v-if="version.onlySetup">
|
||||||
Version configured as in setup-only mode.
|
{{ $t("library.admin.version.setupOnly") }}
|
||||||
</div>
|
</div>
|
||||||
<ul v-else class="space-y-2">
|
<ul v-else class="space-y-2">
|
||||||
<GameEditorVersionConfig
|
<GameEditorVersionConfig
|
||||||
@@ -131,10 +133,7 @@
|
|||||||
class="text-red-400 hover:text-red-300"
|
class="text-red-400 hover:text-red-300"
|
||||||
@click="() => deleteVersion(version.versionId)"
|
@click="() => deleteVersion(version.versionId)"
|
||||||
>
|
>
|
||||||
Delete<span class="sr-only"
|
{{ $t("common.delete") }}
|
||||||
>,
|
|
||||||
{{ version.displayName ?? version.versionPath }}</span
|
|
||||||
>
|
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr></template
|
</tr></template
|
||||||
|
|||||||
@@ -26,8 +26,7 @@
|
|||||||
<div
|
<div
|
||||||
class="z-50 w-64 transition duration-100 opacity-0 shadow peer-hover:opacity-100 absolute left-0 p-2 bg-zinc-900 rounded text-xs text-zinc-300"
|
class="z-50 w-64 transition duration-100 opacity-0 shadow peer-hover:opacity-100 absolute left-0 p-2 bg-zinc-900 rounded text-xs text-zinc-300"
|
||||||
>
|
>
|
||||||
The installation directory is set as the current directory when
|
{{ $t("library.admin.launchRow.currentDirHint") }}
|
||||||
launching. It is not prepended to your command.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ $t("library.admin.import.version.installDir") }}
|
{{ $t("library.admin.import.version.installDir") }}
|
||||||
@@ -124,7 +123,7 @@
|
|||||||
<span
|
<span
|
||||||
:class="['block truncate', selected && 'font-semibold']"
|
:class="['block truncate', selected && 'font-semibold']"
|
||||||
>
|
>
|
||||||
'{{ launchProcessQuery }}'
|
{{ launchProcessQuery }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
@@ -155,11 +154,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-2 inline-flex items-center">
|
<div class="ml-2 inline-flex items-center">
|
||||||
<p class="text-sm text-blue-200">
|
<p class="text-sm text-blue-200">
|
||||||
<span
|
<i18n-t
|
||||||
class="font-mono bg-zinc-950 text-zinc-100 py-1 px-0.5 rounded-xl"
|
keypath="library.admin.launchRow.executorHint"
|
||||||
>{executor}</span
|
tag="span"
|
||||||
|
scope="global"
|
||||||
>
|
>
|
||||||
is replaced with the game's launch command for executors.
|
<template #executor>
|
||||||
|
<span
|
||||||
|
class="font-mono bg-zinc-950 text-zinc-100 py-1 px-0.5 rounded-xl"
|
||||||
|
>{{
|
||||||
|
// eslint-disable-next-line @intlify/vue-i18n/no-raw-text
|
||||||
|
"{executor}"
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,7 +183,7 @@
|
|||||||
</SelectorPlatform>
|
</SelectorPlatform>
|
||||||
<div v-if="props.type && props.type === 'Game' && props.allowExecutor">
|
<div v-if="props.type && props.type === 'Game' && props.allowExecutor">
|
||||||
<h1 class="block text-sm font-medium leading-6 text-zinc-100">
|
<h1 class="block text-sm font-medium leading-6 text-zinc-100">
|
||||||
Executor
|
{{ $t("library.admin.launchRow.executorTitle") }}
|
||||||
</h1>
|
</h1>
|
||||||
<div class="relative mt-2 space-x-1 inline-flex items-center w-full">
|
<div class="relative mt-2 space-x-1 inline-flex items-center w-full">
|
||||||
<ExecutorWidget v-if="executor" :executor="executor" />
|
<ExecutorWidget v-if="executor" :executor="executor" />
|
||||||
@@ -182,12 +191,12 @@
|
|||||||
v-else
|
v-else
|
||||||
class="font-bold uppercase font-display text-zinc-500 text-sm"
|
class="font-bold uppercase font-display text-zinc-500 text-sm"
|
||||||
>
|
>
|
||||||
No executor selected
|
{{ $t("library.admin.launchRow.noExecutorSelected") }}
|
||||||
</div>
|
</div>
|
||||||
<div class="grow" />
|
<div class="grow" />
|
||||||
<LoadingButton :loading="false" @click="selectLaunchOpen = true"
|
<LoadingButton :loading="false" @click="selectLaunchOpen = true">{{
|
||||||
>Select new executor</LoadingButton
|
$t("library.admin.launchRow.executorSelect")
|
||||||
>
|
}}</LoadingButton>
|
||||||
<button
|
<button
|
||||||
:disabled="!executor"
|
:disabled="!executor"
|
||||||
class="transition rounded p-2 bg-zinc-900/30 group hover:enabled:bg-red-600/10 text-zinc-400 hover:enabled:text-red-600 disabled:bg-zinc-900/80 disabled:text-zinc-700"
|
class="transition rounded p-2 bg-zinc-900/30 group hover:enabled:bg-red-600/10 text-zinc-400 hover:enabled:text-red-600 disabled:bg-zinc-900/80 disabled:text-zinc-700"
|
||||||
@@ -199,7 +208,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="props.type && props.type === 'Executor'">
|
<div v-if="props.type && props.type === 'Executor'">
|
||||||
<p class="block text-sm font-medium leading-6 text-zinc-100">
|
<p class="block text-sm font-medium leading-6 text-zinc-100">
|
||||||
Auto-suggest extensions
|
{{ $t("library.admin.launchRow.autosuggestHint") }}
|
||||||
</p>
|
</p>
|
||||||
<SelectorFileExtension
|
<SelectorFileExtension
|
||||||
v-model="launchConfiguration.suggestions!"
|
v-model="launchConfiguration.suggestions!"
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const open = defineModel<boolean>({ required: true });
|
const open = defineModel<boolean>({ required: true });
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
const collectionName = ref("");
|
const collectionName = ref("");
|
||||||
const createCollectionLoading = ref(false);
|
const createCollectionLoading = ref(false);
|
||||||
const collections = await useCollections();
|
const collections = await useCollections();
|
||||||
@@ -74,6 +73,7 @@ async function createCollection() {
|
|||||||
const response = await $dropFetch("/api/v1/collection", {
|
const response = await $dropFetch("/api/v1/collection", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { name: collectionName.value },
|
body: { name: collectionName.value },
|
||||||
|
failTitle: "Failed to create collection",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add the game if provided
|
// Add the game if provided
|
||||||
@@ -83,6 +83,7 @@ async function createCollection() {
|
|||||||
>(`/api/v1/collection/${response.id}/entry`, {
|
>(`/api/v1/collection/${response.id}/entry`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { id: props.gameId },
|
body: { id: props.gameId },
|
||||||
|
failTitle: "Failed to add game to collection",
|
||||||
});
|
});
|
||||||
response.entries.push(entry);
|
response.entries.push(entry);
|
||||||
}
|
}
|
||||||
@@ -94,20 +95,6 @@ async function createCollection() {
|
|||||||
open.value = false;
|
open.value = false;
|
||||||
|
|
||||||
emit("created", response.id);
|
emit("created", response.id);
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to create collection:", error);
|
|
||||||
|
|
||||||
const err = error as { statusMessage?: string };
|
|
||||||
createModal(
|
|
||||||
ModalType.Notification,
|
|
||||||
{
|
|
||||||
title: t("errors.library.collection.create.title"),
|
|
||||||
description: t("errors.library.collection.create.desc", [
|
|
||||||
err?.statusMessage ?? t("errors.unknown"),
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
(_, c) => c(),
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
createCollectionLoading.value = false;
|
createCollectionLoading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
class="bg-red-600 text-white hover:bg-red-500"
|
class="bg-red-600 text-white hover:bg-red-500"
|
||||||
@click="() => deleteCollection()"
|
@click="() => deleteCollection()"
|
||||||
>
|
>
|
||||||
{{ $t("delete") }}
|
{{ $t("common.delete") }}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
|
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
class="bg-red-600 text-white hover:bg-red-500"
|
class="bg-red-600 text-white hover:bg-red-500"
|
||||||
@click="() => deleteArticle()"
|
@click="() => deleteArticle()"
|
||||||
>
|
>
|
||||||
{{ $t("delete") }}
|
{{ $t("common.delete") }}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
|
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
class="bg-red-600 text-white hover:bg-red-500"
|
class="bg-red-600 text-white hover:bg-red-500"
|
||||||
@click="() => deleteUser()"
|
@click="() => deleteUser()"
|
||||||
>
|
>
|
||||||
{{ $t("delete") }}
|
{{ $t("common.delete") }}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
|
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
|
||||||
|
|||||||
@@ -3,17 +3,17 @@
|
|||||||
<template #default>
|
<template #default>
|
||||||
<div>
|
<div>
|
||||||
<h1 as="h3" class="text-lg font-medium leading-6 text-white">
|
<h1 as="h3" class="text-lg font-medium leading-6 text-white">
|
||||||
Select a launch option
|
{{ $t("library.admin.launchSelector.title") }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-1 text-zinc-400 text-sm">
|
<p class="mt-1 text-zinc-400 text-sm">
|
||||||
Select a launch option as an executor for your new launch option.
|
{{ $t("library.admin.launchSelector.description") }}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
v-if="props.filterPlatform"
|
v-if="props.filterPlatform"
|
||||||
class="inline-flex items-center mt-2 gap-x-4"
|
class="inline-flex items-center mt-2 gap-x-4"
|
||||||
>
|
>
|
||||||
<h1 class="block text-sm font-medium leading-6 text-zinc-100">
|
<h1 class="block text-sm font-medium leading-6 text-zinc-100">
|
||||||
Only showing launches for:
|
{{ $t("library.admin.launchSelector.platformFilterHint") }}
|
||||||
</h1>
|
</h1>
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<component
|
<component
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
<div class="mt-2 space-y-4">
|
<div class="mt-2 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="block text-sm font-medium leading-6 text-zinc-100">
|
<h1 class="block text-sm font-medium leading-6 text-zinc-100">
|
||||||
Search for an executor
|
{{ $t("library.admin.launchSelector.search") }}
|
||||||
</h1>
|
</h1>
|
||||||
<SelectorGame
|
<SelectorGame
|
||||||
:search="search"
|
:search="search"
|
||||||
@@ -43,11 +43,11 @@
|
|||||||
v-if="versions !== undefined && Object.entries(versions).length == 0"
|
v-if="versions !== undefined && Object.entries(versions).length == 0"
|
||||||
class="text-zinc-300 text-sm font-bold font-display uppercase text-center w-full"
|
class="text-zinc-300 text-sm font-bold font-display uppercase text-center w-full"
|
||||||
>
|
>
|
||||||
No versions imported.
|
{{ $t("library.admin.launchSelector.noVersions") }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="versions !== undefined">
|
<div v-else-if="versions !== undefined">
|
||||||
<h1 class="block text-sm font-medium leading-6 text-zinc-100">
|
<h1 class="block text-sm font-medium leading-6 text-zinc-100">
|
||||||
Select a version
|
{{ $t("library.admin.launchSelector.selectVersions") }}
|
||||||
</h1>
|
</h1>
|
||||||
<SelectorCombox
|
<SelectorCombox
|
||||||
:search="
|
:search="
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="versions && version">
|
<div v-if="versions && version">
|
||||||
<h1 class="block text-sm font-medium leading-6 text-zinc-100">
|
<h1 class="block text-sm font-medium leading-6 text-zinc-100">
|
||||||
Select a launch command
|
{{ $t("library.admin.launchSelector.selectCommand") }}
|
||||||
</h1>
|
</h1>
|
||||||
<SelectorCombox
|
<SelectorCombox
|
||||||
:search="
|
:search="
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #buttons>
|
<template #buttons>
|
||||||
<LoadingButton :loading="false" :disabled="!launchId" @click="submit">
|
<LoadingButton :loading="false" :disabled="!launchId" @click="submit">
|
||||||
Select
|
{{ $t("common.select") }}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
|
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
v-if="results.length == 0"
|
v-if="results.length == 0"
|
||||||
class="text-zinc-300 uppercase font-display font-bold text-center p-4"
|
class="text-zinc-300 uppercase font-display font-bold text-center p-4"
|
||||||
>
|
>
|
||||||
No results.
|
{{ $t("common.noResults") }}
|
||||||
</div>
|
</div>
|
||||||
<ComboboxOption
|
<ComboboxOption
|
||||||
v-for="result in results"
|
v-for="result in results"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
class="group relative -mr-1 size-3.5 rounded-xs hover:bg-blue-500/30"
|
class="group relative -mr-1 size-3.5 rounded-xs hover:bg-blue-500/30"
|
||||||
@click="() => removeFileExtension(extension)"
|
@click="() => removeFileExtension(extension)"
|
||||||
>
|
>
|
||||||
<span class="sr-only">Remove</span>
|
<span class="sr-only">{{ $t("common.remove") }}</span>
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 14 14"
|
viewBox="0 0 14 14"
|
||||||
class="size-3.5 stroke-blue-400 group-hover:stroke-blue-300"
|
class="size-3.5 stroke-blue-400 group-hover:stroke-blue-300"
|
||||||
@@ -22,9 +22,9 @@
|
|||||||
<span class="absolute -inset-1"></span>
|
<span class="absolute -inset-1"></span>
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="model.length == 0" class="text-zinc-500 text-xs"
|
<span v-if="model.length == 0" class="text-zinc-500 text-xs">{{
|
||||||
>No extensions selected.</span
|
$t("library.admin.fileExtSelector.noSelected")
|
||||||
>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<Combobox
|
<Combobox
|
||||||
as="div"
|
as="div"
|
||||||
@@ -65,7 +65,11 @@
|
|||||||
: 'text-zinc-100',
|
: 'text-zinc-100',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<span> Add "{{ normalize(query) }}" </span>
|
<span>
|
||||||
|
{{
|
||||||
|
$t("library.admin.fileExtSelector.add", [normalize(query)])
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
v-if="selected"
|
v-if="selected"
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
v-if="gameSearchQuery.length < 4"
|
v-if="gameSearchQuery.length < 4"
|
||||||
class="text-zinc-300 uppercase font-display font-bold text-center p-4"
|
class="text-zinc-300 uppercase font-display font-bold text-center p-4"
|
||||||
>
|
>
|
||||||
Type at least 4 characters to get results
|
{{ $t("library.admin.gameSelector.hint") }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="resultsLoading || results === undefined"
|
v-else-if="resultsLoading || results === undefined"
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
v-else-if="results.length == 0"
|
v-else-if="results.length == 0"
|
||||||
class="text-zinc-500 uppercase font-display font-bold text-center p-4"
|
class="text-zinc-500 uppercase font-display font-bold text-center p-4"
|
||||||
>
|
>
|
||||||
No results
|
{{ $t("common.noResults") }}
|
||||||
</div>
|
</div>
|
||||||
<ComboboxOption
|
<ComboboxOption
|
||||||
v-for="result in results"
|
v-for="result in results"
|
||||||
|
|||||||
@@ -128,7 +128,7 @@
|
|||||||
class="text-red-500 hover:text-red-400"
|
class="text-red-500 hover:text-red-400"
|
||||||
@click="() => deleteSource(sourceIdx)"
|
@click="() => deleteSource(sourceIdx)"
|
||||||
>
|
>
|
||||||
{{ $t("delete") }}
|
{{ $t("common.delete") }}
|
||||||
<span class="sr-only">
|
<span class="sr-only">
|
||||||
{{ $t("chars.srComma", [source.name]) }}
|
{{ $t("chars.srComma", [source.name]) }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="w-full bg-zinc-950 p-1 inline-flex items-center gap-x-2 fixed inset-x-0 top-0 z-100"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="p-1 text-zinc-300 hover:text-zinc-100 hover:bg-zinc-900 transition-all rounded"
|
||||||
|
@click="() => router.back()"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon class="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-1 text-zinc-300 hover:text-zinc-100 hover:bg-zinc-900 transition-all rounded"
|
||||||
|
@click="() => router.forward()"
|
||||||
|
>
|
||||||
|
<ChevronRightIcon class="size-4" />
|
||||||
|
</button>
|
||||||
|
<span class="text-zinc-400 text-sm">
|
||||||
|
{{ title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/vue/24/outline";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const title = ref("Loading...");
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
title.value = document.title;
|
||||||
|
});
|
||||||
|
|
||||||
|
router.afterEach(() => {
|
||||||
|
title.value = "Loading...";
|
||||||
|
// TODO: more robust after-render "detection"
|
||||||
|
setTimeout(() => {
|
||||||
|
title.value = document.title;
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:14-alpine
|
image: postgres:14-alpine
|
||||||
user: "1000:1000"
|
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
volumes:
|
volumes:
|
||||||
- ../.data/db:/var/lib/postgresql/data
|
- postgres-data:/var/lib/postgresql/data
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_PASSWORD=drop
|
- POSTGRES_PASSWORD=drop
|
||||||
- POSTGRES_USER=drop
|
- POSTGRES_USER=drop
|
||||||
- POSTGRES_DB=drop
|
- POSTGRES_DB=drop
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
DELETE FROM "Session" WHERE 1=1;
|
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
"register": {
|
"register": {
|
||||||
"confirmPasswordFormat": "Muss mit oben genanntem übereinstimmen",
|
"confirmPasswordFormat": "Muss mit oben genanntem übereinstimmen",
|
||||||
"emailFormat": "Muss im Format nutzer{'@'}beispiel.de sein",
|
"emailFormat": "Muss im Format nutzer{'@'}beispiel.de sein",
|
||||||
"passwordFormat": "Muss mindestens 14 Zeichen enthalten",
|
"passwordFormat": "Muss mindestens 8 Zeichen enthalten",
|
||||||
"subheader": "Gebe unten deine Daten ein, um dein Konto zu erstellen.",
|
"subheader": "Gebe unten deine Daten ein, um dein Konto zu erstellen.",
|
||||||
"title": "Erstelle dein Drop Konto",
|
"title": "Erstelle dein Drop Konto",
|
||||||
"usernameFormat": "Muss mindestens 5 Zeichen enthalten und aus Kleinbuchstaben bestehen"
|
"usernameFormat": "Muss mindestens 5 Zeichen enthalten und aus Kleinbuchstaben bestehen"
|
||||||
@@ -101,6 +101,7 @@
|
|||||||
"close": "Schließen",
|
"close": "Schließen",
|
||||||
"create": "Erstellen",
|
"create": "Erstellen",
|
||||||
"date": "Datum",
|
"date": "Datum",
|
||||||
|
"delete": "Löschen",
|
||||||
"deleteConfirm": "Möchtest du \"{0}\" wirklich löschen?",
|
"deleteConfirm": "Möchtest du \"{0}\" wirklich löschen?",
|
||||||
"divider": "{'|'}",
|
"divider": "{'|'}",
|
||||||
"edit": "Bearbeiten",
|
"edit": "Bearbeiten",
|
||||||
@@ -120,7 +121,6 @@
|
|||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"today": "Heute"
|
"today": "Heute"
|
||||||
},
|
},
|
||||||
"delete": "Löschen",
|
|
||||||
"drop": {
|
"drop": {
|
||||||
"desc": "Eine Open-Source-Plattform für die Verteilung von Spielen, die auf Geschwindigkeit, Flexibilität und Ästhetik ausgelegt ist.",
|
"desc": "Eine Open-Source-Plattform für die Verteilung von Spielen, die auf Geschwindigkeit, Flexibilität und Ästhetik ausgelegt ist.",
|
||||||
"drop": "Drop"
|
"drop": "Drop"
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
"register": {
|
"register": {
|
||||||
"confirmPasswordFormat": "Must be the same as above, savvy?",
|
"confirmPasswordFormat": "Must be the same as above, savvy?",
|
||||||
"emailFormat": "Must be in the fashion of a true scallywag {'@'} example.com",
|
"emailFormat": "Must be in the fashion of a true scallywag {'@'} example.com",
|
||||||
"passwordFormat": "Must be 14 or more marks, ye landlubber!",
|
"passwordFormat": "Must be 8 or more marks, ye landlubber!",
|
||||||
"subheader": "Fill in yer details below to make yer mark.",
|
"subheader": "Fill in yer details below to make yer mark.",
|
||||||
"title": "Forge yer Drop Mark",
|
"title": "Forge yer Drop Mark",
|
||||||
"usernameFormat": "Must be 5 or more marks, and all lowercase, argh!"
|
"usernameFormat": "Must be 5 or more marks, and all lowercase, argh!"
|
||||||
@@ -87,6 +87,7 @@
|
|||||||
"close": "Shut yer trap!",
|
"close": "Shut yer trap!",
|
||||||
"create": "Forge!",
|
"create": "Forge!",
|
||||||
"date": "Date",
|
"date": "Date",
|
||||||
|
"delete": "Scuttle!",
|
||||||
"deleteConfirm": "Are ye sure ye want to scuttle \"{0}\", ye rogue?",
|
"deleteConfirm": "Are ye sure ye want to scuttle \"{0}\", ye rogue?",
|
||||||
"divider": "{'|'}",
|
"divider": "{'|'}",
|
||||||
"edit": "Amend",
|
"edit": "Amend",
|
||||||
@@ -104,7 +105,6 @@
|
|||||||
"tags": "Marks",
|
"tags": "Marks",
|
||||||
"today": "Today"
|
"today": "Today"
|
||||||
},
|
},
|
||||||
"delete": "Scuttle!",
|
|
||||||
"drop": {
|
"drop": {
|
||||||
"desc": "An open-source game distribution platform built for speed, flexibility and beauty, like a swift brigantine!",
|
"desc": "An open-source game distribution platform built for speed, flexibility and beauty, like a swift brigantine!",
|
||||||
"drop": "Drop"
|
"drop": "Drop"
|
||||||
|
|||||||
+121
-11
@@ -9,7 +9,9 @@
|
|||||||
"subheader": "Manage the devices authorized to access your Drop account.",
|
"subheader": "Manage the devices authorized to access your Drop account.",
|
||||||
"title": "Devices"
|
"title": "Devices"
|
||||||
},
|
},
|
||||||
"home": { "title": "Home" },
|
"home": {
|
||||||
|
"title": "Home"
|
||||||
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"all": "View all {arrow}",
|
"all": "View all {arrow}",
|
||||||
"clear": "Clear notifications",
|
"clear": "Clear notifications",
|
||||||
@@ -21,7 +23,35 @@
|
|||||||
"title": "Notifications",
|
"title": "Notifications",
|
||||||
"unread": "Unread Notifications"
|
"unread": "Unread Notifications"
|
||||||
},
|
},
|
||||||
"security": { "title": "Security" },
|
"security": {
|
||||||
|
"2fa": {
|
||||||
|
"superlevelHint": {
|
||||||
|
"signin": "Sign in {arrow}",
|
||||||
|
"success": "You have access to these protected actions.",
|
||||||
|
"title": "Sign in again to access these settings."
|
||||||
|
},
|
||||||
|
"title": "Two-factor authentication",
|
||||||
|
"totp": {
|
||||||
|
"description": "TOTP generates one-time codes, completely offline. You can use any TOTP authenticator you like.",
|
||||||
|
"disableButton": "Disable",
|
||||||
|
"title": "TOTP"
|
||||||
|
},
|
||||||
|
"webauthn": {
|
||||||
|
"bypassHint": "Also lets you bypass signing in with compatible devices.",
|
||||||
|
"description": "Otherwise known as passkeys. Authenticate using biometrics, a device, YubiKeys, or any compatible FIDO2 device.",
|
||||||
|
"manage": "Manage",
|
||||||
|
"modal": {
|
||||||
|
"description": "Create new keys or remove existing keys from your account.",
|
||||||
|
"new": "New key",
|
||||||
|
"tableCreated": "Created",
|
||||||
|
"tableName": "Name",
|
||||||
|
"title": "WebAuthn Keys"
|
||||||
|
},
|
||||||
|
"title": "WebAuthn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "Security"
|
||||||
|
},
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"title": "Account Settings",
|
"title": "Account Settings",
|
||||||
"token": {
|
"token": {
|
||||||
@@ -50,6 +80,31 @@
|
|||||||
"adminTitle": "Admin Dashboard - Drop",
|
"adminTitle": "Admin Dashboard - Drop",
|
||||||
"adminTitleTemplate": "{0} - Admin - Drop",
|
"adminTitleTemplate": "{0} - Admin - Drop",
|
||||||
"auth": {
|
"auth": {
|
||||||
|
"2fa": {
|
||||||
|
"backToOptions": "{arrow} Back to options",
|
||||||
|
"description": "Two-factor authentication is enabled on your account. Choose one of the options below to continue.",
|
||||||
|
"passkey": {
|
||||||
|
"createDescription": "WebAuthn, or passkeys, allow you to sign in or complete 2FA with biometrics or hardware security devices.",
|
||||||
|
"createTitle": "Create a passkey",
|
||||||
|
"description": "Use a passkey, like biometrics, a hardware security device, or other compatible device to sign in to your Drop account.",
|
||||||
|
"passkeyNameTag": "Name",
|
||||||
|
"signinButton": "Sign in with WebAuthn",
|
||||||
|
"title": "WebAuthn"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"back": "{arrow} Back to account security",
|
||||||
|
"description": "Drop has successfully created and added your 2FA method. If this is your first time configuring 2FA, your account now requires it to sign in.",
|
||||||
|
"title": "Added your 2FA method!"
|
||||||
|
},
|
||||||
|
"title": "Two-factor authentication",
|
||||||
|
"totp": {
|
||||||
|
"createDescription": "Use your TOTP authenticator, like Google Authenticator, Aegis, or Bitwarden, to add 2FA to your Drop account.",
|
||||||
|
"createHint": "Enter the generated code to enable TOTP",
|
||||||
|
"createTitle": "Set up your authenticator",
|
||||||
|
"description": "Use a one-time code to sign in to your Drop account.",
|
||||||
|
"title": "TOTP"
|
||||||
|
}
|
||||||
|
},
|
||||||
"callback": {
|
"callback": {
|
||||||
"authClient": "Authorize client?",
|
"authClient": "Authorize client?",
|
||||||
"authorize": "Authorize",
|
"authorize": "Authorize",
|
||||||
@@ -72,7 +127,7 @@
|
|||||||
"register": {
|
"register": {
|
||||||
"confirmPasswordFormat": "Must be the same as above",
|
"confirmPasswordFormat": "Must be the same as above",
|
||||||
"emailFormat": "Must be in the format user{'@'}example.com",
|
"emailFormat": "Must be in the format user{'@'}example.com",
|
||||||
"passwordFormat": "Must be 14 or more characters",
|
"passwordFormat": "Must be 8 or more characters",
|
||||||
"subheader": "Fill in your details below to create your account.",
|
"subheader": "Fill in your details below to create your account.",
|
||||||
"title": "Create your Drop account",
|
"title": "Create your Drop account",
|
||||||
"usernameFormat": "Must be 5 or more characters, and lowercase"
|
"usernameFormat": "Must be 5 or more characters, and lowercase"
|
||||||
@@ -81,12 +136,14 @@
|
|||||||
"externalProvider": "external provider",
|
"externalProvider": "external provider",
|
||||||
"forgot": "Forgot password?",
|
"forgot": "Forgot password?",
|
||||||
"noAccount": "Don't have an account? Ask an admin to create one for you.",
|
"noAccount": "Don't have an account? Ask an admin to create one for you.",
|
||||||
|
"noAccountProtected": "We need you to sign in again for security reasons while attempting to access more sensitive actions.",
|
||||||
"or": "OR",
|
"or": "OR",
|
||||||
"pageTitle": "Sign in to Drop",
|
"pageTitle": "Sign in to Drop",
|
||||||
"rememberMe": "Remember me",
|
"rememberMe": "Remember me",
|
||||||
"signin": "Sign in",
|
"signin": "Sign in",
|
||||||
"signinWithExternalProvider": "Sign in with {externalProvider} {arrow}",
|
"signinWithExternalProvider": "Sign in with {externalProvider} {arrow}",
|
||||||
"title": "Sign in to your account"
|
"title": "Sign in to your account",
|
||||||
|
"titleProtected": "Sign in to access protected action"
|
||||||
},
|
},
|
||||||
"signout": "Signout",
|
"signout": "Signout",
|
||||||
"username": "Username"
|
"username": "Username"
|
||||||
@@ -105,6 +162,7 @@
|
|||||||
"close": "Close",
|
"close": "Close",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
"date": "Date",
|
"date": "Date",
|
||||||
|
"delete": "Delete",
|
||||||
"deleteConfirm": "Are you sure you want to delete \"{0}\"?",
|
"deleteConfirm": "Are you sure you want to delete \"{0}\"?",
|
||||||
"divider": "{'|'}",
|
"divider": "{'|'}",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
@@ -119,12 +177,12 @@
|
|||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"saved": "Saved",
|
"saved": "Saved",
|
||||||
|
"select": "Select",
|
||||||
"servers": "Servers",
|
"servers": "Servers",
|
||||||
"srLoading": "Loading…",
|
"srLoading": "Loading…",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"today": "Today"
|
"today": "Today"
|
||||||
},
|
},
|
||||||
"delete": "Delete",
|
|
||||||
"drop": {
|
"drop": {
|
||||||
"desc": "An open-source game distribution platform built for speed, flexibility and beauty.",
|
"desc": "An open-source game distribution platform built for speed, flexibility and beauty.",
|
||||||
"drop": "Drop"
|
"drop": "Drop"
|
||||||
@@ -158,7 +216,9 @@
|
|||||||
"invalidPassState": "Invalid password state. Please contact the server administrator.",
|
"invalidPassState": "Invalid password state. Please contact the server administrator.",
|
||||||
"invalidUserOrPass": "Invalid username or password.",
|
"invalidUserOrPass": "Invalid username or password.",
|
||||||
"inviteIdRequired": "id required in fetching invitation",
|
"inviteIdRequired": "id required in fetching invitation",
|
||||||
"method": { "signinDisabled": "Sign in method not enabled" },
|
"method": {
|
||||||
|
"signinDisabled": "Sign in method not enabled"
|
||||||
|
},
|
||||||
"usernameTaken": "Username already taken."
|
"usernameTaken": "Username already taken."
|
||||||
},
|
},
|
||||||
"backHome": "{arrow} Back to home",
|
"backHome": "{arrow} Back to home",
|
||||||
@@ -248,12 +308,18 @@
|
|||||||
"aboutDrop": "About Drop",
|
"aboutDrop": "About Drop",
|
||||||
"api": "API documentation",
|
"api": "API documentation",
|
||||||
"comparison": "Comparison",
|
"comparison": "Comparison",
|
||||||
"docs": { "client": "Client Docs", "server": "Server Docs" },
|
"docs": {
|
||||||
|
"client": "Client Docs",
|
||||||
|
"server": "Server Docs"
|
||||||
|
},
|
||||||
"documentation": "Documentation",
|
"documentation": "Documentation",
|
||||||
"findGame": "Find a Game",
|
"findGame": "Find a Game",
|
||||||
"footer": "Footer",
|
"footer": "Footer",
|
||||||
"games": "Games",
|
"games": "Games",
|
||||||
"social": { "discord": "Discord", "github": "GitHub" },
|
"social": {
|
||||||
|
"discord": "Discord",
|
||||||
|
"github": "GitHub"
|
||||||
|
},
|
||||||
"topSellers": "Top Sellers",
|
"topSellers": "Top Sellers",
|
||||||
"version": "Drop {version} {gitRef}"
|
"version": "Drop {version} {gitRef}"
|
||||||
},
|
},
|
||||||
@@ -303,6 +369,10 @@
|
|||||||
"admin": {
|
"admin": {
|
||||||
"detectedGame": "Drop has detected you have new games to import.",
|
"detectedGame": "Drop has detected you have new games to import.",
|
||||||
"detectedVersion": "Drop has detected you have new versions of this game to import.",
|
"detectedVersion": "Drop has detected you have new versions of this game to import.",
|
||||||
|
"fileExtSelector": {
|
||||||
|
"add": "Add \"{0}\"",
|
||||||
|
"noSelected": "No extensions selected."
|
||||||
|
},
|
||||||
"game": {
|
"game": {
|
||||||
"addCarouselNoImages": "No images to add.",
|
"addCarouselNoImages": "No images to add.",
|
||||||
"addDescriptionNoImages": "No images to add.",
|
"addDescriptionNoImages": "No images to add.",
|
||||||
@@ -323,10 +393,14 @@
|
|||||||
"setCover": "Set as cover"
|
"setCover": "Set as cover"
|
||||||
},
|
},
|
||||||
"gameLibrary": "Game Library",
|
"gameLibrary": "Game Library",
|
||||||
|
"gameSelector": {
|
||||||
|
"hint": "Type at least 4 characters to get results"
|
||||||
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"bulkImportDescription": "When on this page, you won't be redirect to the import task, so you can import multiple games in succession.",
|
"bulkImportDescription": "When on this page, you won't be redirect to the import task, so you can import multiple games in succession.",
|
||||||
"bulkImportTitle": "Bulk import mode",
|
"bulkImportTitle": "Bulk import mode",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
|
"importAs": "Import as",
|
||||||
"link": "Import {arrow}",
|
"link": "Import {arrow}",
|
||||||
"loading": "Loading game results…",
|
"loading": "Loading game results…",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
@@ -344,6 +418,7 @@
|
|||||||
"launchPlaceholder": "game.exe --args",
|
"launchPlaceholder": "game.exe --args",
|
||||||
"loadingVersion": "Loading version metadata…",
|
"loadingVersion": "Loading version metadata…",
|
||||||
"noLaunches": "No launch configurations added.",
|
"noLaunches": "No launch configurations added.",
|
||||||
|
"noNameProvided": "No name provided.",
|
||||||
"noSetups": "No setup configurations added.",
|
"noSetups": "No setup configurations added.",
|
||||||
"noVersions": "No versions to import",
|
"noVersions": "No versions to import",
|
||||||
"platform": "Version platform",
|
"platform": "Version platform",
|
||||||
@@ -357,6 +432,23 @@
|
|||||||
},
|
},
|
||||||
"withoutMetadata": "Import without metadata"
|
"withoutMetadata": "Import without metadata"
|
||||||
},
|
},
|
||||||
|
"launchRow": {
|
||||||
|
"autosuggestHint": "Auto-suggest extensions",
|
||||||
|
"currentDirHint": "The installation directory is set as the current directory when launching. It is not prepended to your command.",
|
||||||
|
"executorHint": "{executor} is replaced with the game's launch command for emulators.",
|
||||||
|
"executorSelect": "Select new executor",
|
||||||
|
"executorTitle": "Executor",
|
||||||
|
"noExecutorSelected": "No executor selected"
|
||||||
|
},
|
||||||
|
"launchSelector": {
|
||||||
|
"description": "Select a launch option as an executor for your new launch option.",
|
||||||
|
"noVersions": "No versions imported.",
|
||||||
|
"platformFilterHint": "Only showing launches for:",
|
||||||
|
"search": "Search for an executor",
|
||||||
|
"selectCommand": "Select a launch command",
|
||||||
|
"selectVersions": "Select a version",
|
||||||
|
"title": "Select a launch option"
|
||||||
|
},
|
||||||
"libraryHint": "No libraries configured.",
|
"libraryHint": "No libraries configured.",
|
||||||
"libraryHintDocsLink": "What does this mean? {arrow}",
|
"libraryHintDocsLink": "What does this mean? {arrow}",
|
||||||
"metadata": {
|
"metadata": {
|
||||||
@@ -446,7 +538,17 @@
|
|||||||
"subheader": "As you add folders to your library sources, Drop will detect it and prompt you to import it. Each game needs to be imported before you can import a version.",
|
"subheader": "As you add folders to your library sources, Drop will detect it and prompt you to import it. Each game needs to be imported before you can import a version.",
|
||||||
"title": "Libraries",
|
"title": "Libraries",
|
||||||
"version": {
|
"version": {
|
||||||
"noVersions": "You have no versions of this game available."
|
"description": "All versions imported for your game.",
|
||||||
|
"noSetups": "No setups configured.",
|
||||||
|
"noVersions": "You have no versions of this game available.",
|
||||||
|
"setupOnly": "Version configured as in setup-only mode.",
|
||||||
|
"table": {
|
||||||
|
"launch": "Launch Configurations",
|
||||||
|
"name": "Name (ID)",
|
||||||
|
"path": "Path",
|
||||||
|
"setup": "Setup Configurations"
|
||||||
|
},
|
||||||
|
"title": "Versions"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"back": "Back to Library",
|
"back": "Back to Library",
|
||||||
@@ -595,6 +697,7 @@
|
|||||||
"completedTasksTitle": "Completed tasks",
|
"completedTasksTitle": "Completed tasks",
|
||||||
"dailyScheduledTitle": "Daily scheduled tasks",
|
"dailyScheduledTitle": "Daily scheduled tasks",
|
||||||
"execute": "{arrow} Execute",
|
"execute": "{arrow} Execute",
|
||||||
|
"noActions": "No actions",
|
||||||
"noTasksRunning": "No tasks currently running",
|
"noTasksRunning": "No tasks currently running",
|
||||||
"progress": "{0}%",
|
"progress": "{0}%",
|
||||||
"runningTasksTitle": "Running tasks",
|
"runningTasksTitle": "Running tasks",
|
||||||
@@ -628,8 +731,15 @@
|
|||||||
},
|
},
|
||||||
"userHeader": {
|
"userHeader": {
|
||||||
"closeSidebar": "Close sidebar",
|
"closeSidebar": "Close sidebar",
|
||||||
"links": { "community": "Community", "library": "Library", "news": "News" },
|
"links": {
|
||||||
"profile": { "admin": "Admin Dashboard", "settings": "Account settings" }
|
"community": "Community",
|
||||||
|
"library": "Library",
|
||||||
|
"news": "News"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"admin": "Admin Dashboard",
|
||||||
|
"settings": "Account settings"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"admin": {
|
"admin": {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
"register": {
|
"register": {
|
||||||
"confirmPasswordFormat": "Doit être pareil qu'au dessus",
|
"confirmPasswordFormat": "Doit être pareil qu'au dessus",
|
||||||
"emailFormat": "Doit être au format utilisateur{'@'}exemple.com",
|
"emailFormat": "Doit être au format utilisateur{'@'}exemple.com",
|
||||||
"passwordFormat": "Doit être au moins 14 caractères ou plus",
|
"passwordFormat": "Doit être au moins 8 caractères ou plus",
|
||||||
"subheader": "Remplissez vos coordonnées pour créer votre compte.",
|
"subheader": "Remplissez vos coordonnées pour créer votre compte.",
|
||||||
"title": "Créer votre compte Drop",
|
"title": "Créer votre compte Drop",
|
||||||
"usernameFormat": "Doit être au moins 5 caractères et en minuscules"
|
"usernameFormat": "Doit être au moins 5 caractères et en minuscules"
|
||||||
@@ -101,6 +101,7 @@
|
|||||||
"close": "Fermer",
|
"close": "Fermer",
|
||||||
"create": "Créer",
|
"create": "Créer",
|
||||||
"date": "Date",
|
"date": "Date",
|
||||||
|
"delete": "Supprimer",
|
||||||
"deleteConfirm": "Êtes vous sûr de vouloir supprimer \"{0}\" ?",
|
"deleteConfirm": "Êtes vous sûr de vouloir supprimer \"{0}\" ?",
|
||||||
"divider": "{'|'}",
|
"divider": "{'|'}",
|
||||||
"edit": "Éditer",
|
"edit": "Éditer",
|
||||||
@@ -120,7 +121,6 @@
|
|||||||
"tags": "Étiquettes",
|
"tags": "Étiquettes",
|
||||||
"today": "Aujourd'hui"
|
"today": "Aujourd'hui"
|
||||||
},
|
},
|
||||||
"delete": "Supprimer",
|
|
||||||
"drop": {
|
"drop": {
|
||||||
"desc": "Une plateforme de distribution libre conçue pour être rapide, flexible et belle.",
|
"desc": "Une plateforme de distribution libre conçue pour être rapide, flexible et belle.",
|
||||||
"drop": "Drop"
|
"drop": "Drop"
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
"register": {
|
"register": {
|
||||||
"confirmPasswordFormat": "Musi być takie samo jak powyżej",
|
"confirmPasswordFormat": "Musi być takie samo jak powyżej",
|
||||||
"emailFormat": "Musi być w formacie uzytkownik{'@'}example.com",
|
"emailFormat": "Musi być w formacie uzytkownik{'@'}example.com",
|
||||||
"passwordFormat": "Musi mieć conajmniej 14 znaków",
|
"passwordFormat": "Musi mieć conajmniej 8 znaków",
|
||||||
"subheader": "Wpisz poniżej swoje dane, aby utworzyć swoje konto.",
|
"subheader": "Wpisz poniżej swoje dane, aby utworzyć swoje konto.",
|
||||||
"title": "Stwórz swoje konto Drop",
|
"title": "Stwórz swoje konto Drop",
|
||||||
"usernameFormat": "Musi mieć co najmniej 5 znaków i małe litery"
|
"usernameFormat": "Musi mieć co najmniej 5 znaków i małe litery"
|
||||||
@@ -101,6 +101,7 @@
|
|||||||
"close": "Zamknij",
|
"close": "Zamknij",
|
||||||
"create": "Utwórz",
|
"create": "Utwórz",
|
||||||
"date": "Data",
|
"date": "Data",
|
||||||
|
"delete": "Usuń",
|
||||||
"deleteConfirm": "Czy jesteś pewny że chcesz usunąć \"{0}\"?",
|
"deleteConfirm": "Czy jesteś pewny że chcesz usunąć \"{0}\"?",
|
||||||
"divider": "{'|'}",
|
"divider": "{'|'}",
|
||||||
"edit": "Edytuj",
|
"edit": "Edytuj",
|
||||||
@@ -120,7 +121,6 @@
|
|||||||
"tags": "Tagi",
|
"tags": "Tagi",
|
||||||
"today": "Dzisiaj"
|
"today": "Dzisiaj"
|
||||||
},
|
},
|
||||||
"delete": "Usuń",
|
|
||||||
"drop": {
|
"drop": {
|
||||||
"desc": "Platforma typu open source do dystrybucji gier, stworzona z myślą o szybkości, elastyczności i estetyce.",
|
"desc": "Platforma typu open source do dystrybucji gier, stworzona z myślą o szybkości, elastyczności i estetyce.",
|
||||||
"drop": "Drop"
|
"drop": "Drop"
|
||||||
|
|||||||
@@ -79,6 +79,7 @@
|
|||||||
"close": "Закрыть",
|
"close": "Закрыть",
|
||||||
"create": "Создать",
|
"create": "Создать",
|
||||||
"date": "Дата",
|
"date": "Дата",
|
||||||
|
"delete": "Удалить",
|
||||||
"deleteConfirm": "Вы точно хотите удалить \"{0}\"?",
|
"deleteConfirm": "Вы точно хотите удалить \"{0}\"?",
|
||||||
"edit": "Редактировать",
|
"edit": "Редактировать",
|
||||||
"friends": "Друзья",
|
"friends": "Друзья",
|
||||||
@@ -94,7 +95,6 @@
|
|||||||
"tags": "Теги",
|
"tags": "Теги",
|
||||||
"today": "Сегодня"
|
"today": "Сегодня"
|
||||||
},
|
},
|
||||||
"delete": "Удалить",
|
|
||||||
"drop": {
|
"drop": {
|
||||||
"drop": "Уронить"
|
"drop": "Уронить"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,8 +9,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<LazyUserFooter class="z-50" hydrate-on-interaction />
|
<LazyUserFooter class="z-50" hydrate-on-interaction />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex w-full min-h-screen bg-zinc-900">
|
<div v-else class="flex flex-col w-full min-h-screen bg-zinc-900">
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
|
<LazyUserHeaderStoreNav />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -12,7 +12,7 @@
|
|||||||
"dev": "nuxt dev",
|
"dev": "nuxt dev",
|
||||||
"generate": "nuxt generate",
|
"generate": "nuxt generate",
|
||||||
"preview": "nuxt preview",
|
"preview": "nuxt preview",
|
||||||
"postinstall": "nuxt prepare && prisma generate",
|
"postinstall": "nuxt prepare && prisma generate && buf generate",
|
||||||
"typecheck": "nuxt typecheck",
|
"typecheck": "nuxt typecheck",
|
||||||
"lint": "pnpm run lint:eslint && pnpm run lint:prettier",
|
"lint": "pnpm run lint:eslint && pnpm run lint:prettier",
|
||||||
"lint:eslint": "eslint .",
|
"lint:eslint": "eslint .",
|
||||||
@@ -20,8 +20,8 @@
|
|||||||
"lint:fix": "eslint . --fix && prettier --write --list-different ."
|
"lint:fix": "eslint . --fix && prettier --write --list-different ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@bufbuild/protobuf": "^2.11.0",
|
||||||
"@discordapp/twemoji": "^16.0.1",
|
"@discordapp/twemoji": "^16.0.1",
|
||||||
"@drop-oss/droplet": "5.3.1",
|
|
||||||
"@headlessui/vue": "^1.7.23",
|
"@headlessui/vue": "^1.7.23",
|
||||||
"@heroicons/vue": "^2.1.5",
|
"@heroicons/vue": "^2.1.5",
|
||||||
"@lobomfz/prismark": "0.0.3",
|
"@lobomfz/prismark": "0.0.3",
|
||||||
@@ -44,8 +44,9 @@
|
|||||||
"fast-fuzzy": "^1.12.0",
|
"fast-fuzzy": "^1.12.0",
|
||||||
"file-type-mime": "^0.4.3",
|
"file-type-mime": "^0.4.3",
|
||||||
"jdenticon": "^3.3.0",
|
"jdenticon": "^3.3.0",
|
||||||
"kjua": "^0.10.0",
|
|
||||||
"jose": "^6.1.3",
|
"jose": "^6.1.3",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"kjua": "^0.10.0",
|
||||||
"luxon": "^3.6.1",
|
"luxon": "^3.6.1",
|
||||||
"micromark": "^4.0.1",
|
"micromark": "^4.0.1",
|
||||||
"normalize-url": "^8.0.2",
|
"normalize-url": "^8.0.2",
|
||||||
@@ -68,10 +69,13 @@
|
|||||||
"vuedraggable": "^4.1.0"
|
"vuedraggable": "^4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@bufbuild/buf": "^1.65.0",
|
||||||
|
"@bufbuild/protoc-gen-es": "^2.11.0",
|
||||||
"@intlify/eslint-plugin-vue-i18n": "^4.0.1",
|
"@intlify/eslint-plugin-vue-i18n": "^4.0.1",
|
||||||
"@nuxt/eslint": "^1.3.0",
|
"@nuxt/eslint": "^1.3.0",
|
||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/luxon": "^3.6.2",
|
"@types/luxon": "^3.6.2",
|
||||||
"@types/node": "^22.13.16",
|
"@types/node": "^22.13.16",
|
||||||
"@types/semver": "^7.7.0",
|
"@types/semver": "^7.7.0",
|
||||||
|
|||||||
@@ -85,7 +85,7 @@
|
|||||||
@click="deleteNotification(notification.id)"
|
@click="deleteNotification(notification.id)"
|
||||||
>
|
>
|
||||||
<TrashIcon class="size-3" />
|
<TrashIcon class="size-3" />
|
||||||
{{ $t("delete") }}
|
{{ $t("common.delete") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,13 +13,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-3">
|
<div class="ml-3">
|
||||||
<p class="text-sm text-yellow-300">
|
<p class="text-sm text-yellow-300">
|
||||||
Sign in again to access these settings.
|
{{ $t("account.security.2fa.superlevelHint.title") }}
|
||||||
{{ " " }}
|
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
href="/auth/signin?redirect=/account/security&superlevel=true"
|
href="/auth/signin?redirect=/account/security&superlevel=true"
|
||||||
class="font-medium underline text-yellow-300 hover:text-yellow-200"
|
class="font-medium underline text-yellow-300 hover:text-yellow-200"
|
||||||
>Sign in →</NuxtLink
|
|
||||||
>
|
>
|
||||||
|
<i18n-t
|
||||||
|
keypath="account.security.2fa.superlevelHint.signin"
|
||||||
|
tag="span"
|
||||||
|
scope="global"
|
||||||
|
>
|
||||||
|
<template #arrow>
|
||||||
|
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
|
</NuxtLink>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-3">
|
<div class="ml-3">
|
||||||
<p class="text-sm text-green-300">
|
<p class="text-sm text-green-300">
|
||||||
You have access to these protected actions.
|
{{ $t("account.security.2fa.superlevelHint.success") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,7 +48,7 @@
|
|||||||
<div></div>
|
<div></div>
|
||||||
<div class="mt-8 border-b border-white/10 pb-2">
|
<div class="mt-8 border-b border-white/10 pb-2">
|
||||||
<h3 class="text-base font-semibold text-white">
|
<h3 class="text-base font-semibold text-white">
|
||||||
Two-factor authentication
|
{{ $t("account.security.2fa.title") }}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex flex-wrap gap-8">
|
<div class="mt-4 flex flex-wrap gap-8">
|
||||||
@@ -67,15 +75,16 @@
|
|||||||
class="absolute inset-0"
|
class="absolute inset-0"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></span>
|
></span>
|
||||||
TOTP
|
{{ $t("account.security.2fa.totp.title") }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mt-2 text-sm text-gray-400">
|
<p class="mt-2 text-sm text-gray-400">
|
||||||
TOTP generates one-time codes, completely offline. You can use any
|
{{ $t("account.security.2fa.totp.description") }}
|
||||||
TOTP authenticator you like.
|
|
||||||
</p>
|
</p>
|
||||||
<div v-if="mfa.mecs.TOTP?.enabled" class="mt-3">
|
<div v-if="mfa.mecs.TOTP?.enabled" class="mt-3">
|
||||||
<LoadingButton :loading="false">Disable</LoadingButton>
|
<LoadingButton :loading="false">{{
|
||||||
|
$t("account.security.2fa.totp.disableButton")
|
||||||
|
}}</LoadingButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
@@ -107,20 +116,21 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-8 max-w-sm">
|
<div class="mt-8 max-w-sm">
|
||||||
<h3 class="text-base font-semibold text-white">WebAuthn</h3>
|
<h3 class="text-base font-semibold text-white">
|
||||||
|
{{ $t("account.security.2fa.webauthn.title") }}
|
||||||
|
</h3>
|
||||||
<p class="mt-2 text-sm text-gray-400">
|
<p class="mt-2 text-sm text-gray-400">
|
||||||
Otherwise known as passkeys. Authenticate using biometrics, a
|
{{ $t("account.security.2fa.webauthn.description") }}
|
||||||
device, YubiKeys, or any compatible FIDO2 device.
|
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1 text-xs font-bold text-zinc-300">
|
<p class="mt-1 text-xs font-bold text-zinc-300">
|
||||||
Also lets you bypass signing in with compatible devices.
|
{{ $t("account.security.2fa.webauthn.bypassHint") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<LoadingButton
|
<LoadingButton
|
||||||
class="mt-3"
|
class="mt-3"
|
||||||
:loading="false"
|
:loading="false"
|
||||||
@click="() => (webAuthnOpen = true)"
|
@click="() => (webAuthnOpen = true)"
|
||||||
>Manage</LoadingButton
|
>{{ $t("account.security.2fa.webauthn.manage") }}</LoadingButton
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,9 +140,11 @@
|
|||||||
<template #default>
|
<template #default>
|
||||||
<div class="sm:flex sm:items-center">
|
<div class="sm:flex sm:items-center">
|
||||||
<div class="sm:flex-auto">
|
<div class="sm:flex-auto">
|
||||||
<h1 class="text-base font-semibold text-white">WebAuthn Keys</h1>
|
<h1 class="text-base font-semibold text-white">
|
||||||
|
{{ $t("account.security.2fa.webauthn.modal.title") }}
|
||||||
|
</h1>
|
||||||
<p class="mt-2 text-sm text-gray-300">
|
<p class="mt-2 text-sm text-gray-300">
|
||||||
Create new keys or remove existing keys from your account.
|
{{ $t("account.security.2fa.webauthn.modal.description") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
||||||
@@ -140,7 +152,7 @@
|
|||||||
to="/mfa/setup/webauthn"
|
to="/mfa/setup/webauthn"
|
||||||
class="block rounded-md bg-blue-500 px-3 py-2 text-center text-sm font-semibold text-white shadow-xs hover:bg-blue-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
|
class="block rounded-md bg-blue-500 px-3 py-2 text-center text-sm font-semibold text-white shadow-xs hover:bg-blue-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
|
||||||
>
|
>
|
||||||
New key
|
{{ $t("account.security.2fa.webauthn.modal.new") }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,17 +168,19 @@
|
|||||||
scope="col"
|
scope="col"
|
||||||
class="py-3.5 pr-3 pl-4 text-left text-sm font-semibold text-white sm:pl-0"
|
class="py-3.5 pr-3 pl-4 text-left text-sm font-semibold text-white sm:pl-0"
|
||||||
>
|
>
|
||||||
Name
|
{{ $t("account.security.2fa.webauthn.modal.tableName") }}
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
class="py-3.5 pr-3 pl-4 text-left text-sm font-semibold text-white sm:pl-0"
|
class="py-3.5 pr-3 pl-4 text-left text-sm font-semibold text-white sm:pl-0"
|
||||||
>
|
>
|
||||||
Created
|
{{
|
||||||
|
$t("account.security.2fa.webauthn.modal.tableCreated")
|
||||||
|
}}
|
||||||
</th>
|
</th>
|
||||||
|
|
||||||
<th scope="col" class="py-3.5 pr-4 pl-3 sm:pr-0">
|
<th scope="col" class="py-3.5 pr-4 pl-3 sm:pr-0">
|
||||||
<span class="sr-only">Delete</span>
|
<span class="sr-only">{{ $t("common.delete") }}</span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -193,9 +207,12 @@
|
|||||||
<td
|
<td
|
||||||
class="py-4 pr-4 pl-3 text-right text-sm font-medium whitespace-nowrap sm:pr-0"
|
class="py-4 pr-4 pl-3 text-right text-sm font-medium whitespace-nowrap sm:pr-0"
|
||||||
>
|
>
|
||||||
<a href="#" class="text-blue-400 hover:text-blue-300"
|
<button
|
||||||
>Delete</a
|
class="text-blue-400 hover:text-blue-300"
|
||||||
|
@click="() => deletePasskey(mec.id)"
|
||||||
>
|
>
|
||||||
|
{{ $t("common.delete") }}
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -229,4 +246,12 @@ const superlevel = await $dropFetch("/api/v1/user/superlevel");
|
|||||||
const mfa = await $dropFetch("/api/v1/user/mfa");
|
const mfa = await $dropFetch("/api/v1/user/mfa");
|
||||||
|
|
||||||
const webAuthnOpen = ref(false);
|
const webAuthnOpen = ref(false);
|
||||||
|
|
||||||
|
async function deletePasskey(id: string) {
|
||||||
|
await $dropFetch("/api/v1/user/mfa/webauthn", {
|
||||||
|
method: "DELETE",
|
||||||
|
body: { id },
|
||||||
|
failTitle: "Failed to delete passkey",
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -173,7 +173,7 @@
|
|||||||
:title="t('home.admin.biggestGamesToDownload')"
|
:title="t('home.admin.biggestGamesToDownload')"
|
||||||
:subtitle="t('home.admin.latestVersionOnly')"
|
:subtitle="t('home.admin.latestVersionOnly')"
|
||||||
>
|
>
|
||||||
<RankingList :items="biggestGamesLatest.map(gameToRankItem)" />
|
<!-- <RankingList :items="biggestGamesLatest.map(gameToRankItem)" />-->
|
||||||
</TileWithLink>
|
</TileWithLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-6 lg:col-span-2">
|
<div class="col-span-6 lg:col-span-2">
|
||||||
@@ -181,7 +181,7 @@
|
|||||||
:title="t('home.admin.biggestGamesOnServer')"
|
:title="t('home.admin.biggestGamesOnServer')"
|
||||||
:subtitle="t('home.admin.allVersionsCombined')"
|
:subtitle="t('home.admin.allVersionsCombined')"
|
||||||
>
|
>
|
||||||
<RankingList :items="biggestGamesCombined.map(gameToRankItem)" />
|
<!-- <RankingList :items="biggestGamesCombined.map(gameToRankItem)" />-->
|
||||||
</TileWithLink>
|
</TileWithLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -196,8 +196,6 @@ import DropLogo from "~/components/DropLogo.vue";
|
|||||||
import { ServerStackIcon, UserGroupIcon } from "@heroicons/vue/24/outline";
|
import { ServerStackIcon, UserGroupIcon } from "@heroicons/vue/24/outline";
|
||||||
import { getPercentage } from "~/utils/utils";
|
import { getPercentage } from "~/utils/utils";
|
||||||
import { getBarColor } from "~/utils/colors";
|
import { getBarColor } from "~/utils/colors";
|
||||||
import type { GameSize } from "~/server/internal/gamesize";
|
|
||||||
import type { RankItem } from "~/components/RankingList.vue";
|
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: "admin",
|
layout: "admin",
|
||||||
@@ -211,20 +209,8 @@ const { t } = useI18n();
|
|||||||
|
|
||||||
const systemData = useSystemData();
|
const systemData = useSystemData();
|
||||||
|
|
||||||
const {
|
const { version, gameCount, sources, userStats } =
|
||||||
version,
|
await $dropFetch("/api/v1/admin/home");
|
||||||
gameCount,
|
|
||||||
sources,
|
|
||||||
userStats,
|
|
||||||
biggestGamesLatest,
|
|
||||||
biggestGamesCombined,
|
|
||||||
} = await $dropFetch("/api/v1/admin/home");
|
|
||||||
|
|
||||||
const gameToRankItem = (game: GameSize, rank: number): RankItem => ({
|
|
||||||
rank: rank + 1,
|
|
||||||
name: game.gameName,
|
|
||||||
value: formatBytes(game.size),
|
|
||||||
});
|
|
||||||
|
|
||||||
const pieChartData = [
|
const pieChartData = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -191,9 +191,9 @@
|
|||||||
<span v-if="launch.name" class="text-sm font-semibold">{{
|
<span v-if="launch.name" class="text-sm font-semibold">{{
|
||||||
launch.name
|
launch.name
|
||||||
}}</span>
|
}}</span>
|
||||||
<span v-else class="text-sm text-zinc-500 italic"
|
<span v-else class="text-sm text-zinc-500 italic">{{
|
||||||
>No name provided.</span
|
$t("library.admin.import.version.noNameProvided")
|
||||||
>
|
}}</span>
|
||||||
<span class="ml-auto flex h-7 items-center">
|
<span class="ml-auto flex h-7 items-center">
|
||||||
<PlusIcon v-if="!open" class="size-6" aria-hidden="true" />
|
<PlusIcon v-if="!open" class="size-6" aria-hidden="true" />
|
||||||
<MinusIcon v-else class="size-6" aria-hidden="true" />
|
<MinusIcon v-else class="size-6" aria-hidden="true" />
|
||||||
|
|||||||
@@ -115,13 +115,14 @@
|
|||||||
|
|
||||||
<div v-if="currentlySelectedGame !== -1" class="flex flex-col gap-y-4">
|
<div v-if="currentlySelectedGame !== -1" class="flex flex-col gap-y-4">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend class="text-sm/6 font-semibold text-white">Import as</legend>
|
<legend class="text-sm/6 font-semibold text-white">
|
||||||
|
{{ $t("library.admin.import.importAs") }}
|
||||||
|
</legend>
|
||||||
<div class="mt-6 grid grid-cols-1 gap-y-6 sm:grid-cols-3 sm:gap-x-4">
|
<div class="mt-6 grid grid-cols-1 gap-y-6 sm:grid-cols-3 sm:gap-x-4">
|
||||||
<label
|
<label
|
||||||
v-for="[type, meta] in Object.entries(importModes)"
|
v-for="[type, meta] in Object.entries(importModes)"
|
||||||
:key="type"
|
:key="type"
|
||||||
:aria-label="meta.title"
|
:aria-label="meta.title"
|
||||||
:aria-description="`Import as ${meta.title}`"
|
|
||||||
class="cursor-pointer group relative flex rounded-lg border border-white/10 bg-gray-800/50 p-4 has-checked:bg-blue-500/10 has-checked:outline-2 has-checked:-outline-offset-2 has-checked:outline-blue-500 has-focus-visible:outline-3 has-focus-visible:-outline-offset-1 has-disabled:bg-gray-800 has-disabled:opacity-25"
|
class="cursor-pointer group relative flex rounded-lg border border-white/10 bg-gray-800/50 p-4 has-checked:bg-blue-500/10 has-checked:outline-2 has-checked:-outline-offset-2 has-checked:outline-blue-500 has-focus-visible:outline-3 has-focus-visible:-outline-offset-1 has-disabled:bg-gray-800 has-disabled:opacity-25"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -161,7 +161,7 @@
|
|||||||
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"
|
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(game.id)"
|
||||||
>
|
>
|
||||||
{{ $t("delete") }}
|
{{ $t("common.delete") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
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"
|
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="() => deleteCompany(company.id)"
|
@click="() => deleteCompany(company.id)"
|
||||||
>
|
>
|
||||||
{{ $t("delete") }}
|
{{ $t("common.delete") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,39 +11,35 @@
|
|||||||
</i18n-t>
|
</i18n-t>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<div
|
<div v-if="task" class="flex flex-col w-full gap-y-4">
|
||||||
v-if="task && task.error"
|
|
||||||
class="grow w-full flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col items-center">
|
|
||||||
<ExclamationCircleIcon
|
|
||||||
class="h-12 w-12 text-red-600"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<div class="mt-3 text-center sm:mt-5">
|
|
||||||
<h1
|
|
||||||
class="text-3xl font-semibold font-display leading-6 text-zinc-100"
|
|
||||||
>
|
|
||||||
{{ task.error.title }}
|
|
||||||
</h1>
|
|
||||||
<div class="mt-4">
|
|
||||||
<p class="text-sm text-zinc-400 max-w-md">
|
|
||||||
{{ task.error.description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="task" class="flex flex-col w-full gap-y-4">
|
|
||||||
<h1
|
<h1
|
||||||
class="inline-flex items-center gap-x-3 text-3xl text-zinc-100 font-bold font-display"
|
class="inline-flex items-center gap-x-3 text-3xl text-zinc-100 font-bold font-display"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<CheckCircleIcon v-if="task.success" class="size-5 text-green-600" />
|
<CheckCircleIcon v-if="task.success" class="size-8 text-green-600" />
|
||||||
|
<XMarkIcon v-else-if="task.error" class="size-8 text-red-600" />
|
||||||
<div v-else class="size-4 bg-blue-600 rounded-full animate-pulse" />
|
<div v-else class="size-4 bg-blue-600 rounded-full animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
{{ task.name }}
|
{{ task.name }}
|
||||||
</h1>
|
</h1>
|
||||||
|
<div
|
||||||
|
v-if="task.error"
|
||||||
|
class="rounded-md bg-red-500/15 p-4 outline outline-red-500/25"
|
||||||
|
>
|
||||||
|
<div class="flex">
|
||||||
|
<div class="shrink-0">
|
||||||
|
<XCircleIcon class="size-5 text-red-400" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-red-200">
|
||||||
|
{{ task.error.title }}
|
||||||
|
</h3>
|
||||||
|
<div class="mt-2 text-sm text-red-200/80">
|
||||||
|
{{ task.error.description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<ul class="flex flex-row items-center h-12 gap-x-3">
|
<ul class="flex flex-row items-center h-12 gap-x-3">
|
||||||
<li
|
<li
|
||||||
v-for="[name, link] in task.actions.map((v) => v.split(':'))"
|
v-for="[name, link] in task.actions.map((v) => v.split(':'))"
|
||||||
@@ -57,7 +53,7 @@
|
|||||||
v-if="task.actions.length == 0"
|
v-if="task.actions.length == 0"
|
||||||
class="text-md uppercase font-display font-bold text-zinc-700"
|
class="text-md uppercase font-display font-bold text-zinc-700"
|
||||||
>
|
>
|
||||||
No actions
|
{{ $t("tasks.admin.noActions") }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -95,8 +91,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { CheckCircleIcon } from "@heroicons/vue/16/solid";
|
import { CheckCircleIcon } from "@heroicons/vue/24/solid";
|
||||||
import { ExclamationCircleIcon } from "@heroicons/vue/24/solid";
|
import { XMarkIcon, XCircleIcon } from "@heroicons/vue/24/outline";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const taskId = route.params.id.toString();
|
const taskId = route.params.id.toString();
|
||||||
|
|||||||
@@ -5,11 +5,10 @@
|
|||||||
<h1
|
<h1
|
||||||
class="mt-4 text-3xl font-semibold tracking-tight text-balance text-white sm:text-4xl"
|
class="mt-4 text-3xl font-semibold tracking-tight text-balance text-white sm:text-4xl"
|
||||||
>
|
>
|
||||||
Two-factor authentication
|
{{ $t("auth.2fa.title") }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-6 text-sm font-medium text-pretty text-zinc-400 sm:text-md">
|
<p class="mt-6 text-sm font-medium text-pretty text-zinc-400 sm:text-md">
|
||||||
Two-factor authentication is enabled on your account. Choose one of the
|
{{ $t("auth.2fa.description") }}
|
||||||
options below to continue.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mx-auto mt-16 flow-root max-w-lg sm:mt-20">
|
<div class="mx-auto mt-16 flow-root max-w-lg sm:mt-20">
|
||||||
@@ -18,7 +17,11 @@
|
|||||||
<NuxtLink
|
<NuxtLink
|
||||||
:href="{ path: '/auth/mfa', query: route.query }"
|
:href="{ path: '/auth/mfa', query: route.query }"
|
||||||
class="text-sm/6 font-semibold text-blue-400"
|
class="text-sm/6 font-semibold text-blue-400"
|
||||||
><span aria-hidden="true">←</span> Back to options</NuxtLink
|
><i18n-t keypath="auth.2fa.backToOptions" tag="span" scope="global">
|
||||||
|
<template #arrow>
|
||||||
|
<span aria-hidden="true">{{ $t("chars.arrowBack") }}</span>
|
||||||
|
</template>
|
||||||
|
</i18n-t></NuxtLink
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,11 +13,11 @@
|
|||||||
<h3 class="text-sm/6 font-semibold text-white">
|
<h3 class="text-sm/6 font-semibold text-white">
|
||||||
<NuxtLink :to="{ path: '/auth/mfa/totp', query: route.query }">
|
<NuxtLink :to="{ path: '/auth/mfa/totp', query: route.query }">
|
||||||
<span class="absolute inset-0" aria-hidden="true"></span>
|
<span class="absolute inset-0" aria-hidden="true"></span>
|
||||||
TOTP
|
{{ $t("auth.2fa.totp.title") }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mt-2 text-sm/6 text-zinc-400">
|
<p class="mt-2 text-sm/6 text-zinc-400">
|
||||||
Use a one-time code to sign in to your Drop account.
|
{{ $t("auth.2fa.totp.description") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-none self-center">
|
<div class="flex-none self-center">
|
||||||
@@ -34,12 +34,11 @@
|
|||||||
<h3 class="text-sm/6 font-semibold text-white">
|
<h3 class="text-sm/6 font-semibold text-white">
|
||||||
<NuxtLink :to="{ path: '/auth/mfa/webauthn', query: route.query }">
|
<NuxtLink :to="{ path: '/auth/mfa/webauthn', query: route.query }">
|
||||||
<span class="absolute inset-0" aria-hidden="true"></span>
|
<span class="absolute inset-0" aria-hidden="true"></span>
|
||||||
WebAuthn
|
{{ $t("auth.2fa.passkey.title") }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mt-2 text-sm/6 text-zinc-400">
|
<p class="mt-2 text-sm/6 text-zinc-400">
|
||||||
Use a passkey, like biometrics, a hardware security device, or other
|
{{ $t("auth.2fa.passkey.description") }}
|
||||||
compatible device to sign in to your Drop account.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-none self-center">
|
<div class="flex-none self-center">
|
||||||
|
|||||||
@@ -24,8 +24,8 @@
|
|||||||
|
|
||||||
<div v-else class="inline-flex gap-x-2">
|
<div v-else class="inline-flex gap-x-2">
|
||||||
<LoadingButton :loading="false" @click="() => tryAuthWrapper()">
|
<LoadingButton :loading="false" @click="() => tryAuthWrapper()">
|
||||||
Sign in with WebAuthn</LoadingButton
|
{{ $t("auth.2fa.passkey.signinButton") }}
|
||||||
>
|
</LoadingButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="mt-8 rounded-md bg-red-600/10 p-4">
|
<div v-if="error" class="mt-8 rounded-md bg-red-600/10 p-4">
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ const validUsername = computed(
|
|||||||
!((usernameValidator(username.value) as unknown) instanceof type.errors),
|
!((usernameValidator(username.value) as unknown) instanceof type.errors),
|
||||||
);
|
);
|
||||||
|
|
||||||
const passwordValidator = type("string >= 14");
|
const passwordValidator = type("string >= 8");
|
||||||
const validPassword = computed(
|
const validPassword = computed(
|
||||||
() =>
|
() =>
|
||||||
!((passwordValidator(password.value) as unknown) instanceof type.errors),
|
!((passwordValidator(password.value) as unknown) instanceof type.errors),
|
||||||
|
|||||||
@@ -11,14 +11,14 @@
|
|||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
superlevel
|
superlevel
|
||||||
? "Sign in to access protected action"
|
? $t("auth.signin.titleProtected")
|
||||||
: $t("auth.signin.title")
|
: $t("auth.signin.title")
|
||||||
}}
|
}}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mt-2 text-sm leading-6 text-zinc-400">
|
<p class="mt-2 text-sm leading-6 text-zinc-400">
|
||||||
{{
|
{{
|
||||||
superlevel
|
superlevel
|
||||||
? "We need you to sign in again for security reasons while attempting to access more sensitive actions."
|
? $t("auth.signin.noAccountProtected")
|
||||||
: $t("auth.signin.noAccount")
|
: $t("auth.signin.noAccount")
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -8,21 +8,26 @@ import { CheckCircleIcon } from "@heroicons/vue/24/outline";
|
|||||||
<CheckCircleIcon class="h-12 w-12 text-green-600" aria-hidden="true" />
|
<CheckCircleIcon class="h-12 w-12 text-green-600" aria-hidden="true" />
|
||||||
<div class="mt-3 text-center sm:mt-5">
|
<div class="mt-3 text-center sm:mt-5">
|
||||||
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
|
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
|
||||||
Added your 2FA method!
|
{{ $t("auth.2fa.success.title") }}
|
||||||
</h1>
|
</h1>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<p class="mx-auto text-sm text-zinc-400 max-w-sm">
|
<p class="mx-auto text-sm text-zinc-400 max-w-sm">
|
||||||
Drop has successfully created and added your 2FA method. If this is
|
{{ $t("auth.2fa.success.description") }}
|
||||||
your first time configuring 2FA, your account now requires it to
|
|
||||||
sign in.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-10 flex justify-center">
|
<div class="mt-10 flex justify-center">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
href="/account/security"
|
href="/account/security"
|
||||||
class="text-sm/6 font-semibold text-blue-400"
|
class="text-sm/6 font-semibold text-blue-400"
|
||||||
><span aria-hidden="true">←</span> Back to account
|
><i18n-t
|
||||||
security</NuxtLink
|
keypath="auth.2fa.success.back"
|
||||||
|
tag="span"
|
||||||
|
scope="global"
|
||||||
|
>
|
||||||
|
<template #arrow>
|
||||||
|
<span aria-hidden="true">{{ $t("chars.arrowBack") }}</span>
|
||||||
|
</template>
|
||||||
|
</i18n-t></NuxtLink
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,15 +7,14 @@
|
|||||||
<h1
|
<h1
|
||||||
class="mt-4 text-3xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl"
|
class="mt-4 text-3xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl"
|
||||||
>
|
>
|
||||||
Set up your authenticator
|
{{ $t("auth.2fa.totp.createTitle") }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-6 text-base leading-7 text-zinc-400">
|
<p class="mt-6 text-base leading-7 text-zinc-400">
|
||||||
Use your TOTP authenticator, like Google Authenticator, Aegis, or
|
{{ $t("auth.2fa.totp.createDescription") }}
|
||||||
Bitwarden, to add 2FA to your Drop account.
|
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<p class="text-xs leading-7 text-zinc-200">
|
<p class="text-xs leading-7 text-zinc-200">
|
||||||
Enter the generated code to enable TOTP
|
{{ $t("auth.2fa.totp.createHint") }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-2 flex flex-row gap-2">
|
<div class="mt-2 flex flex-row gap-2">
|
||||||
<CodeInput
|
<CodeInput
|
||||||
|
|||||||
@@ -7,11 +7,10 @@
|
|||||||
<h2
|
<h2
|
||||||
class="mt-10 text-center text-2xl/9 font-bold tracking-tight text-white"
|
class="mt-10 text-center text-2xl/9 font-bold tracking-tight text-white"
|
||||||
>
|
>
|
||||||
Create a passkey
|
{{ $t("auth.2fa.passkey.createTitle") }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-center text-zinc-400">
|
<p class="text-sm text-center text-zinc-400">
|
||||||
WebAuthn, or passkeys, allow you to sign in or complete 2FA with
|
{{ $t("auth.2fa.passkey.createDescription") }}
|
||||||
biometrics or hardware security devices.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -23,9 +22,9 @@
|
|||||||
@submit.prevent="attemptPasskeyWrapper"
|
@submit.prevent="attemptPasskeyWrapper"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<label for="name" class="block text-sm/6 font-medium text-gray-100"
|
<label for="name" class="block text-sm/6 font-medium text-gray-100">{{
|
||||||
>Name</label
|
$t("auth.2fa.passkey.passkeyNameTag")
|
||||||
>
|
}}</label>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<input
|
<input
|
||||||
id="name"
|
id="name"
|
||||||
@@ -41,7 +40,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<LoadingButton :disabled="disabled" :loading="loading" class="w-full">
|
<LoadingButton :disabled="disabled" :loading="loading" class="w-full">
|
||||||
Create
|
{{ $t("common.create") }}
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
<AddLibraryButton :game-id="game.id" />
|
<AddLibraryButton :game-id="game.id" />
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="user?.admin"
|
v-if="user?.admin && !isClient"
|
||||||
:href="`/admin/library/${game.id}`"
|
:href="`/admin/library/${game.id}`"
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display 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 duration-200 hover:scale-105 active:scale-95"
|
class="inline-flex items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display 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 duration-200 hover:scale-105 active:scale-95"
|
||||||
@@ -93,10 +93,26 @@
|
|||||||
{{ $t("store.size") }}
|
{{ $t("store.size") }}
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
v-if="size"
|
v-if="size.versions.length > 0"
|
||||||
class="whitespace-nowrap inline-flex gap-x-4 px-3 py-4 text-sm text-zinc-400"
|
class="whitespace-nowrap inline-flex gap-x-4 px-3 py-4 text-sm text-zinc-400"
|
||||||
>
|
>
|
||||||
{{ formatBytes(size) }}
|
<ul class="flex flex-col">
|
||||||
|
<ol
|
||||||
|
v-for="version in size.versions"
|
||||||
|
:key="version.versionId"
|
||||||
|
class="inline-flex items-center gap-x-1"
|
||||||
|
>
|
||||||
|
<ServerIcon class="size-4" />
|
||||||
|
{{
|
||||||
|
formatBytes(version.installSize)
|
||||||
|
}}
|
||||||
|
|
||||||
|
<CloudIcon class="size-4 ml-3" />
|
||||||
|
{{
|
||||||
|
formatBytes(version.downloadSize)
|
||||||
|
}}
|
||||||
|
</ol>
|
||||||
|
</ul>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
v-else
|
v-else
|
||||||
@@ -243,7 +259,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline";
|
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline";
|
||||||
import { StarIcon } from "@heroicons/vue/24/solid";
|
import { StarIcon, ServerIcon, CloudIcon } from "@heroicons/vue/24/solid";
|
||||||
import { micromark } from "micromark";
|
import { micromark } from "micromark";
|
||||||
import { formatBytes } from "~/server/internal/utils/files";
|
import { formatBytes } from "~/server/internal/utils/files";
|
||||||
|
|
||||||
@@ -254,10 +270,15 @@ const user = useUser();
|
|||||||
|
|
||||||
const { game, rating, size } = await $dropFetch(`/api/v1/games/${gameId}`);
|
const { game, rating, size } = await $dropFetch(`/api/v1/games/${gameId}`);
|
||||||
|
|
||||||
|
const isClient = isClientRequest();
|
||||||
|
|
||||||
const descriptionHTML = micromark(game.mDescription);
|
const descriptionHTML = micromark(game.mDescription);
|
||||||
|
|
||||||
const platforms = game.versions
|
const platforms = game.versions
|
||||||
.map((e) => e.launches.map((v) => v.platform))
|
.map((e) => [
|
||||||
|
...e.launches.map((v) => v.platform),
|
||||||
|
...e.setups.map((v) => v.platform),
|
||||||
|
])
|
||||||
.flat()
|
.flat()
|
||||||
.flat()
|
.flat()
|
||||||
.filter((e, i, u) => u.indexOf(e) === i);
|
.filter((e, i, u) => u.indexOf(e) === i);
|
||||||
|
|||||||
Generated
+230
-109
@@ -8,12 +8,12 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@bufbuild/protobuf':
|
||||||
|
specifier: ^2.11.0
|
||||||
|
version: 2.11.0
|
||||||
'@discordapp/twemoji':
|
'@discordapp/twemoji':
|
||||||
specifier: ^16.0.1
|
specifier: ^16.0.1
|
||||||
version: 16.0.1
|
version: 16.0.1
|
||||||
'@drop-oss/droplet':
|
|
||||||
specifier: 5.3.1
|
|
||||||
version: 5.3.1
|
|
||||||
'@headlessui/vue':
|
'@headlessui/vue':
|
||||||
specifier: ^1.7.23
|
specifier: ^1.7.23
|
||||||
version: 1.7.23(vue@3.5.27(typescript@5.8.3))
|
version: 1.7.23(vue@3.5.27(typescript@5.8.3))
|
||||||
@@ -83,6 +83,9 @@ importers:
|
|||||||
jose:
|
jose:
|
||||||
specifier: ^6.1.3
|
specifier: ^6.1.3
|
||||||
version: 6.1.3
|
version: 6.1.3
|
||||||
|
jsonwebtoken:
|
||||||
|
specifier: ^9.0.3
|
||||||
|
version: 9.0.3
|
||||||
kjua:
|
kjua:
|
||||||
specifier: ^0.10.0
|
specifier: ^0.10.0
|
||||||
version: 0.10.0
|
version: 0.10.0
|
||||||
@@ -147,6 +150,12 @@ importers:
|
|||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0(vue@3.5.27(typescript@5.8.3))
|
version: 4.1.0(vue@3.5.27(typescript@5.8.3))
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@bufbuild/buf':
|
||||||
|
specifier: ^1.65.0
|
||||||
|
version: 1.65.0
|
||||||
|
'@bufbuild/protoc-gen-es':
|
||||||
|
specifier: ^2.11.0
|
||||||
|
version: 2.11.0(@bufbuild/protobuf@2.11.0)
|
||||||
'@intlify/eslint-plugin-vue-i18n':
|
'@intlify/eslint-plugin-vue-i18n':
|
||||||
specifier: ^4.0.1
|
specifier: ^4.0.1
|
||||||
version: 4.0.1(eslint@9.31.0(jiti@2.6.1))(jsonc-eslint-parser@2.4.0)(vue-eslint-parser@10.2.0(eslint@9.31.0(jiti@2.6.1)))(yaml-eslint-parser@1.3.0)
|
version: 4.0.1(eslint@9.31.0(jiti@2.6.1))(jsonc-eslint-parser@2.4.0)(vue-eslint-parser@10.2.0(eslint@9.31.0(jiti@2.6.1)))(yaml-eslint-parser@1.3.0)
|
||||||
@@ -159,6 +168,9 @@ importers:
|
|||||||
'@tailwindcss/typography':
|
'@tailwindcss/typography':
|
||||||
specifier: ^0.5.15
|
specifier: ^0.5.15
|
||||||
version: 0.5.16(tailwindcss@4.1.11)
|
version: 0.5.16(tailwindcss@4.1.11)
|
||||||
|
'@types/jsonwebtoken':
|
||||||
|
specifier: ^9.0.10
|
||||||
|
version: 9.0.10
|
||||||
'@types/luxon':
|
'@types/luxon':
|
||||||
specifier: ^3.6.2
|
specifier: ^3.6.2
|
||||||
version: 3.6.2
|
version: 3.6.2
|
||||||
@@ -399,6 +411,69 @@ packages:
|
|||||||
resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==}
|
resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@bufbuild/buf-darwin-arm64@1.65.0':
|
||||||
|
resolution: {integrity: sha512-2U8CHjW1ysINYKwIPcc4WAiQPxe91RIjNtjpg+RC9rP0aZ7TpGm5MTMY5l3sN4drtmKdb9rBs3bMQsMNhSc90A==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@bufbuild/buf-darwin-x64@1.65.0':
|
||||||
|
resolution: {integrity: sha512-aMqfc6pQC4L9dZpSD61XCEpPWKEtb1rXDPkK0/tzrfTWodnbaJ/elNoxsCGzbZVSMFeAdomUpXmSMrk8ALfWWw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@bufbuild/buf-linux-aarch64@1.65.0':
|
||||||
|
resolution: {integrity: sha512-gzqvY4PLRQ7g0+RlE9g+OL/6yPd5szG7e3Wd5bgjJzfKaQerNiQWaGyPLdcRsIM/WxJhT5e5lG8OrrWHwgQ9Ig==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@bufbuild/buf-linux-armv7@1.65.0':
|
||||||
|
resolution: {integrity: sha512-RpYFuPr9MKniD+WNfDgCclyvMu+/w9kK41OWr9sNnbS2BorujskwPiY0iTf5j+8+n/MeAnLIGlyC36+vUB/wIw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@bufbuild/buf-linux-x64@1.65.0':
|
||||||
|
resolution: {integrity: sha512-0j06h1uKCXlOtrlNcTBkURazT+AwMNvuVxgJsYeUDnSliN05QS7LnBzPOwKg76ariSqlLo+QXk9eNtdhgVjYOg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@bufbuild/buf-win32-arm64@1.65.0':
|
||||||
|
resolution: {integrity: sha512-KBFsQ3iEityUuLTUCoXAO6ZTGUXWljSjK4upqofsYCb4OJeSeVguD7b09efkQt9ymKsXBt5wQicsRdkMJy/VEA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@bufbuild/buf-win32-x64@1.65.0':
|
||||||
|
resolution: {integrity: sha512-vJYzHjncSLdy4sPDW8kLqUldHh6Vucg6KabAflm7CDj29lU/HydV8T+nOVsXkoRMUf4+H/qy8WjnSMEtRkaogA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@bufbuild/buf@1.65.0':
|
||||||
|
resolution: {integrity: sha512-IQmIBB2CGbJAwx1NkuAWMuj4QGPnZ8mujbf4ckx9t6KI9EzfUzql1OyKi9qPrxlLAciI+kBIyPDQ2MIvXTxWUg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
'@bufbuild/protobuf@2.11.0':
|
||||||
|
resolution: {integrity: sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==}
|
||||||
|
|
||||||
|
'@bufbuild/protoc-gen-es@2.11.0':
|
||||||
|
resolution: {integrity: sha512-VzQuwEQDXipbZ1soWUuAWm1Z0C3B/IDWGeysnbX6ogJ6As91C2mdvAND/ekQ4YIWgen4d5nqLfIBOWLqCCjYUA==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@bufbuild/protobuf': 2.11.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@bufbuild/protobuf':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@bufbuild/protoplugin@2.11.0':
|
||||||
|
resolution: {integrity: sha512-lyZVNFUHArIOt4W0+dwYBe5GBwbKzbOy8ObaloEqsw9Mmiwv2O48TwddDoHN4itylC+BaEGqFdI1W8WQt2vWJQ==}
|
||||||
|
|
||||||
'@capsizecss/metrics@3.5.0':
|
'@capsizecss/metrics@3.5.0':
|
||||||
resolution: {integrity: sha512-Ju2I/Qn3c1OaU8FgeW4Tc22D4C9NwyVfKzNmzst59bvxBjPoLYNZMqFYn+HvCtn4MpXwiaDtCE8fNuQLpdi9yA==}
|
resolution: {integrity: sha512-Ju2I/Qn3c1OaU8FgeW4Tc22D4C9NwyVfKzNmzst59bvxBjPoLYNZMqFYn+HvCtn4MpXwiaDtCE8fNuQLpdi9yA==}
|
||||||
|
|
||||||
@@ -433,69 +508,6 @@ packages:
|
|||||||
'@discordapp/twemoji@16.0.1':
|
'@discordapp/twemoji@16.0.1':
|
||||||
resolution: {integrity: sha512-figLiBWzjS5cyrAjLaGjM8AAaowO3qvK8rg5bA2dElB4qsaPMvBVlFDMO2d3x+nC1igt7kgWH4dvNmvvUHUF8w==}
|
resolution: {integrity: sha512-figLiBWzjS5cyrAjLaGjM8AAaowO3qvK8rg5bA2dElB4qsaPMvBVlFDMO2d3x+nC1igt7kgWH4dvNmvvUHUF8w==}
|
||||||
|
|
||||||
'@drop-oss/droplet-darwin-arm64@5.3.1':
|
|
||||||
resolution: {integrity: sha512-+MJXRmDNH/zTinW7C/ZR8l4f9NRKDOQnc2EQF+xifbjvuXOxP3L3CVgoq1eBk0qc/0VONQUEjpNzNw+IvlOKtw==}
|
|
||||||
engines: {node: '>= 10'}
|
|
||||||
cpu: [arm64]
|
|
||||||
os: [darwin]
|
|
||||||
|
|
||||||
'@drop-oss/droplet-darwin-universal@5.3.1':
|
|
||||||
resolution: {integrity: sha512-ifvQGjoWtGfPssA9jYRb7hFjDIQSa4SUWYAdBpOWstan6hb36ak9Q7uxrMY+7cWuTayZrYOUd1O4IfRTGEojKg==}
|
|
||||||
engines: {node: '>= 10'}
|
|
||||||
os: [darwin]
|
|
||||||
|
|
||||||
'@drop-oss/droplet-darwin-x64@5.3.1':
|
|
||||||
resolution: {integrity: sha512-u9nbl/y7QpuIWEI5qurpoAdl8pUSXh8Gl2+Mu/UFXvLLqgR3UAhIsNwppTQvyBNANSJdzRRQSZzMhI45Ta2CUg==}
|
|
||||||
engines: {node: '>= 10'}
|
|
||||||
cpu: [x64]
|
|
||||||
os: [darwin]
|
|
||||||
|
|
||||||
'@drop-oss/droplet-linux-arm64-gnu@5.3.1':
|
|
||||||
resolution: {integrity: sha512-KhE+YUjMup6D42T2iclrf9pCAUqkUfK83lwlVTMo1WOd+DEY/03UuIpJe4Q2TKwCvtt10Catz8+QMmMfBqE4Ww==}
|
|
||||||
engines: {node: '>= 10'}
|
|
||||||
cpu: [arm64]
|
|
||||||
os: [linux]
|
|
||||||
|
|
||||||
'@drop-oss/droplet-linux-arm64-musl@5.3.1':
|
|
||||||
resolution: {integrity: sha512-BNNqtcM+hNOYIHKvtzn7dT1A28+uAEHz7iXJztiHYJIk4AkwDQkyngtlvbciYOE8rJRSJJodNNTgQJWYjV8oOA==}
|
|
||||||
engines: {node: '>= 10'}
|
|
||||||
cpu: [arm64]
|
|
||||||
os: [linux]
|
|
||||||
|
|
||||||
'@drop-oss/droplet-linux-riscv64-gnu@5.3.1':
|
|
||||||
resolution: {integrity: sha512-Lwf9elNVmboa6ktm4KaHsqYymOpBjpDS4W37+CG/m0u3krmPmnZDMPXHRx5f5920tG35UWsziH/DnYDAvs6QLg==}
|
|
||||||
engines: {node: '>= 10'}
|
|
||||||
cpu: [riscv64]
|
|
||||||
os: [linux]
|
|
||||||
|
|
||||||
'@drop-oss/droplet-linux-x64-gnu@5.3.1':
|
|
||||||
resolution: {integrity: sha512-NpR1i+bGSHFS7RbBz4RvGjhinUlZVg/Ne3hCzOqZ641XZgjlsdL7OD4DGeC0oYgOFpjazogAimNA7JTZi5LZcg==}
|
|
||||||
engines: {node: '>= 10'}
|
|
||||||
cpu: [x64]
|
|
||||||
os: [linux]
|
|
||||||
|
|
||||||
'@drop-oss/droplet-linux-x64-musl@5.3.1':
|
|
||||||
resolution: {integrity: sha512-LLH9U1q+rPFUTCHFNoGxmIC9YFsmYMes1F7RDvHTYoZdUGtEX3zd2AuTbQhh4XM/AbKU5Iu4N9hPxJZ/0v63SA==}
|
|
||||||
engines: {node: '>= 10'}
|
|
||||||
cpu: [x64]
|
|
||||||
os: [linux]
|
|
||||||
|
|
||||||
'@drop-oss/droplet-win32-arm64-msvc@5.3.1':
|
|
||||||
resolution: {integrity: sha512-j4fQ9/2emaxNENMha6Y4MhiSgb6iVyq7KttNIZOQeNsGxGfUdpuJOeUYJwPGPFJzBxPD7uYcWaqwMP9LdB7+vg==}
|
|
||||||
engines: {node: '>= 10'}
|
|
||||||
cpu: [arm64]
|
|
||||||
os: [win32]
|
|
||||||
|
|
||||||
'@drop-oss/droplet-win32-x64-msvc@5.3.1':
|
|
||||||
resolution: {integrity: sha512-eN0WwtZcyb83O8eRpcHG3O9NMsTMaSPrgHWNLy0RU1TDbS+Sb52qtG9jIwE/Vv1IX3Pd5prgiLETtUK0Y8c5ag==}
|
|
||||||
engines: {node: '>= 10'}
|
|
||||||
cpu: [x64]
|
|
||||||
os: [win32]
|
|
||||||
|
|
||||||
'@drop-oss/droplet@5.3.1':
|
|
||||||
resolution: {integrity: sha512-8tHYSsAk5tFGkJ9FX3S0ikHlj/rnUvedWR9OpP7KC3sRQmH1zmTA8Z9QxeMFSRiWlK57JY+mcU/qQO9LMLqJ3g==}
|
|
||||||
engines: {node: '>= 10'}
|
|
||||||
|
|
||||||
'@dxup/nuxt@0.2.2':
|
'@dxup/nuxt@0.2.2':
|
||||||
resolution: {integrity: sha512-RNpJjDZs9+JcT9N87AnOuHsNM75DEd58itADNd/s1LIF6BZbTLZV0xxilJZb55lntn4TYvscTaXLCBX2fq9CXg==}
|
resolution: {integrity: sha512-RNpJjDZs9+JcT9N87AnOuHsNM75DEd58itADNd/s1LIF6BZbTLZV0xxilJZb55lntn4TYvscTaXLCBX2fq9CXg==}
|
||||||
|
|
||||||
@@ -2459,6 +2471,9 @@ packages:
|
|||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
|
'@types/jsonwebtoken@9.0.10':
|
||||||
|
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
|
||||||
|
|
||||||
'@types/luxon@3.6.2':
|
'@types/luxon@3.6.2':
|
||||||
resolution: {integrity: sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==}
|
resolution: {integrity: sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==}
|
||||||
|
|
||||||
@@ -2589,6 +2604,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==}
|
resolution: {integrity: sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
|
'@typescript/vfs@1.6.2':
|
||||||
|
resolution: {integrity: sha512-hoBwJwcbKHmvd2QVebiytN1aELvpk9B74B4L1mFm/XT1Q/VOYAWl2vQ9AWRFtQq8zmz6enTpfTV8WRc4ATjW/g==}
|
||||||
|
peerDependencies:
|
||||||
|
typescript: '*'
|
||||||
|
|
||||||
'@unhead/vue@2.0.19':
|
'@unhead/vue@2.0.19':
|
||||||
resolution: {integrity: sha512-7BYjHfOaoZ9+ARJkT10Q2TjnTUqDXmMpfakIAsD/hXiuff1oqWg1xeXT5+MomhNcC15HbiABpbbBmITLSHxdKg==}
|
resolution: {integrity: sha512-7BYjHfOaoZ9+ARJkT10Q2TjnTUqDXmMpfakIAsD/hXiuff1oqWg1xeXT5+MomhNcC15HbiABpbbBmITLSHxdKg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -3104,6 +3124,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
|
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
|
||||||
engines: {node: '>=8.0.0'}
|
engines: {node: '>=8.0.0'}
|
||||||
|
|
||||||
|
buffer-equal-constant-time@1.0.1:
|
||||||
|
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||||
|
|
||||||
buffer-from@1.1.2:
|
buffer-from@1.1.2:
|
||||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||||
|
|
||||||
@@ -3658,6 +3681,9 @@ packages:
|
|||||||
eastasianwidth@0.2.0:
|
eastasianwidth@0.2.0:
|
||||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||||
|
|
||||||
ee-first@1.1.1:
|
ee-first@1.1.1:
|
||||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||||
|
|
||||||
@@ -4526,10 +4552,20 @@ packages:
|
|||||||
jsonfile@5.0.0:
|
jsonfile@5.0.0:
|
||||||
resolution: {integrity: sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==}
|
resolution: {integrity: sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==}
|
||||||
|
|
||||||
|
jsonwebtoken@9.0.3:
|
||||||
|
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
|
||||||
|
engines: {node: '>=12', npm: '>=6'}
|
||||||
|
|
||||||
junk@4.0.1:
|
junk@4.0.1:
|
||||||
resolution: {integrity: sha512-Qush0uP+G8ZScpGMZvHUiRfI0YBWuB3gVBYlI0v0vvOJt5FLicco+IkP0a50LqTTQhmts/m6tP5SWE+USyIvcQ==}
|
resolution: {integrity: sha512-Qush0uP+G8ZScpGMZvHUiRfI0YBWuB3gVBYlI0v0vvOJt5FLicco+IkP0a50LqTTQhmts/m6tP5SWE+USyIvcQ==}
|
||||||
engines: {node: '>=12.20'}
|
engines: {node: '>=12.20'}
|
||||||
|
|
||||||
|
jwa@2.0.1:
|
||||||
|
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
|
||||||
|
|
||||||
|
jws@4.0.1:
|
||||||
|
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||||
|
|
||||||
jwt-decode@4.0.0:
|
jwt-decode@4.0.0:
|
||||||
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
|
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -4678,18 +4714,36 @@ packages:
|
|||||||
lodash.defaults@4.2.0:
|
lodash.defaults@4.2.0:
|
||||||
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
||||||
|
|
||||||
|
lodash.includes@4.3.0:
|
||||||
|
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||||
|
|
||||||
lodash.isarguments@3.1.0:
|
lodash.isarguments@3.1.0:
|
||||||
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
|
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
|
||||||
|
|
||||||
|
lodash.isboolean@3.0.3:
|
||||||
|
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
|
||||||
|
|
||||||
|
lodash.isinteger@4.0.4:
|
||||||
|
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
|
||||||
|
|
||||||
|
lodash.isnumber@3.0.3:
|
||||||
|
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
|
||||||
|
|
||||||
lodash.isplainobject@4.0.6:
|
lodash.isplainobject@4.0.6:
|
||||||
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||||
|
|
||||||
|
lodash.isstring@4.0.1:
|
||||||
|
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
|
||||||
|
|
||||||
lodash.memoize@4.1.2:
|
lodash.memoize@4.1.2:
|
||||||
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
|
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
|
||||||
|
|
||||||
lodash.merge@4.6.2:
|
lodash.merge@4.6.2:
|
||||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||||
|
|
||||||
|
lodash.once@4.1.1:
|
||||||
|
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||||
|
|
||||||
lodash.uniq@4.5.0:
|
lodash.uniq@4.5.0:
|
||||||
resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==}
|
resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==}
|
||||||
|
|
||||||
@@ -6255,6 +6309,11 @@ packages:
|
|||||||
type-level-regexp@0.1.17:
|
type-level-regexp@0.1.17:
|
||||||
resolution: {integrity: sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg==}
|
resolution: {integrity: sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg==}
|
||||||
|
|
||||||
|
typescript@5.4.5:
|
||||||
|
resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==}
|
||||||
|
engines: {node: '>=14.17'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
typescript@5.8.3:
|
typescript@5.8.3:
|
||||||
resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==}
|
resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==}
|
||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
@@ -7125,6 +7184,55 @@ snapshots:
|
|||||||
'@babel/helper-string-parser': 7.27.1
|
'@babel/helper-string-parser': 7.27.1
|
||||||
'@babel/helper-validator-identifier': 7.28.5
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
|
|
||||||
|
'@bufbuild/buf-darwin-arm64@1.65.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@bufbuild/buf-darwin-x64@1.65.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@bufbuild/buf-linux-aarch64@1.65.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@bufbuild/buf-linux-armv7@1.65.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@bufbuild/buf-linux-x64@1.65.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@bufbuild/buf-win32-arm64@1.65.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@bufbuild/buf-win32-x64@1.65.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@bufbuild/buf@1.65.0':
|
||||||
|
optionalDependencies:
|
||||||
|
'@bufbuild/buf-darwin-arm64': 1.65.0
|
||||||
|
'@bufbuild/buf-darwin-x64': 1.65.0
|
||||||
|
'@bufbuild/buf-linux-aarch64': 1.65.0
|
||||||
|
'@bufbuild/buf-linux-armv7': 1.65.0
|
||||||
|
'@bufbuild/buf-linux-x64': 1.65.0
|
||||||
|
'@bufbuild/buf-win32-arm64': 1.65.0
|
||||||
|
'@bufbuild/buf-win32-x64': 1.65.0
|
||||||
|
|
||||||
|
'@bufbuild/protobuf@2.11.0': {}
|
||||||
|
|
||||||
|
'@bufbuild/protoc-gen-es@2.11.0(@bufbuild/protobuf@2.11.0)':
|
||||||
|
dependencies:
|
||||||
|
'@bufbuild/protoplugin': 2.11.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@bufbuild/protobuf': 2.11.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
'@bufbuild/protoplugin@2.11.0':
|
||||||
|
dependencies:
|
||||||
|
'@bufbuild/protobuf': 2.11.0
|
||||||
|
'@typescript/vfs': 1.6.2(typescript@5.4.5)
|
||||||
|
typescript: 5.4.5
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@capsizecss/metrics@3.5.0': {}
|
'@capsizecss/metrics@3.5.0': {}
|
||||||
|
|
||||||
'@capsizecss/unpack@2.4.0':
|
'@capsizecss/unpack@2.4.0':
|
||||||
@@ -7172,49 +7280,6 @@ snapshots:
|
|||||||
jsonfile: 5.0.0
|
jsonfile: 5.0.0
|
||||||
universalify: 0.1.2
|
universalify: 0.1.2
|
||||||
|
|
||||||
'@drop-oss/droplet-darwin-arm64@5.3.1':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@drop-oss/droplet-darwin-universal@5.3.1':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@drop-oss/droplet-darwin-x64@5.3.1':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@drop-oss/droplet-linux-arm64-gnu@5.3.1':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@drop-oss/droplet-linux-arm64-musl@5.3.1':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@drop-oss/droplet-linux-riscv64-gnu@5.3.1':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@drop-oss/droplet-linux-x64-gnu@5.3.1':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@drop-oss/droplet-linux-x64-musl@5.3.1':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@drop-oss/droplet-win32-arm64-msvc@5.3.1':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@drop-oss/droplet-win32-x64-msvc@5.3.1':
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@drop-oss/droplet@5.3.1':
|
|
||||||
optionalDependencies:
|
|
||||||
'@drop-oss/droplet-darwin-arm64': 5.3.1
|
|
||||||
'@drop-oss/droplet-darwin-universal': 5.3.1
|
|
||||||
'@drop-oss/droplet-darwin-x64': 5.3.1
|
|
||||||
'@drop-oss/droplet-linux-arm64-gnu': 5.3.1
|
|
||||||
'@drop-oss/droplet-linux-arm64-musl': 5.3.1
|
|
||||||
'@drop-oss/droplet-linux-riscv64-gnu': 5.3.1
|
|
||||||
'@drop-oss/droplet-linux-x64-gnu': 5.3.1
|
|
||||||
'@drop-oss/droplet-linux-x64-musl': 5.3.1
|
|
||||||
'@drop-oss/droplet-win32-arm64-msvc': 5.3.1
|
|
||||||
'@drop-oss/droplet-win32-x64-msvc': 5.3.1
|
|
||||||
|
|
||||||
'@dxup/nuxt@0.2.2(magicast@0.5.1)':
|
'@dxup/nuxt@0.2.2(magicast@0.5.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@dxup/unimport': 0.1.2
|
'@dxup/unimport': 0.1.2
|
||||||
@@ -9249,6 +9314,11 @@ snapshots:
|
|||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
|
'@types/jsonwebtoken@9.0.10':
|
||||||
|
dependencies:
|
||||||
|
'@types/ms': 2.1.0
|
||||||
|
'@types/node': 22.16.5
|
||||||
|
|
||||||
'@types/luxon@3.6.2': {}
|
'@types/luxon@3.6.2': {}
|
||||||
|
|
||||||
'@types/ms@2.1.0': {}
|
'@types/ms@2.1.0': {}
|
||||||
@@ -9422,6 +9492,13 @@ snapshots:
|
|||||||
'@typescript-eslint/types': 8.50.0
|
'@typescript-eslint/types': 8.50.0
|
||||||
eslint-visitor-keys: 4.2.1
|
eslint-visitor-keys: 4.2.1
|
||||||
|
|
||||||
|
'@typescript/vfs@1.6.2(typescript@5.4.5)':
|
||||||
|
dependencies:
|
||||||
|
debug: 4.4.1
|
||||||
|
typescript: 5.4.5
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@unhead/vue@2.0.19(vue@3.5.27(typescript@5.8.3))':
|
'@unhead/vue@2.0.19(vue@3.5.27(typescript@5.8.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
hookable: 5.5.3
|
hookable: 5.5.3
|
||||||
@@ -10028,6 +10105,8 @@ snapshots:
|
|||||||
|
|
||||||
buffer-crc32@1.0.0: {}
|
buffer-crc32@1.0.0: {}
|
||||||
|
|
||||||
|
buffer-equal-constant-time@1.0.1: {}
|
||||||
|
|
||||||
buffer-from@1.1.2: {}
|
buffer-from@1.1.2: {}
|
||||||
|
|
||||||
buffer@5.7.1:
|
buffer@5.7.1:
|
||||||
@@ -10569,6 +10648,10 @@ snapshots:
|
|||||||
|
|
||||||
eastasianwidth@0.2.0: {}
|
eastasianwidth@0.2.0: {}
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
ee-first@1.1.1: {}
|
ee-first@1.1.1: {}
|
||||||
|
|
||||||
electron-to-chromium@1.5.194: {}
|
electron-to-chromium@1.5.194: {}
|
||||||
@@ -11573,8 +11656,32 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
|
|
||||||
|
jsonwebtoken@9.0.3:
|
||||||
|
dependencies:
|
||||||
|
jws: 4.0.1
|
||||||
|
lodash.includes: 4.3.0
|
||||||
|
lodash.isboolean: 3.0.3
|
||||||
|
lodash.isinteger: 4.0.4
|
||||||
|
lodash.isnumber: 3.0.3
|
||||||
|
lodash.isplainobject: 4.0.6
|
||||||
|
lodash.isstring: 4.0.1
|
||||||
|
lodash.once: 4.1.1
|
||||||
|
ms: 2.1.3
|
||||||
|
semver: 7.7.3
|
||||||
|
|
||||||
junk@4.0.1: {}
|
junk@4.0.1: {}
|
||||||
|
|
||||||
|
jwa@2.0.1:
|
||||||
|
dependencies:
|
||||||
|
buffer-equal-constant-time: 1.0.1
|
||||||
|
ecdsa-sig-formatter: 1.0.11
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
|
jws@4.0.1:
|
||||||
|
dependencies:
|
||||||
|
jwa: 2.0.1
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
jwt-decode@4.0.0: {}
|
jwt-decode@4.0.0: {}
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
@@ -11711,14 +11818,26 @@ snapshots:
|
|||||||
|
|
||||||
lodash.defaults@4.2.0: {}
|
lodash.defaults@4.2.0: {}
|
||||||
|
|
||||||
|
lodash.includes@4.3.0: {}
|
||||||
|
|
||||||
lodash.isarguments@3.1.0: {}
|
lodash.isarguments@3.1.0: {}
|
||||||
|
|
||||||
|
lodash.isboolean@3.0.3: {}
|
||||||
|
|
||||||
|
lodash.isinteger@4.0.4: {}
|
||||||
|
|
||||||
|
lodash.isnumber@3.0.3: {}
|
||||||
|
|
||||||
lodash.isplainobject@4.0.6: {}
|
lodash.isplainobject@4.0.6: {}
|
||||||
|
|
||||||
|
lodash.isstring@4.0.1: {}
|
||||||
|
|
||||||
lodash.memoize@4.1.2: {}
|
lodash.memoize@4.1.2: {}
|
||||||
|
|
||||||
lodash.merge@4.6.2: {}
|
lodash.merge@4.6.2: {}
|
||||||
|
|
||||||
|
lodash.once@4.1.1: {}
|
||||||
|
|
||||||
lodash.uniq@4.5.0: {}
|
lodash.uniq@4.5.0: {}
|
||||||
|
|
||||||
lodash@4.17.21: {}
|
lodash@4.17.21: {}
|
||||||
@@ -13762,6 +13881,8 @@ snapshots:
|
|||||||
|
|
||||||
type-level-regexp@0.1.17: {}
|
type-level-regexp@0.1.17: {}
|
||||||
|
|
||||||
|
typescript@5.4.5: {}
|
||||||
|
|
||||||
typescript@5.8.3: {}
|
typescript@5.8.3: {}
|
||||||
|
|
||||||
ufo@1.6.1: {}
|
ufo@1.6.1: {}
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import { ArkErrors, type } from "arktype";
|
|
||||||
import prisma from "~/server/internal/db/database";
|
|
||||||
import type { H3Event } from "h3";
|
|
||||||
import { castManifest } from "~/server/internal/library/manifest";
|
|
||||||
|
|
||||||
const AUTHORIZATION_HEADER_PREFIX = "Bearer ";
|
|
||||||
|
|
||||||
const Query = type({
|
|
||||||
version: "string",
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function depotAuthorization(h3: H3Event) {
|
|
||||||
const authorization = getHeader(h3, "Authorization");
|
|
||||||
if (!authorization) throw createError({ statusCode: 403 });
|
|
||||||
|
|
||||||
if (!authorization.startsWith(AUTHORIZATION_HEADER_PREFIX))
|
|
||||||
throw createError({ statusCode: 403 });
|
|
||||||
const key = authorization.slice(AUTHORIZATION_HEADER_PREFIX.length);
|
|
||||||
|
|
||||||
const depot = await prisma.depot.findFirst({ where: { key } });
|
|
||||||
if (!depot) throw createError({ statusCode: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineEventHandler(async (h3) => {
|
|
||||||
await depotAuthorization(h3);
|
|
||||||
|
|
||||||
const query = Query(getQuery(h3));
|
|
||||||
if (query instanceof ArkErrors)
|
|
||||||
throw createError({ statusCode: 400, message: query.summary });
|
|
||||||
|
|
||||||
const version = await prisma.gameVersion.findUnique({
|
|
||||||
where: {
|
|
||||||
versionId: query.version,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
dropletManifest: true,
|
|
||||||
versionPath: true,
|
|
||||||
game: {
|
|
||||||
select: {
|
|
||||||
library: true,
|
|
||||||
libraryPath: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!version)
|
|
||||||
throw createError({ statusCode: 404, message: "Game version not found" });
|
|
||||||
|
|
||||||
return {
|
|
||||||
manifest: castManifest(version.dropletManifest),
|
|
||||||
library: version.game.library,
|
|
||||||
libraryPath: version.game.libraryPath,
|
|
||||||
versionPath: version.versionPath,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import prisma from "~/server/internal/db/database";
|
|
||||||
import { depotAuthorization } from "./manifest.get";
|
|
||||||
|
|
||||||
export default defineEventHandler(async (h3) => {
|
|
||||||
await depotAuthorization(h3);
|
|
||||||
|
|
||||||
const games = await prisma.game.findMany({
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
versions: {
|
|
||||||
select: {
|
|
||||||
versionId: true,
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
versionPath: {
|
|
||||||
not: null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return games;
|
|
||||||
});
|
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
import type { GameVersion, Prisma } from "~/prisma/client/client";
|
import type { GameVersion, Prisma } from "~/prisma/client/client";
|
||||||
import aclManager from "~/server/internal/acls";
|
import aclManager from "~/server/internal/acls";
|
||||||
import prisma from "~/server/internal/db/database";
|
import prisma from "~/server/internal/db/database";
|
||||||
|
import gameSizeManager from "~/server/internal/gamesize";
|
||||||
import type { UnimportedVersionInformation } from "~/server/internal/library";
|
import type { UnimportedVersionInformation } from "~/server/internal/library";
|
||||||
import libraryManager from "~/server/internal/library";
|
import libraryManager from "~/server/internal/library";
|
||||||
|
|
||||||
async function getGameVersionSize<
|
async function getGameVersionSize<
|
||||||
T extends Omit<GameVersion, "dropletManifest">,
|
T extends Omit<GameVersion, "dropletManifest">,
|
||||||
>(gameId: string, version: T) {
|
>(gameId: string, version: T) {
|
||||||
const size = await libraryManager.getGameVersionSize(
|
const clientSize = await gameSizeManager.getVersionSize(version.versionId);
|
||||||
gameId,
|
const diskSize = await gameSizeManager.getVersionDiskSize(version.versionId);
|
||||||
version.versionId,
|
return { ...version, diskSize, clientSize };
|
||||||
);
|
|
||||||
return { ...version, size };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AdminFetchGameType = Prisma.GameGetPayload<{
|
export type AdminFetchGameType = Prisma.GameGetPayload<{
|
||||||
|
|||||||
@@ -10,18 +10,10 @@ export default defineEventHandler(async (h3) => {
|
|||||||
|
|
||||||
const sources = await libraryManager.fetchLibraries();
|
const sources = await libraryManager.fetchLibraries();
|
||||||
const userStats = await userStatsManager.getUserStats();
|
const userStats = await userStatsManager.getUserStats();
|
||||||
|
|
||||||
const biggestGamesCombined =
|
|
||||||
await libraryManager.getBiggestGamesCombinedVersions(5);
|
|
||||||
const biggestGamesLatest =
|
|
||||||
await libraryManager.getBiggestGamesLatestVersions(5);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
gameCount: await prisma.game.count(),
|
gameCount: await prisma.game.count(),
|
||||||
version: systemConfig.getDropVersion(),
|
version: systemConfig.getDropVersion(),
|
||||||
userStats,
|
userStats,
|
||||||
sources,
|
sources,
|
||||||
biggestGamesLatest,
|
|
||||||
biggestGamesCombined,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,7 +50,12 @@ export default defineEventHandler(async (h3) => {
|
|||||||
where: {
|
where: {
|
||||||
gameId: body.id,
|
gameId: body.id,
|
||||||
delta: false,
|
delta: false,
|
||||||
launches: { some: { platform: platformObject.platform } },
|
OR: [
|
||||||
|
{ launches: { some: { platform: platformObject.platform } } },
|
||||||
|
{
|
||||||
|
setups: { some: { platform: platformObject.platform } },
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (validOverlayVersions == 0)
|
if (validOverlayVersions == 0)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default defineEventHandler<{
|
|||||||
if (!authManager.getAuthProviders().Simple)
|
if (!authManager.getAuthProviders().Simple)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
statusMessage: t("errors.auth.method.signinDisabled"),
|
message: t("errors.auth.method.signinDisabled"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const body = signinValidator(await readBody(h3));
|
const body = signinValidator(await readBody(h3));
|
||||||
@@ -33,7 +33,7 @@ export default defineEventHandler<{
|
|||||||
|
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
statusMessage: body.summary,
|
message: body.summary,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,13 +57,13 @@ export default defineEventHandler<{
|
|||||||
if (!authMek)
|
if (!authMek)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
statusMessage: t("errors.auth.invalidUserOrPass"),
|
message: t("errors.auth.invalidUserOrPass"),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!authMek.user.enabled)
|
if (!authMek.user.enabled)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
statusMessage: t("errors.auth.disabled"),
|
message: t("errors.auth.disabled"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// LEGACY bcrypt
|
// LEGACY bcrypt
|
||||||
@@ -74,13 +74,13 @@ export default defineEventHandler<{
|
|||||||
if (!hash)
|
if (!hash)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
statusMessage: t("errors.auth.invalidPassState"),
|
message: t("errors.auth.invalidPassState"),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!(await checkHashBcrypt(body.password, hash)))
|
if (!(await checkHashBcrypt(body.password, hash)))
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
statusMessage: t("errors.auth.invalidUserOrPass"),
|
message: t("errors.auth.invalidUserOrPass"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: send user to forgot password screen or something to force them to change their password to new system
|
// TODO: send user to forgot password screen or something to force them to change their password to new system
|
||||||
@@ -101,13 +101,13 @@ export default defineEventHandler<{
|
|||||||
if (!hash || typeof hash !== "string")
|
if (!hash || typeof hash !== "string")
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
statusMessage: t("errors.auth.invalidPassState"),
|
message: t("errors.auth.invalidPassState"),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!(await checkHashArgon2(body.password, hash)))
|
if (!(await checkHashArgon2(body.password, hash)))
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
statusMessage: t("errors.auth.invalidUserOrPass"),
|
message: t("errors.auth.invalidUserOrPass"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await sessionHandler.signin(h3, authMek.userId, {
|
const result = await sessionHandler.signin(h3, authMek.userId, {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const SharedRegisterValidator = type({
|
|||||||
|
|
||||||
const CreateUserValidator = SharedRegisterValidator.and({
|
const CreateUserValidator = SharedRegisterValidator.and({
|
||||||
invitation: "string",
|
invitation: "string",
|
||||||
password: "string >= 14",
|
password: "string >= 8",
|
||||||
"displayName?": "string | undefined",
|
"displayName?": "string | undefined",
|
||||||
}).configure(throwingArktype);
|
}).configure(throwingArktype);
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ export default defineEventHandler<{
|
|||||||
if (!authManager.getAuthProviders().Simple)
|
if (!authManager.getAuthProviders().Simple)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
statusMessage: t("errors.auth.method.signinDisabled"),
|
message: t("errors.auth.method.signinDisabled"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const user = await readValidatedBody(h3, CreateUserValidator);
|
const user = await readValidatedBody(h3, CreateUserValidator);
|
||||||
@@ -38,7 +38,7 @@ export default defineEventHandler<{
|
|||||||
if (!invitation)
|
if (!invitation)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
statusMessage: t("errors.auth.invalidInvite"),
|
message: t("errors.auth.invalidInvite"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// reuse items from invite
|
// reuse items from invite
|
||||||
@@ -51,7 +51,7 @@ export default defineEventHandler<{
|
|||||||
if (existing > 0)
|
if (existing > 0)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
statusMessage: t("errors.auth.usernameTaken"),
|
message: t("errors.auth.usernameTaken"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const userId = randomUUID();
|
const userId = randomUUID();
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
|
import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
|
||||||
import prisma from "~/server/internal/db/database";
|
import prisma from "~/server/internal/db/database";
|
||||||
import libraryManager from "~/server/internal/library";
|
|
||||||
|
|
||||||
export default defineClientEventHandler(async (h3) => {
|
export default defineClientEventHandler(async (h3) => {
|
||||||
const id = getRouterParam(h3, "id");
|
const id = getRouterParam(h3, "id");
|
||||||
@@ -57,8 +56,5 @@ export default defineClientEventHandler(async (h3) => {
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return gameVersionMapped;
|
||||||
...gameVersionMapped,
|
|
||||||
size: libraryManager.getGameVersionSize(id, version),
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|||||||
+12
-16
@@ -1,6 +1,7 @@
|
|||||||
import type { Platform } from "~/prisma/client/enums";
|
import type { Platform } from "~/prisma/client/enums";
|
||||||
import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
|
import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
|
||||||
import prisma from "~/server/internal/db/database";
|
import prisma from "~/server/internal/db/database";
|
||||||
|
import type { GameVersionSize } from "~/server/internal/gamesize";
|
||||||
import gameSizeManager from "~/server/internal/gamesize";
|
import gameSizeManager from "~/server/internal/gamesize";
|
||||||
|
|
||||||
type VersionDownloadOption = {
|
type VersionDownloadOption = {
|
||||||
@@ -8,24 +9,23 @@ type VersionDownloadOption = {
|
|||||||
displayName?: string | undefined;
|
displayName?: string | undefined;
|
||||||
versionPath?: string | undefined;
|
versionPath?: string | undefined;
|
||||||
platform: Platform;
|
platform: Platform;
|
||||||
size: number;
|
size: GameVersionSize;
|
||||||
requiredContent: Array<{
|
requiredContent: Array<{
|
||||||
gameId: string;
|
gameId: string;
|
||||||
versionId: string;
|
versionId: string;
|
||||||
name: string;
|
name: string;
|
||||||
iconObjectId: string;
|
iconObjectId: string;
|
||||||
shortDescription: string;
|
shortDescription: string;
|
||||||
size: number;
|
size: GameVersionSize;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defineClientEventHandler(async (h3) => {
|
export default defineClientEventHandler(async (h3) => {
|
||||||
const query = getQuery(h3);
|
const id = getRouterParam(h3, "id")!;
|
||||||
const id = query.id?.toString();
|
|
||||||
if (!id)
|
if (!id)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
statusMessage: "No ID in request query",
|
statusMessage: "No ID in router params",
|
||||||
});
|
});
|
||||||
|
|
||||||
const rawVersions = await prisma.gameVersion.findMany({
|
const rawVersions = await prisma.gameVersion.findMany({
|
||||||
@@ -62,6 +62,7 @@ export default defineClientEventHandler(async (h3) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
setups: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,11 +74,11 @@ export default defineClientEventHandler(async (h3) => {
|
|||||||
VersionDownloadOption["requiredContent"]
|
VersionDownloadOption["requiredContent"]
|
||||||
> = new Map();
|
> = new Map();
|
||||||
|
|
||||||
for (const launch of v.launches) {
|
for (const launch of [...v.launches, ...v.setups]) {
|
||||||
if (!platformOptions.has(launch.platform))
|
if (!platformOptions.has(launch.platform))
|
||||||
platformOptions.set(launch.platform, []);
|
platformOptions.set(launch.platform, []);
|
||||||
|
|
||||||
if (launch.executor) {
|
if ("executor" in launch && launch.executor) {
|
||||||
const old = platformOptions.get(launch.platform)!;
|
const old = platformOptions.get(launch.platform)!;
|
||||||
old.push({
|
old.push({
|
||||||
gameId: launch.executor.gameVersion.game.id,
|
gameId: launch.executor.gameVersion.game.id,
|
||||||
@@ -86,19 +87,14 @@ export default defineClientEventHandler(async (h3) => {
|
|||||||
iconObjectId: launch.executor.gameVersion.game.mIconObjectId,
|
iconObjectId: launch.executor.gameVersion.game.mIconObjectId,
|
||||||
shortDescription:
|
shortDescription:
|
||||||
launch.executor.gameVersion.game.mShortDescription,
|
launch.executor.gameVersion.game.mShortDescription,
|
||||||
size:
|
size: (await gameSizeManager.getVersionSize(
|
||||||
(await gameSizeManager.getGameVersionSize(
|
launch.executor.gameVersion.versionId,
|
||||||
launch.executor.gameVersion.game.id,
|
))!,
|
||||||
launch.executor.gameVersion.versionId,
|
|
||||||
)) ?? 0,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const size = await gameSizeManager.getGameVersionSize(
|
const size = await gameSizeManager.getVersionSize(v.versionId);
|
||||||
v.gameId,
|
|
||||||
v.versionId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return platformOptions
|
return platformOptions
|
||||||
.entries()
|
.entries()
|
||||||
@@ -1,29 +1,21 @@
|
|||||||
import { APITokenMode } from "~/prisma/client/enums";
|
import { APITokenMode } from "~/prisma/client/enums";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import type { UserACL } from "~/server/internal/acls";
|
|
||||||
import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
|
import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
|
||||||
import prisma from "~/server/internal/db/database";
|
import prisma from "~/server/internal/db/database";
|
||||||
|
import { CLIENT_WEBTOKEN_ACLS } from "~/server/plugins/04.auth-init";
|
||||||
|
|
||||||
export default defineClientEventHandler(
|
export default defineClientEventHandler(
|
||||||
async (h3, { fetchUser, fetchClient, clientId }) => {
|
async (h3, { fetchUser, fetchClient, clientId }) => {
|
||||||
const user = await fetchUser();
|
const user = await fetchUser();
|
||||||
const client = await fetchClient();
|
const client = await fetchClient();
|
||||||
|
|
||||||
const acls: UserACL = [
|
|
||||||
"read",
|
|
||||||
"store:read",
|
|
||||||
"collections:read",
|
|
||||||
"object:read",
|
|
||||||
"settings:read",
|
|
||||||
];
|
|
||||||
|
|
||||||
const token = await prisma.aPIToken.create({
|
const token = await prisma.aPIToken.create({
|
||||||
data: {
|
data: {
|
||||||
name: `${client.name} Web Access Token ${DateTime.now().toISO()}`,
|
name: `${client.name} Web Access Token ${DateTime.now().toISO()}`,
|
||||||
clientId,
|
clientId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
mode: APITokenMode.Client,
|
mode: APITokenMode.Client,
|
||||||
acls,
|
acls: CLIENT_WEBTOKEN_ACLS,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
|
import { type } from "arktype";
|
||||||
|
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
|
||||||
import aclManager from "~/server/internal/acls";
|
import aclManager from "~/server/internal/acls";
|
||||||
import userLibraryManager from "~/server/internal/userlibrary";
|
import userLibraryManager from "~/server/internal/userlibrary";
|
||||||
|
|
||||||
|
const CreateCollection = type({
|
||||||
|
name: "string",
|
||||||
|
}).configure(throwingArktype);
|
||||||
|
|
||||||
export default defineEventHandler(async (h3) => {
|
export default defineEventHandler(async (h3) => {
|
||||||
const userId = await aclManager.getUserIdACL(h3, ["collections:read"]);
|
const userId = await aclManager.getUserIdACL(h3, ["collections:new"]);
|
||||||
if (!userId)
|
if (!userId)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
});
|
});
|
||||||
|
|
||||||
const body = await readBody(h3);
|
const body = await readDropValidatedBody(h3, CreateCollection);
|
||||||
|
|
||||||
const name = body.name;
|
|
||||||
if (!name)
|
|
||||||
throw createError({ statusCode: 400, statusMessage: "Requires name" });
|
|
||||||
|
|
||||||
// Create the collection using the manager
|
// Create the collection using the manager
|
||||||
const newCollection = await userLibraryManager.collectionCreate(name, userId);
|
const newCollection = await userLibraryManager.collectionCreate(
|
||||||
|
body.name,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
return newCollection;
|
return newCollection;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import aclManager from "~/server/internal/acls";
|
import aclManager from "~/server/internal/acls";
|
||||||
import prisma from "~/server/internal/db/database";
|
import prisma from "~/server/internal/db/database";
|
||||||
import libraryManager from "~/server/internal/library";
|
import gameSizeManager from "~/server/internal/gamesize";
|
||||||
|
|
||||||
export default defineEventHandler(async (h3) => {
|
export default defineEventHandler(async (h3) => {
|
||||||
const userId = await aclManager.getUserIdACL(h3, ["store:read"]);
|
const userId = await aclManager.getUserIdACL(h3, ["store:read"]);
|
||||||
@@ -57,7 +57,7 @@ export default defineEventHandler(async (h3) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const size = await libraryManager.getGameVersionSize(game.id);
|
const size = (await gameSizeManager.getGameBreakdown(gameId))!;
|
||||||
|
|
||||||
return { game, rating, size };
|
return { game, rating, size };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ export default defineEventHandler(async (h3) => {
|
|||||||
if (options instanceof ArkErrors)
|
if (options instanceof ArkErrors)
|
||||||
throw createError({ statusCode: 400, statusMessage: options.summary });
|
throw createError({ statusCode: 400, statusMessage: options.summary });
|
||||||
|
|
||||||
|
const filterPlatforms = options.platform
|
||||||
|
?.split(",")
|
||||||
|
.map(parsePlatform)
|
||||||
|
.filter((e) => e !== undefined);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic filters
|
* Generic filters
|
||||||
*/
|
*/
|
||||||
@@ -46,23 +51,27 @@ export default defineEventHandler(async (h3) => {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
const platformFilter = options.platform
|
const platformFilter = filterPlatforms
|
||||||
? {
|
? ({
|
||||||
versions: {
|
versions: {
|
||||||
some: {
|
some: {
|
||||||
launches: {
|
launches: {
|
||||||
some: {
|
some: {
|
||||||
platform: {
|
platform: {
|
||||||
in: options.platform
|
in: filterPlatforms,
|
||||||
.split(",")
|
},
|
||||||
.map(parsePlatform)
|
},
|
||||||
.filter((e) => e !== undefined),
|
},
|
||||||
|
setups: {
|
||||||
|
some: {
|
||||||
|
platform: {
|
||||||
|
in: filterPlatforms,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
} satisfies Prisma.GameWhereInput)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { aclManager } from "~/server/internal/acls";
|
||||||
|
import { type } from "arktype";
|
||||||
|
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
|
||||||
|
import prisma from "~/server/internal/db/database";
|
||||||
|
import { MFAMec } from "~/prisma/client/client";
|
||||||
|
import type { WebAuthNv1Credentials } from "~/server/internal/auth/webauthn";
|
||||||
|
|
||||||
|
const WebAuthnDelete = type({
|
||||||
|
id: "string",
|
||||||
|
}).configure(throwingArktype);
|
||||||
|
|
||||||
|
export default defineEventHandler(async (h3) => {
|
||||||
|
const userId = await aclManager.allowUserSuperlevel(h3); // No ACLs only allows session authentication
|
||||||
|
if (!userId)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
message: "Not signed in or superlevelled.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await readDropValidatedBody(h3, WebAuthnDelete);
|
||||||
|
|
||||||
|
const webauthnMec = await prisma.linkedMFAMec.findUnique({
|
||||||
|
where: { userId_mec: { userId, mec: MFAMec.WebAuthn } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!webauthnMec)
|
||||||
|
throw createError({ statusCode: 400, message: "WebAuthn not enabled." });
|
||||||
|
|
||||||
|
const credentials =
|
||||||
|
webauthnMec.credentials as unknown as WebAuthNv1Credentials;
|
||||||
|
const index = credentials.passkeys.findIndex((v) => v.id === body.id);
|
||||||
|
credentials.passkeys.splice(index, 1);
|
||||||
|
|
||||||
|
// SAFETY: we request the object further up
|
||||||
|
// eslint-disable-next-line drop/no-prisma-delete
|
||||||
|
await prisma.linkedMFAMec.update({
|
||||||
|
where: {
|
||||||
|
userId_mec: {
|
||||||
|
userId,
|
||||||
|
mec: MFAMec.WebAuthn,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
// This works, I don't know why the types don't line up
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
credentials: credentials as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,10 +21,6 @@ class AuthManager {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor() {
|
|
||||||
logger.info("AuthManager initialized");
|
|
||||||
}
|
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
for (const [key, init] of Object.entries(this.initFuncs)) {
|
for (const [key, init] of Object.entries(this.initFuncs)) {
|
||||||
try {
|
try {
|
||||||
@@ -42,6 +38,8 @@ class AuthManager {
|
|||||||
if (!this.authProviders[AuthMec.OpenID]) {
|
if (!this.authProviders[AuthMec.OpenID]) {
|
||||||
this.authProviders[AuthMec.Simple] = true;
|
this.authProviders[AuthMec.Simple] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info("AuthManager initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
getAuthProviders() {
|
getAuthProviders() {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import droplet from "@drop-oss/droplet";
|
|
||||||
import type { CertificateStore } from "./ca-store";
|
import type { CertificateStore } from "./ca-store";
|
||||||
|
import { dropletInterface } from "../services/torrential/droplet-interface";
|
||||||
|
|
||||||
export type CertificateBundle = {
|
export type CertificateBundle = {
|
||||||
priv: string;
|
priv: string;
|
||||||
@@ -23,8 +23,7 @@ export class CertificateAuthority {
|
|||||||
const root = await store.fetch("ca");
|
const root = await store.fetch("ca");
|
||||||
let ca;
|
let ca;
|
||||||
if (root === undefined) {
|
if (root === undefined) {
|
||||||
const [cert, priv] = droplet.generateRootCa();
|
const bundle: CertificateBundle = await dropletInterface.generateRootCa();
|
||||||
const bundle: CertificateBundle = { priv, cert };
|
|
||||||
await store.store("ca", bundle);
|
await store.store("ca", bundle);
|
||||||
ca = new CertificateAuthority(store, bundle);
|
ca = new CertificateAuthority(store, bundle);
|
||||||
} else {
|
} else {
|
||||||
@@ -43,16 +42,13 @@ export class CertificateAuthority {
|
|||||||
const caCertificate = await this.certificateStore.fetch("ca");
|
const caCertificate = await this.certificateStore.fetch("ca");
|
||||||
if (!caCertificate)
|
if (!caCertificate)
|
||||||
throw new Error("Certificate authority not initialised");
|
throw new Error("Certificate authority not initialised");
|
||||||
const [cert, priv] = droplet.generateClientCertificate(
|
|
||||||
clientId,
|
const certBundle: CertificateBundle =
|
||||||
clientName,
|
await dropletInterface.generateClientCert(
|
||||||
caCertificate.cert,
|
clientId,
|
||||||
caCertificate.priv,
|
clientName,
|
||||||
);
|
caCertificate,
|
||||||
const certBundle: CertificateBundle = {
|
);
|
||||||
priv,
|
|
||||||
cert,
|
|
||||||
};
|
|
||||||
return certBundle;
|
return certBundle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { ClientModel, UserModel } from "~/prisma/client/models";
|
import type { ClientModel, UserModel } from "~/prisma/client/models";
|
||||||
import type { EventHandlerRequest, H3Event } from "h3";
|
import type { EventHandlerRequest, H3Event } from "h3";
|
||||||
import droplet from "@drop-oss/droplet";
|
|
||||||
import prisma from "../db/database";
|
import prisma from "../db/database";
|
||||||
import { useCertificateAuthority } from "~/server/plugins/ca";
|
import { useCertificateAuthority } from "~/server/plugins/ca";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
|
||||||
export type EventHandlerFunction<T> = (
|
export type EventHandlerFunction<T> = (
|
||||||
h3: H3Event<EventHandlerRequest>,
|
h3: H3Event<EventHandlerRequest>,
|
||||||
@@ -15,7 +15,8 @@ type ClientUtils = {
|
|||||||
fetchUser: () => Promise<UserModel>;
|
fetchUser: () => Promise<UserModel>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const NONCE_LENIENCE = 30_000;
|
// I forgot how to spell leniancne
|
||||||
|
const JWT_TIME_WIGGLE = 30_000;
|
||||||
|
|
||||||
export function defineClientEventHandler<T>(handler: EventHandlerFunction<T>) {
|
export function defineClientEventHandler<T>(handler: EventHandlerFunction<T>) {
|
||||||
return defineEventHandler(async (h3) => {
|
return defineEventHandler(async (h3) => {
|
||||||
@@ -25,39 +26,11 @@ export function defineClientEventHandler<T>(handler: EventHandlerFunction<T>) {
|
|||||||
|
|
||||||
let clientId: string;
|
let clientId: string;
|
||||||
switch (method) {
|
switch (method) {
|
||||||
case "Debug": {
|
case "JWT": {
|
||||||
if (!import.meta.dev) throw createError({ statusCode: 403 });
|
|
||||||
const client = await prisma.client.findFirst({ select: { id: true } });
|
|
||||||
if (!client)
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: "No clients created.",
|
|
||||||
});
|
|
||||||
clientId = client.id;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "Nonce": {
|
|
||||||
clientId = parts[0];
|
clientId = parts[0];
|
||||||
const nonce = parts[1];
|
const jwtToken = parts[1];
|
||||||
const signature = parts[2];
|
|
||||||
|
|
||||||
if (!clientId || !nonce || !signature)
|
if (!clientId || !jwtToken) throw createError({ statusCode: 403 });
|
||||||
throw createError({ statusCode: 403 });
|
|
||||||
|
|
||||||
const nonceTime = parseInt(nonce);
|
|
||||||
const current = Date.now();
|
|
||||||
if (
|
|
||||||
// If it "will be generated" in thirty seconds
|
|
||||||
nonceTime > current + NONCE_LENIENCE ||
|
|
||||||
// Or more than thirty seconds ago
|
|
||||||
nonceTime < current - NONCE_LENIENCE
|
|
||||||
) {
|
|
||||||
// We reject the request
|
|
||||||
throw createError({
|
|
||||||
statusCode: 403,
|
|
||||||
statusMessage: "Nonce expired",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const certificateAuthority = useCertificateAuthority();
|
const certificateAuthority = useCertificateAuthority();
|
||||||
const certBundle =
|
const certBundle =
|
||||||
@@ -66,21 +39,24 @@ export function defineClientEventHandler<T>(handler: EventHandlerFunction<T>) {
|
|||||||
if (!certBundle)
|
if (!certBundle)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
statusMessage: "Invalid client ID",
|
message: "Invalid client ID",
|
||||||
});
|
});
|
||||||
|
|
||||||
const valid = droplet.verifyNonce(certBundle.cert, nonce, signature);
|
const valid = jwt.verify(jwtToken, certBundle.cert, {
|
||||||
|
clockTolerance: JWT_TIME_WIGGLE,
|
||||||
|
// algorithms: ["ES384"],
|
||||||
|
});
|
||||||
if (!valid)
|
if (!valid)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
statusMessage: "Invalid nonce signature.",
|
message: "Invalid nonce signature.",
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
statusMessage: "No authentication",
|
message: "No authentication",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,7 +64,7 @@ export function defineClientEventHandler<T>(handler: EventHandlerFunction<T>) {
|
|||||||
if (clientId === undefined)
|
if (clientId === undefined)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
statusMessage: "Failed to execute authentication pipeline.",
|
message: "Failed to execute authentication pipeline.",
|
||||||
});
|
});
|
||||||
|
|
||||||
async function fetchClient() {
|
async function fetchClient() {
|
||||||
|
|||||||
@@ -1,228 +1,116 @@
|
|||||||
import cacheHandler from "../cache";
|
import cacheHandler from "../cache";
|
||||||
import prisma from "../db/database";
|
import prisma from "../db/database";
|
||||||
import { sum } from "../../../utils/array";
|
import { sum } from "../../../utils/array";
|
||||||
import type { Game, GameVersion } from "~/prisma/client/client";
|
import { createDownloadManifestDetails } from "../library/manifest";
|
||||||
import { castManifest } from "../library/manifest";
|
import { castManifest } from "../library/manifest/utils";
|
||||||
|
|
||||||
export type GameSize = {
|
export type GameVersionSize = {
|
||||||
gameName: string;
|
versionId: string;
|
||||||
size: number;
|
installSize: number;
|
||||||
gameId: string;
|
downloadSize: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type VersionSize = GameSize & {
|
export type GameSizeBreakdown = {
|
||||||
latest: boolean;
|
diskSize: number;
|
||||||
};
|
versions: Array<GameVersionSize & { diskSize: number; name: string }>;
|
||||||
|
|
||||||
type VersionsSizes = {
|
|
||||||
[versionName: string]: VersionSize;
|
|
||||||
};
|
|
||||||
|
|
||||||
type GameVersionsSize = {
|
|
||||||
[gameId: string]: VersionsSizes;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
class GameSizeManager {
|
class GameSizeManager {
|
||||||
private gameVersionsSizesCache =
|
private gameVersionsSizesCache =
|
||||||
cacheHandler.createCache<GameVersionsSize>("gameVersionsSizes");
|
cacheHandler.createCache<GameVersionSize>("versionSizes");
|
||||||
// All versions sizes combined
|
private gameBreakdownCache =
|
||||||
private gameSizesCache = cacheHandler.createCache<GameSize>("gameSizes");
|
cacheHandler.createCache<GameSizeBreakdown>("gameBreakdown");
|
||||||
|
|
||||||
private async clearGameVersionsSizesCache() {
|
/***
|
||||||
(await this.gameVersionsSizesCache.getKeys()).map((key) =>
|
* Gets the size of the game to the user:
|
||||||
this.gameVersionsSizesCache.remove(key),
|
* - installSize: size on disk after install
|
||||||
);
|
* - downloadSize: how many bytes are downloaded (but not necessarily stored)
|
||||||
}
|
*/
|
||||||
|
async getVersionSize(versionId: string): Promise<GameVersionSize | null> {
|
||||||
private async clearGameSizesCache() {
|
if (await this.gameVersionsSizesCache.has(versionId))
|
||||||
(await this.gameSizesCache.getKeys()).map((key) =>
|
return await this.gameVersionsSizesCache.get(versionId);
|
||||||
this.gameSizesCache.remove(key),
|
try {
|
||||||
);
|
const { downloadSize, installSize } =
|
||||||
}
|
await createDownloadManifestDetails(versionId);
|
||||||
|
const result = {
|
||||||
// All versions of a game combined
|
downloadSize,
|
||||||
async getCombinedGameSize(gameId: string) {
|
installSize,
|
||||||
const versions = await prisma.gameVersion.findMany({
|
versionId,
|
||||||
where: { gameId },
|
} satisfies GameVersionSize;
|
||||||
});
|
await this.gameVersionsSizesCache.set(versionId, result);
|
||||||
const sizes = await Promise.all(
|
return result;
|
||||||
versions.map((version) => castManifest(version.dropletManifest).size),
|
} catch {
|
||||||
);
|
return null;
|
||||||
return sum(sizes);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getGameVersionSize(
|
|
||||||
gameId: string,
|
|
||||||
versionId?: string,
|
|
||||||
): Promise<number | null> {
|
|
||||||
if (!versionId) {
|
|
||||||
const version = await prisma.gameVersion.findFirst({
|
|
||||||
where: { gameId },
|
|
||||||
orderBy: {
|
|
||||||
versionIndex: "desc",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!version) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
versionId = version.versionId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { dropletManifest } = (await prisma.gameVersion.findUnique({
|
|
||||||
where: { versionId },
|
|
||||||
}))!;
|
|
||||||
|
|
||||||
return castManifest(dropletManifest).size;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async isLatestVersion(
|
/***
|
||||||
gameVersions: GameVersion[],
|
* Get the size of the game on disk
|
||||||
version: GameVersion,
|
*/
|
||||||
): Promise<boolean> {
|
async getVersionDiskSize(versionId: string): Promise<number | null> {
|
||||||
return gameVersions.length > 0
|
const version = await prisma.gameVersion.findUnique({
|
||||||
? gameVersions[0].versionId === version.versionId
|
where: {
|
||||||
: false;
|
versionId,
|
||||||
}
|
},
|
||||||
|
select: {
|
||||||
async getBiggestGamesLatestVersion(top: number): Promise<VersionSize[]> {
|
dropletManifest: true,
|
||||||
const gameIds = await this.gameVersionsSizesCache.getKeys();
|
|
||||||
const latestGames = await Promise.all(
|
|
||||||
gameIds.map(async (gameId) => {
|
|
||||||
const versionsSizes = await this.gameVersionsSizesCache.get(gameId);
|
|
||||||
if (!versionsSizes) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const latestVersionName = Object.keys(versionsSizes).find(
|
|
||||||
(versionName) => versionsSizes[versionName].latest,
|
|
||||||
);
|
|
||||||
if (!latestVersionName) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return versionsSizes[latestVersionName] || null;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return latestGames
|
|
||||||
.filter((game) => game !== null)
|
|
||||||
.sort((gameA, gameB) => gameB.size - gameA.size)
|
|
||||||
.slice(0, top);
|
|
||||||
}
|
|
||||||
|
|
||||||
async isGameVersionsSizesCacheEmpty() {
|
|
||||||
return (await this.gameVersionsSizesCache.getKeys()).length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async isGameSizesCacheEmpty() {
|
|
||||||
return (await this.gameSizesCache.getKeys()).length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async cacheAllCombinedGames() {
|
|
||||||
await this.clearGameSizesCache();
|
|
||||||
const games = await prisma.game.findMany({ include: { versions: true } });
|
|
||||||
|
|
||||||
await Promise.all(games.map((game) => this.cacheCombinedGame(game)));
|
|
||||||
}
|
|
||||||
|
|
||||||
async cacheCombinedGame(game: Game) {
|
|
||||||
const size = await this.getCombinedGameSize(game.id);
|
|
||||||
if (!size) {
|
|
||||||
this.gameSizesCache.remove(game.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const gameSize = {
|
|
||||||
size,
|
|
||||||
gameName: game.mName,
|
|
||||||
gameId: game.id,
|
|
||||||
};
|
|
||||||
await this.gameSizesCache.set(game.id, gameSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
async cacheAllGameVersions() {
|
|
||||||
await this.clearGameVersionsSizesCache();
|
|
||||||
const games = await prisma.game.findMany({
|
|
||||||
include: {
|
|
||||||
versions: {
|
|
||||||
orderBy: {
|
|
||||||
versionIndex: "desc",
|
|
||||||
},
|
|
||||||
take: 1,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (!version) return null;
|
||||||
await Promise.all(games.map((game) => this.cacheGameVersion(game)));
|
return castManifest(version.dropletManifest).size;
|
||||||
}
|
}
|
||||||
|
|
||||||
async cacheGameVersion(
|
/**
|
||||||
game: Game & { versions: GameVersion[] },
|
* Calculate the total disk usage of a game
|
||||||
versionId?: string,
|
* @param gameId Game ID to calculate
|
||||||
) {
|
* @returns Total **disk** size of the game
|
||||||
const cacheVersion = async (version: GameVersion) => {
|
*/
|
||||||
const size = await this.getGameVersionSize(game.id, version.versionId);
|
async getGameDiskSize(gameId: string): Promise<number> {
|
||||||
if (!version.versionId || !size) {
|
const versions = await prisma.gameVersion.findMany({
|
||||||
return;
|
where: { gameId },
|
||||||
}
|
select: {
|
||||||
|
versionId: true,
|
||||||
const versionsSizes = {
|
},
|
||||||
[version.versionId]: {
|
});
|
||||||
size,
|
const sizes = await Promise.all(
|
||||||
gameName: game.mName,
|
versions.map((version) => this.getVersionDiskSize(version.versionId)),
|
||||||
gameId: game.id,
|
|
||||||
latest: await this.isLatestVersion(game.versions, version),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const allVersionsSizes =
|
|
||||||
(await this.gameVersionsSizesCache.get(game.id)) || {};
|
|
||||||
await this.gameVersionsSizesCache.set(game.id, {
|
|
||||||
...allVersionsSizes,
|
|
||||||
...versionsSizes,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (versionId) {
|
|
||||||
const version = await prisma.gameVersion.findFirst({
|
|
||||||
where: { gameId: game.id, versionId },
|
|
||||||
});
|
|
||||||
if (!version) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cacheVersion(version);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if ("versions" in game) {
|
|
||||||
await Promise.all(game.versions.map(cacheVersion));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBiggestGamesAllVersions(top: number): Promise<GameSize[]> {
|
|
||||||
const gameIds = await this.gameSizesCache.getKeys();
|
|
||||||
const allGames = await Promise.all(
|
|
||||||
gameIds.map(async (gameId) => await this.gameSizesCache.get(gameId)),
|
|
||||||
);
|
);
|
||||||
return allGames
|
return sum(sizes.filter((v) => v !== null));
|
||||||
.filter((game) => game !== null)
|
|
||||||
.sort((gameA, gameB) => gameB.size - gameA.size)
|
|
||||||
.slice(0, top);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteGameVersion(gameId: string, version: string) {
|
async getGameBreakdown(gameId: string): Promise<GameSizeBreakdown | null> {
|
||||||
const game = await prisma.game.findFirst({ where: { id: gameId } });
|
const versions = await prisma.gameVersion.findMany({
|
||||||
if (game) {
|
where: { gameId },
|
||||||
await this.cacheCombinedGame(game);
|
orderBy: { versionIndex: "desc" },
|
||||||
}
|
select: { versionId: true, displayName: true, versionPath: true },
|
||||||
const versionsSizes = await this.gameVersionsSizesCache.get(gameId);
|
});
|
||||||
if (!versionsSizes) {
|
if (!versions) return null;
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Remove the version from the VersionsSizes object
|
|
||||||
const { [version]: _, ...updatedVersionsSizes } = versionsSizes;
|
|
||||||
await this.gameVersionsSizesCache.set(gameId, updatedVersionsSizes);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteGame(gameId: string) {
|
const breakdownKey = `${gameId} ${versions.map((v) => v.versionId).join(" ")}`;
|
||||||
this.gameSizesCache.remove(gameId);
|
|
||||||
this.gameVersionsSizesCache.remove(gameId);
|
if (await this.gameBreakdownCache.has(breakdownKey))
|
||||||
|
return (await this.gameBreakdownCache.get(breakdownKey))!;
|
||||||
|
|
||||||
|
let diskSize = 0;
|
||||||
|
const versionInformation = [];
|
||||||
|
for (const version of versions) {
|
||||||
|
const size = (await this.getVersionSize(version.versionId))!;
|
||||||
|
const vDiskSize = (await this.getVersionDiskSize(version.versionId))!;
|
||||||
|
diskSize += vDiskSize;
|
||||||
|
versionInformation.push({
|
||||||
|
...size,
|
||||||
|
diskSize: vDiskSize,
|
||||||
|
name: (version.displayName ?? version.versionPath)!,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const result = {
|
||||||
|
diskSize,
|
||||||
|
versions: versionInformation,
|
||||||
|
};
|
||||||
|
await this.gameBreakdownCache.set(breakdownKey, result);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const manager = new GameSizeManager();
|
export const gameSizeManager = new GameSizeManager();
|
||||||
export default manager;
|
export default gameSizeManager;
|
||||||
|
|||||||
@@ -16,10 +16,9 @@ import type { GameModel } from "~/prisma/client/models";
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import type { WorkingLibrarySource } from "~/server/api/v1/admin/library/sources/index.get";
|
import type { WorkingLibrarySource } from "~/server/api/v1/admin/library/sources/index.get";
|
||||||
import gameSizeManager from "~/server/internal/gamesize";
|
import gameSizeManager from "~/server/internal/gamesize";
|
||||||
import { TORRENTIAL_SERVICE } from "../services/services/torrential";
|
|
||||||
import type { ImportVersion } from "~/server/api/v1/admin/import/version/index.post";
|
import type { ImportVersion } from "~/server/api/v1/admin/import/version/index.post";
|
||||||
import { GameType, type Platform } from "~/prisma/client/enums";
|
import { GameType, type Platform } from "~/prisma/client/enums";
|
||||||
import { castManifest } from "./manifest";
|
import { castManifest } from "./manifest/utils";
|
||||||
|
|
||||||
export function createGameImportTaskId(libraryId: string, libraryPath: string) {
|
export function createGameImportTaskId(libraryId: string, libraryPath: string) {
|
||||||
return createHash("md5")
|
return createHash("md5")
|
||||||
@@ -417,12 +416,10 @@ class LibraryManager {
|
|||||||
manifest = await library.generateDropletManifest(
|
manifest = await library.generateDropletManifest(
|
||||||
game.libraryPath,
|
game.libraryPath,
|
||||||
versionPath,
|
versionPath,
|
||||||
(err, value) => {
|
(value) => {
|
||||||
if (err) throw err;
|
|
||||||
progress(value * 0.9);
|
progress(value * 0.9);
|
||||||
},
|
},
|
||||||
(err, value) => {
|
(value) => {
|
||||||
if (err) throw err;
|
|
||||||
logger.info(value);
|
logger.info(value);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -500,13 +497,12 @@ class LibraryManager {
|
|||||||
acls: ["system:import:version:read"],
|
acls: ["system:import:version:read"],
|
||||||
});
|
});
|
||||||
|
|
||||||
await libraryManager.cacheCombinedGameSize(gameId);
|
// Ensure cache is filled (also pre-caches the manifest)
|
||||||
await libraryManager.cacheGameVersionSize(gameId, newVersion.versionId);
|
try {
|
||||||
|
await gameSizeManager.getVersionSize(newVersion.versionId);
|
||||||
await TORRENTIAL_SERVICE.utils().invalidate(
|
} catch (e) {
|
||||||
gameId,
|
logger.warn(`Failed to pre-cache game size and manifest: ${e}`);
|
||||||
newVersion.versionId,
|
}
|
||||||
);
|
|
||||||
|
|
||||||
if (version.type === "depot") {
|
if (version.type === "depot") {
|
||||||
// SAFETY: we can only reach this if the type is depot and identifier is valid
|
// SAFETY: we can only reach this if the type is depot and identifier is valid
|
||||||
@@ -533,18 +529,6 @@ class LibraryManager {
|
|||||||
return await library.peekFile(game, version, filename);
|
return await library.peekFile(game, version, filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
async readFile(
|
|
||||||
libraryId: string,
|
|
||||||
game: string,
|
|
||||||
version: string,
|
|
||||||
filename: string,
|
|
||||||
options?: { start?: number; end?: number },
|
|
||||||
) {
|
|
||||||
const library = this.libraries.get(libraryId);
|
|
||||||
if (!library) return undefined;
|
|
||||||
return await library.readFile(game, version, filename, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteGameVersion(gameId: string, version: string) {
|
async deleteGameVersion(gameId: string, version: string) {
|
||||||
await prisma.gameVersion.deleteMany({
|
await prisma.gameVersion.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -552,8 +536,6 @@ class LibraryManager {
|
|||||||
versionId: version,
|
versionId: version,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await gameSizeManager.deleteGameVersion(gameId, version);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteGame(gameId: string) {
|
async deleteGame(gameId: string) {
|
||||||
@@ -562,7 +544,6 @@ class LibraryManager {
|
|||||||
id: gameId,
|
id: gameId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await gameSizeManager.deleteGame(gameId);
|
|
||||||
// Delete all game versions that depended on this game
|
// Delete all game versions that depended on this game
|
||||||
await prisma.gameVersion.deleteMany({
|
await prisma.gameVersion.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -578,46 +559,6 @@ class LibraryManager {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getGameVersionSize(
|
|
||||||
gameId: string,
|
|
||||||
versionName?: string,
|
|
||||||
): Promise<number | null> {
|
|
||||||
return gameSizeManager.getGameVersionSize(gameId, versionName);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBiggestGamesCombinedVersions(top: number) {
|
|
||||||
if (await gameSizeManager.isGameSizesCacheEmpty()) {
|
|
||||||
await gameSizeManager.cacheAllCombinedGames();
|
|
||||||
}
|
|
||||||
return gameSizeManager.getBiggestGamesAllVersions(top);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBiggestGamesLatestVersions(top: number) {
|
|
||||||
if (await gameSizeManager.isGameVersionsSizesCacheEmpty()) {
|
|
||||||
await gameSizeManager.cacheAllGameVersions();
|
|
||||||
}
|
|
||||||
return gameSizeManager.getBiggestGamesLatestVersion(top);
|
|
||||||
}
|
|
||||||
|
|
||||||
async cacheCombinedGameSize(gameId: string) {
|
|
||||||
const game = await prisma.game.findFirst({ where: { id: gameId } });
|
|
||||||
if (!game) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await gameSizeManager.cacheCombinedGame(game);
|
|
||||||
}
|
|
||||||
|
|
||||||
async cacheGameVersionSize(gameId: string, versionId: string) {
|
|
||||||
const game = await prisma.game.findFirst({
|
|
||||||
where: { id: gameId },
|
|
||||||
include: { versions: true },
|
|
||||||
});
|
|
||||||
if (!game) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await gameSizeManager.cacheGameVersion(game, versionId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const libraryManager = new LibraryManager();
|
export const libraryManager = new LibraryManager();
|
||||||
|
|||||||
@@ -1,14 +1,27 @@
|
|||||||
|
import cacheHandler from "../../cache";
|
||||||
import prisma from "../../db/database";
|
import prisma from "../../db/database";
|
||||||
import { castManifest, type DropletManifest } from "../manifest";
|
import { castManifest, type DropletManifest } from "./utils";
|
||||||
|
|
||||||
export type DownloadManifestDetails = {
|
export type DownloadManifestDetails = {
|
||||||
|
/***
|
||||||
|
* Version ID to manifest
|
||||||
|
*/
|
||||||
manifests: { [key: string]: DropletManifest };
|
manifests: { [key: string]: DropletManifest };
|
||||||
|
/***
|
||||||
|
* File name to version ID
|
||||||
|
*/
|
||||||
fileList: { [key: string]: string };
|
fileList: { [key: string]: string };
|
||||||
|
/// Size on disk after download
|
||||||
|
installSize: number;
|
||||||
|
/// Size of download
|
||||||
|
downloadSize: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function convertMap<T>(map: Map<string, T>): { [key: string]: T } {
|
function convertMap<T>(map: Map<string, T>): { [key: string]: T } {
|
||||||
return Object.fromEntries(map.entries().toArray());
|
return Object.fromEntries(map.entries().toArray());
|
||||||
}
|
}
|
||||||
|
const manifestCache =
|
||||||
|
cacheHandler.createCache<DownloadManifestDetails>("manifestCache");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@@ -17,7 +30,10 @@ function convertMap<T>(map: Map<string, T>): { [key: string]: T } {
|
|||||||
*/
|
*/
|
||||||
export async function createDownloadManifestDetails(
|
export async function createDownloadManifestDetails(
|
||||||
versionId: string,
|
versionId: string,
|
||||||
|
refresh = false,
|
||||||
): Promise<DownloadManifestDetails> {
|
): Promise<DownloadManifestDetails> {
|
||||||
|
if ((await manifestCache.has(versionId)) && !refresh)
|
||||||
|
return (await manifestCache.get(versionId))!;
|
||||||
const mainVersion = await prisma.gameVersion.findUnique({
|
const mainVersion = await prisma.gameVersion.findUnique({
|
||||||
where: { versionId },
|
where: { versionId },
|
||||||
select: {
|
select: {
|
||||||
@@ -35,7 +51,7 @@ export async function createDownloadManifestDetails(
|
|||||||
|
|
||||||
const collectedVersions = [];
|
const collectedVersions = [];
|
||||||
let versionIndex = mainVersion.versionIndex;
|
let versionIndex = mainVersion.versionIndex;
|
||||||
while (true) {
|
while (mainVersion.delta) {
|
||||||
const nextVersion = await prisma.gameVersion.findFirst({
|
const nextVersion = await prisma.gameVersion.findFirst({
|
||||||
where: { gameId: mainVersion.gameId, versionIndex: { lt: versionIndex } },
|
where: { gameId: mainVersion.gameId, versionIndex: { lt: versionIndex } },
|
||||||
orderBy: {
|
orderBy: {
|
||||||
@@ -75,6 +91,9 @@ export async function createDownloadManifestDetails(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let installSize = 0;
|
||||||
|
let downloadSize = 0;
|
||||||
|
|
||||||
// Now that we have our file list, filter the manifests
|
// Now that we have our file list, filter the manifests
|
||||||
const manifests = new Map<string, DropletManifest>();
|
const manifests = new Map<string, DropletManifest>();
|
||||||
for (const version of versionOrder) {
|
for (const version of versionOrder) {
|
||||||
@@ -86,9 +105,22 @@ export async function createDownloadManifestDetails(
|
|||||||
const fileNames = Object.fromEntries(files);
|
const fileNames = Object.fromEntries(files);
|
||||||
const manifest = castManifest(version.dropletManifest);
|
const manifest = castManifest(version.dropletManifest);
|
||||||
const filteredChunks = Object.fromEntries(
|
const filteredChunks = Object.fromEntries(
|
||||||
Object.entries(manifest.chunks).filter(([, chunkData]) =>
|
Object.entries(manifest.chunks).filter(([, chunkData]) => {
|
||||||
chunkData.files.some((fileEntry) => !!fileNames[fileEntry.filename]),
|
let flag = false;
|
||||||
),
|
chunkData.files.forEach((fileEntry) => {
|
||||||
|
if (fileNames[fileEntry.filename]) {
|
||||||
|
flag = true;
|
||||||
|
installSize += fileEntry.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// If we have to download this chunk, add it's length
|
||||||
|
if (flag) {
|
||||||
|
downloadSize += chunkData.files
|
||||||
|
.map((v) => v.length)
|
||||||
|
.reduce((a, b) => a + b, 0);
|
||||||
|
}
|
||||||
|
return flag;
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
manifests.set(version.versionId, {
|
manifests.set(version.versionId, {
|
||||||
...manifest,
|
...manifest,
|
||||||
@@ -96,5 +128,13 @@ export async function createDownloadManifestDetails(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { fileList: convertMap(fileList), manifests: convertMap(manifests) };
|
const result = {
|
||||||
|
fileList: convertMap(fileList),
|
||||||
|
manifests: convertMap(manifests),
|
||||||
|
installSize,
|
||||||
|
downloadSize,
|
||||||
|
};
|
||||||
|
await manifestCache.set(versionId, result);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ export abstract class LibraryProvider<CFG> {
|
|||||||
abstract generateDropletManifest(
|
abstract generateDropletManifest(
|
||||||
game: string,
|
game: string,
|
||||||
version: string,
|
version: string,
|
||||||
progress: (err: Error | null, v: number) => void,
|
progress: (v: number) => void,
|
||||||
log: (err: Error | null, v: string) => void,
|
log: (v: string) => void,
|
||||||
): Promise<string>;
|
): Promise<string>;
|
||||||
|
|
||||||
abstract peekFile(
|
abstract peekFile(
|
||||||
@@ -54,13 +54,6 @@ export abstract class LibraryProvider<CFG> {
|
|||||||
filename: string,
|
filename: string,
|
||||||
): Promise<{ size: number } | undefined>;
|
): Promise<{ size: number } | undefined>;
|
||||||
|
|
||||||
abstract readFile(
|
|
||||||
game: string,
|
|
||||||
version: string,
|
|
||||||
filename: string,
|
|
||||||
options?: { start?: number; end?: number },
|
|
||||||
): Promise<ReadableStream | undefined>;
|
|
||||||
|
|
||||||
abstract fsStats(): { freeSpace: number; totalSpace: number } | undefined;
|
abstract fsStats(): { freeSpace: number; totalSpace: number } | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,8 @@ import {
|
|||||||
import { LibraryBackend } from "~/prisma/client/enums";
|
import { LibraryBackend } from "~/prisma/client/enums";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import droplet, {
|
|
||||||
hasBackendForPath,
|
|
||||||
listFiles,
|
|
||||||
peekFile,
|
|
||||||
readFile,
|
|
||||||
} from "@drop-oss/droplet";
|
|
||||||
import { fsStats } from "~/server/internal/utils/files";
|
import { fsStats } from "~/server/internal/utils/files";
|
||||||
|
import { dropletInterface } from "../../services/torrential/droplet-interface";
|
||||||
|
|
||||||
export const FilesystemProviderConfig = type({
|
export const FilesystemProviderConfig = type({
|
||||||
baseDir: "string",
|
baseDir: "string",
|
||||||
@@ -64,57 +59,49 @@ export class FilesystemProvider
|
|||||||
const gameDir = path.join(this.config.baseDir, game);
|
const gameDir = path.join(this.config.baseDir, game);
|
||||||
if (!fs.existsSync(gameDir)) throw new GameNotFoundError();
|
if (!fs.existsSync(gameDir)) throw new GameNotFoundError();
|
||||||
const versionDirs = fs.readdirSync(gameDir);
|
const versionDirs = fs.readdirSync(gameDir);
|
||||||
const validVersionDirs = versionDirs.filter((e) => {
|
const validVersionDirs = [];
|
||||||
if (ignoredVersions && ignoredVersions.includes(e)) return false;
|
|
||||||
const fullDir = path.join(this.config.baseDir, game, e);
|
for (const versionDir of versionDirs) {
|
||||||
return hasBackendForPath(fullDir);
|
if (ignoredVersions && ignoredVersions.includes(versionDir)) continue;
|
||||||
});
|
const fullDir = path.join(this.config.baseDir, game, versionDir);
|
||||||
|
const valid = await dropletInterface.hasBackend(fullDir);
|
||||||
|
if (!valid) continue;
|
||||||
|
|
||||||
|
validVersionDirs.push(versionDir);
|
||||||
|
}
|
||||||
|
|
||||||
return validVersionDirs;
|
return validVersionDirs;
|
||||||
}
|
}
|
||||||
|
|
||||||
async versionReaddir(game: string, version: string): Promise<string[]> {
|
async versionReaddir(game: string, version: string): Promise<string[]> {
|
||||||
const versionDir = path.join(this.config.baseDir, game, version);
|
const versionDir = path.join(this.config.baseDir, game, version);
|
||||||
if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
|
if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
|
||||||
return await listFiles(versionDir);
|
return await dropletInterface.listFiles(versionDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateDropletManifest(
|
async generateDropletManifest(
|
||||||
game: string,
|
game: string,
|
||||||
version: string,
|
version: string,
|
||||||
progress: (err: Error | null, v: number) => void,
|
progress: (v: number) => void,
|
||||||
log: (err: Error | null, v: string) => void,
|
log: (v: string) => void,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const versionDir = path.join(this.config.baseDir, game, version);
|
const versionDir = path.join(this.config.baseDir, game, version);
|
||||||
if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
|
if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
|
||||||
const manifest = await droplet.generateManifest(versionDir, progress, log);
|
const manifest = await dropletInterface.generateDropletManifest(
|
||||||
|
versionDir,
|
||||||
|
progress,
|
||||||
|
log,
|
||||||
|
);
|
||||||
return manifest;
|
return manifest;
|
||||||
}
|
}
|
||||||
|
|
||||||
async peekFile(game: string, version: string, filename: string) {
|
async peekFile(game: string, version: string, filename: string) {
|
||||||
const filepath = path.join(this.config.baseDir, game, version);
|
const filepath = path.join(this.config.baseDir, game, version);
|
||||||
if (!fs.existsSync(filepath)) return undefined;
|
if (!fs.existsSync(filepath)) return undefined;
|
||||||
const stat = await peekFile(filepath, filename);
|
const stat = await dropletInterface.peekFile(filepath, filename);
|
||||||
return { size: Number(stat) };
|
return { size: Number(stat) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async readFile(
|
|
||||||
game: string,
|
|
||||||
version: string,
|
|
||||||
filename: string,
|
|
||||||
options?: { start?: number; end?: number },
|
|
||||||
) {
|
|
||||||
const filepath = path.join(this.config.baseDir, game, version);
|
|
||||||
if (!fs.existsSync(filepath)) return undefined;
|
|
||||||
const stream = await readFile(
|
|
||||||
filepath,
|
|
||||||
filename,
|
|
||||||
options?.start ? BigInt(options.start) : undefined,
|
|
||||||
options?.end ? BigInt(options.end) : undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
return stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
fsStats() {
|
fsStats() {
|
||||||
return fsStats(this.config.baseDir);
|
return fsStats(this.config.baseDir);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,8 @@ import { VersionNotFoundError } from "../provider";
|
|||||||
import { LibraryBackend } from "~/prisma/client/enums";
|
import { LibraryBackend } from "~/prisma/client/enums";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import droplet, {
|
|
||||||
hasBackendForPath,
|
|
||||||
listFiles,
|
|
||||||
peekFile,
|
|
||||||
readFile,
|
|
||||||
} from "@drop-oss/droplet";
|
|
||||||
import { fsStats } from "~/server/internal/utils/files";
|
import { fsStats } from "~/server/internal/utils/files";
|
||||||
|
import { dropletInterface } from "../../services/torrential/droplet-interface";
|
||||||
|
|
||||||
export const FlatFilesystemProviderConfig = type({
|
export const FlatFilesystemProviderConfig = type({
|
||||||
baseDir: "string",
|
baseDir: "string",
|
||||||
@@ -50,10 +45,15 @@ export class FlatFilesystemProvider
|
|||||||
*/
|
*/
|
||||||
async listGames() {
|
async listGames() {
|
||||||
const versionDirs = fs.readdirSync(this.config.baseDir);
|
const versionDirs = fs.readdirSync(this.config.baseDir);
|
||||||
const validVersionDirs = versionDirs.filter((e) => {
|
const validVersionDirs = [];
|
||||||
const fullDir = path.join(this.config.baseDir, e);
|
|
||||||
return hasBackendForPath(fullDir);
|
for (const versionDir of versionDirs) {
|
||||||
});
|
const fullDir = path.join(this.config.baseDir, versionDir);
|
||||||
|
const valid = await dropletInterface.hasBackend(fullDir);
|
||||||
|
if (!valid) continue;
|
||||||
|
|
||||||
|
validVersionDirs.push(versionDir);
|
||||||
|
}
|
||||||
return validVersionDirs;
|
return validVersionDirs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,44 +69,31 @@ export class FlatFilesystemProvider
|
|||||||
async versionReaddir(game: string, _version: string) {
|
async versionReaddir(game: string, _version: string) {
|
||||||
const versionDir = path.join(this.config.baseDir, game);
|
const versionDir = path.join(this.config.baseDir, game);
|
||||||
if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
|
if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
|
||||||
return await listFiles(versionDir);
|
return await dropletInterface.listFiles(versionDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateDropletManifest(
|
async generateDropletManifest(
|
||||||
game: string,
|
game: string,
|
||||||
_version: string,
|
_version: string,
|
||||||
progress: (err: Error | null, v: number) => void,
|
progress: (v: number) => void,
|
||||||
log: (err: Error | null, v: string) => void,
|
log: (v: string) => void,
|
||||||
) {
|
) {
|
||||||
const versionDir = path.join(this.config.baseDir, game);
|
const versionDir = path.join(this.config.baseDir, game);
|
||||||
if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
|
if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
|
||||||
const manifest = await droplet.generateManifest(versionDir, progress, log);
|
const manifest = await dropletInterface.generateDropletManifest(
|
||||||
|
versionDir,
|
||||||
|
progress,
|
||||||
|
log,
|
||||||
|
);
|
||||||
return manifest;
|
return manifest;
|
||||||
}
|
}
|
||||||
|
|
||||||
async peekFile(game: string, _version: string, filename: string) {
|
async peekFile(game: string, _version: string, filename: string) {
|
||||||
const filepath = path.join(this.config.baseDir, game);
|
const filepath = path.join(this.config.baseDir, game);
|
||||||
if (!fs.existsSync(filepath)) return undefined;
|
if (!fs.existsSync(filepath)) return undefined;
|
||||||
const stat = await peekFile(filepath, filename);
|
const stat = await dropletInterface.peekFile(filepath, filename);
|
||||||
return { size: Number(stat) };
|
return { size: Number(stat) };
|
||||||
}
|
}
|
||||||
async readFile(
|
|
||||||
game: string,
|
|
||||||
_version: string,
|
|
||||||
filename: string,
|
|
||||||
options?: { start?: number; end?: number },
|
|
||||||
) {
|
|
||||||
const filepath = path.join(this.config.baseDir, game);
|
|
||||||
if (!fs.existsSync(filepath)) return undefined;
|
|
||||||
const stream = await readFile(
|
|
||||||
filepath,
|
|
||||||
filename,
|
|
||||||
options?.start ? BigInt(options.start) : undefined,
|
|
||||||
options?.end ? BigInt(options.end) : undefined,
|
|
||||||
);
|
|
||||||
if (!stream) return undefined;
|
|
||||||
|
|
||||||
return stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
fsStats() {
|
fsStats() {
|
||||||
return fsStats(this.config.baseDir);
|
return fsStats(this.config.baseDir);
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export class Service<T> {
|
|||||||
private setup: Setup | undefined;
|
private setup: Setup | undefined;
|
||||||
private healthcheck: Healthcheck | undefined;
|
private healthcheck: Healthcheck | undefined;
|
||||||
|
|
||||||
private logger: Logger<never>;
|
logger: Logger<never>;
|
||||||
|
|
||||||
private currentProcess: ChildProcess | undefined;
|
private currentProcess: ChildProcess | undefined;
|
||||||
|
|
||||||
@@ -90,6 +90,7 @@ export class Service<T> {
|
|||||||
if (!process.env[disableEnv]) {
|
if (!process.env[disableEnv]) {
|
||||||
const serviceProcess = this.executor();
|
const serviceProcess = this.executor();
|
||||||
this.logger.info("service launched");
|
this.logger.info("service launched");
|
||||||
|
|
||||||
serviceProcess.on("close", async (code, signal) => {
|
serviceProcess.on("close", async (code, signal) => {
|
||||||
serviceProcess.kill();
|
serviceProcess.kill();
|
||||||
this.currentProcess = undefined;
|
this.currentProcess = undefined;
|
||||||
@@ -99,12 +100,15 @@ export class Service<T> {
|
|||||||
await new Promise((r) => setTimeout(r, 5000));
|
await new Promise((r) => setTimeout(r, 5000));
|
||||||
if (this.spun) this.launch();
|
if (this.spun) this.launch();
|
||||||
});
|
});
|
||||||
|
|
||||||
serviceProcess.stdout?.on("data", (data) =>
|
serviceProcess.stdout?.on("data", (data) =>
|
||||||
this.logger.info(data.toString().trim()),
|
this.logger.info(data.toString().trim()),
|
||||||
);
|
);
|
||||||
|
|
||||||
serviceProcess.stderr?.on("data", (data) =>
|
serviceProcess.stderr?.on("data", (data) =>
|
||||||
this.logger.error(data.toString().trim()),
|
this.logger.error(data.toString().trim()),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.currentProcess = serviceProcess;
|
this.currentProcess = serviceProcess;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
import { spawn } from "child_process";
|
|
||||||
import { Service } from "..";
|
|
||||||
import fs from "fs";
|
|
||||||
import prisma from "../../db/database";
|
|
||||||
import { logger } from "../../logging";
|
|
||||||
import { systemConfig } from "../../config/sys-conf";
|
|
||||||
|
|
||||||
const INTERNAL_DEPOT_URL = new URL(
|
|
||||||
process.env.INTERNAL_DEPOT_URL ?? "http://localhost:5000",
|
|
||||||
);
|
|
||||||
|
|
||||||
export const TORRENTIAL_SERVICE = new Service(
|
|
||||||
"torrential",
|
|
||||||
() => {
|
|
||||||
const localDir = fs.readdirSync(".");
|
|
||||||
if ("torrential" in localDir) {
|
|
||||||
const stat = fs.statSync("./torrential");
|
|
||||||
if (stat.isDirectory()) {
|
|
||||||
// in dev and we have the submodule
|
|
||||||
logger.info(
|
|
||||||
"torrential detected in development mode - building from source",
|
|
||||||
);
|
|
||||||
return spawn(
|
|
||||||
"cargo run --manifest-path ./torrential/Cargo.toml",
|
|
||||||
[],
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// binary
|
|
||||||
return spawn("./torrential", [], {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const envPath = process.env.TORRENTIAL_PATH;
|
|
||||||
if (envPath) return spawn(envPath, [], {});
|
|
||||||
|
|
||||||
return spawn("torrential", [], {});
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
const externalUrl = systemConfig.getExternalUrl();
|
|
||||||
const depot = await prisma.depot.upsert({
|
|
||||||
where: {
|
|
||||||
id: "torrential",
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
endpoint: `${externalUrl}/api/v1/depot`,
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
id: "torrential",
|
|
||||||
endpoint: `${externalUrl}/api/v1/depot`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await $fetch(`${INTERNAL_DEPOT_URL.toString()}key`, {
|
|
||||||
method: "POST",
|
|
||||||
body: { key: depot.key },
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
async () => await $fetch(`${INTERNAL_DEPOT_URL.toString()}healthcheck`),
|
|
||||||
{
|
|
||||||
async invalidate(gameId: string, versionId: string) {
|
|
||||||
try {
|
|
||||||
await $fetch(`${INTERNAL_DEPOT_URL.toString()}invalidate`, {
|
|
||||||
method: "POST",
|
|
||||||
body: {
|
|
||||||
game: gameId,
|
|
||||||
version: versionId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn("invalidate torrential cache failed with error: " + e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# torrential service
|
||||||
|
|
||||||
|
The role of torrential has expanded recently to be the source of ALL Rust/native execution within Drop, to avoid using the buggy napi.rs `droplet` package.
|
||||||
|
|
||||||
|
It communicates over `127.0.0.1:33148`, which the service connects to and stores the socket handle to.
|
||||||
|
|
||||||
|
## message format
|
||||||
|
|
||||||
|
Each message is prefixed with an 8 byte little-endian unsigned integer that dictates the length of the message. Then, they are wrapped in the respective DropBound or TorrentialBound wrappers, which contain the type and data fields, which dictate which sub-message they are deserialized into.
|
||||||
|
|
||||||
|
## query processors
|
||||||
|
|
||||||
|
**Note: "Query" is the old name for a DropBound message**
|
||||||
|
|
||||||
|
The service allows you to configure a series of query processors that match based on type and recieve the raw message to deserialize themselves. They can optionally return a response message, which automatically gets returned and wrapped.
|
||||||
|
|
||||||
|
## message ids
|
||||||
|
|
||||||
|
All messages in the pipe have a message ID which dictates which "request" they're for. Queries and responses (DropBound and TorrentialBound) carry the same message ID if they are related.
|
||||||
|
|
||||||
|
## old `/api/v1/admin/depot/torrential/*` routes
|
||||||
|
|
||||||
|
They've been turned into query and response messages as described above.
|
||||||
|
|
||||||
|
# torrential service internals
|
||||||
|
|
||||||
|
We use a read buffer to queue up enough bytes that we can deserialize the entire message at once. When a chunk comes in, we append it to the current readbuf, and then check if we have enough bytes to assemble the length header and it's associated packet. If we do, we deserialize, cut off the bytes, and fire off all the necessary handlers for that packet.
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
import type { Message } from "@bufbuild/protobuf";
|
||||||
|
import { create, fromBinary } from "@bufbuild/protobuf";
|
||||||
|
import {
|
||||||
|
ClientCertQuerySchema,
|
||||||
|
ClientCertResponseSchema,
|
||||||
|
GenerateManifestSchema,
|
||||||
|
HasBackendQuerySchema,
|
||||||
|
HasBackendResponseSchema,
|
||||||
|
ListFilesQuerySchema,
|
||||||
|
ListFilesResponseSchema,
|
||||||
|
ManifestCompleteSchema,
|
||||||
|
ManifestLogSchema,
|
||||||
|
ManifestProgressSchema,
|
||||||
|
PeekFileQuerySchema,
|
||||||
|
PeekFileResponseSchema,
|
||||||
|
RootCertQuerySchema,
|
||||||
|
RootCertResponseSchema,
|
||||||
|
RpcErrorSchema,
|
||||||
|
} from "../../proto/torrential/proto/droplet_pb";
|
||||||
|
import type { QueryProcessor } from ".";
|
||||||
|
import TORRENTIAL_SERVICE from ".";
|
||||||
|
import type { DropBound } from "../../proto/torrential/proto/core_pb";
|
||||||
|
import {
|
||||||
|
DropBoundType,
|
||||||
|
TorrentialBoundType,
|
||||||
|
} from "../../proto/torrential/proto/core_pb";
|
||||||
|
import { logger } from "../../logging";
|
||||||
|
import type { CertificateBundle } from "../../clients/ca";
|
||||||
|
import type { GenMessage } from "@bufbuild/protobuf/codegenv2";
|
||||||
|
|
||||||
|
interface BaseCallbacks<T> {
|
||||||
|
resolve: (value: T) => void;
|
||||||
|
|
||||||
|
reject: (err: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManifestGenerationCallbacks = BaseCallbacks<string> & {
|
||||||
|
progress: (v: number) => void;
|
||||||
|
log: (v: string) => void;
|
||||||
|
type: "manifest";
|
||||||
|
};
|
||||||
|
|
||||||
|
type CaGenerationCallback = BaseCallbacks<CertificateBundle> & {
|
||||||
|
type: "certificate";
|
||||||
|
};
|
||||||
|
|
||||||
|
type HasBackendCallback = BaseCallbacks<boolean> & {
|
||||||
|
type: "has_backend";
|
||||||
|
};
|
||||||
|
|
||||||
|
type ListFilesCallback = BaseCallbacks<string[]> & {
|
||||||
|
type: "list_files";
|
||||||
|
};
|
||||||
|
|
||||||
|
type PeekFileCallback = BaseCallbacks<number> & {
|
||||||
|
type: "peek_file";
|
||||||
|
};
|
||||||
|
|
||||||
|
type DropletFunctionCallbacks =
|
||||||
|
| ManifestGenerationCallbacks
|
||||||
|
| CaGenerationCallback
|
||||||
|
| HasBackendCallback
|
||||||
|
| ListFilesCallback
|
||||||
|
| PeekFileCallback;
|
||||||
|
|
||||||
|
class DropletInterfaceManager {
|
||||||
|
private callbacks: Map<string, DropletFunctionCallbacks> = new Map();
|
||||||
|
|
||||||
|
private queryProcessors: QueryProcessor<
|
||||||
|
DropBoundType,
|
||||||
|
TorrentialBoundType,
|
||||||
|
Message
|
||||||
|
>[];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// This handler is special, it's a global error handler
|
||||||
|
const errorProcessor = this.defineDropletCallbackProcessor({
|
||||||
|
queryType: DropBoundType.RPC_ERROR,
|
||||||
|
run: async (message, callbacks) => {
|
||||||
|
const messageData = fromBinary(RpcErrorSchema, message.data);
|
||||||
|
callbacks.reject(messageData.error);
|
||||||
|
this.callbacks.delete(message.messageId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Other than the error handler, each "_COMPLETE" handler is responsible
|
||||||
|
// for resolving the promise, and cleaning themselves up (removing from map)
|
||||||
|
const manifestCompleteProcessor = this.defineDropletCallbackProcessor({
|
||||||
|
queryType: DropBoundType.MANIFEST_COMPLETE,
|
||||||
|
callbackType: "manifest",
|
||||||
|
run: async (message, callbacks) => {
|
||||||
|
const messageData = fromBinary(ManifestCompleteSchema, message.data);
|
||||||
|
|
||||||
|
callbacks.resolve(messageData.manifest);
|
||||||
|
this.callbacks.delete(message.messageId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const manifestLogProcessor = this.defineDropletCallbackProcessor({
|
||||||
|
queryType: DropBoundType.MANIFEST_LOG,
|
||||||
|
callbackType: "manifest",
|
||||||
|
run: async (message, callbacks) => {
|
||||||
|
const messageData = fromBinary(ManifestLogSchema, message.data);
|
||||||
|
callbacks.log(messageData.logLine);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const manifestProgressProcessor = this.defineDropletCallbackProcessor({
|
||||||
|
queryType: DropBoundType.MANIFEST_PROGRESS,
|
||||||
|
callbackType: "manifest",
|
||||||
|
run: async (message, callbacks) => {
|
||||||
|
const messageData = fromBinary(ManifestProgressSchema, message.data);
|
||||||
|
callbacks.progress(messageData.progress);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rootCaProcessor = this.defineDropletCallbackProcessor({
|
||||||
|
queryType: DropBoundType.ROOT_CA_COMPLETE,
|
||||||
|
callbackType: "certificate",
|
||||||
|
run: async (message, callbacks) => {
|
||||||
|
const messageData = fromBinary(RootCertResponseSchema, message.data);
|
||||||
|
callbacks.resolve({
|
||||||
|
priv: messageData.priv,
|
||||||
|
cert: messageData.cert,
|
||||||
|
} satisfies CertificateBundle);
|
||||||
|
this.callbacks.delete(message.messageId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const clientCertProcessor = this.defineDropletCallbackProcessor({
|
||||||
|
queryType: DropBoundType.CLIENT_CERT_COMPLETE,
|
||||||
|
callbackType: "certificate",
|
||||||
|
run: async (message, callbacks) => {
|
||||||
|
const messageData = fromBinary(ClientCertResponseSchema, message.data);
|
||||||
|
callbacks.resolve({
|
||||||
|
cert: messageData.cert,
|
||||||
|
priv: messageData.priv,
|
||||||
|
});
|
||||||
|
this.callbacks.delete(message.messageId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasBackendProcessor = this.defineDropletCallbackProcessor({
|
||||||
|
queryType: DropBoundType.HAS_BACKEND_COMPLETE,
|
||||||
|
callbackType: "has_backend",
|
||||||
|
run: async (message, callbacks) => {
|
||||||
|
const messageData = fromBinary(HasBackendResponseSchema, message.data);
|
||||||
|
callbacks.resolve(messageData.result);
|
||||||
|
this.callbacks.delete(message.messageId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const listFilesProcessor = this.defineDropletCallbackProcessor({
|
||||||
|
queryType: DropBoundType.LIST_FILES_COMPLETE,
|
||||||
|
callbackType: "list_files",
|
||||||
|
run: async (message, callbacks) => {
|
||||||
|
const messageData = fromBinary(ListFilesResponseSchema, message.data);
|
||||||
|
callbacks.resolve(messageData.files);
|
||||||
|
this.callbacks.delete(message.messageId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const peekFileProcessor = this.defineDropletCallbackProcessor({
|
||||||
|
queryType: DropBoundType.PEEK_FILE_COMPLETE,
|
||||||
|
callbackType: "peek_file",
|
||||||
|
run: async (message, callbacks) => {
|
||||||
|
const messageData = fromBinary(PeekFileResponseSchema, message.data);
|
||||||
|
callbacks.resolve(Number(messageData.size));
|
||||||
|
this.callbacks.delete(message.messageId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// All query processors go into the array to get added
|
||||||
|
this.queryProcessors = [
|
||||||
|
errorProcessor,
|
||||||
|
manifestCompleteProcessor,
|
||||||
|
manifestLogProcessor,
|
||||||
|
manifestProgressProcessor,
|
||||||
|
rootCaProcessor,
|
||||||
|
clientCertProcessor,
|
||||||
|
hasBackendProcessor,
|
||||||
|
listFilesProcessor,
|
||||||
|
peekFileProcessor,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const processor of this.queryProcessors) {
|
||||||
|
TORRENTIAL_SERVICE.registerProcessor(processor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines a handler to consume an incoming message
|
||||||
|
* from torrential
|
||||||
|
*
|
||||||
|
* Passes in the query type (DropBoundType) and callback type,
|
||||||
|
* to make sure we respond to right callback,
|
||||||
|
* and give us proper typing when it comes to the callbacks (resolve, specifically)
|
||||||
|
*
|
||||||
|
* Returns a query processor that can be registered with the service
|
||||||
|
*/
|
||||||
|
private defineDropletCallbackProcessor<
|
||||||
|
T extends DropBoundType,
|
||||||
|
K extends TorrentialBoundType,
|
||||||
|
V extends Message,
|
||||||
|
C extends DropletFunctionCallbacks,
|
||||||
|
CT extends C["type"],
|
||||||
|
>(opts: {
|
||||||
|
queryType: T;
|
||||||
|
callbackType?: CT;
|
||||||
|
run: (
|
||||||
|
query: DropBound,
|
||||||
|
callbacks: Extract<C, { type: CT }>,
|
||||||
|
) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
queryType: opts.queryType,
|
||||||
|
run: async (message) => {
|
||||||
|
const callbacks = this.callbacks.get(message.messageId);
|
||||||
|
if (!callbacks) {
|
||||||
|
logger.warn(
|
||||||
|
`got a droplet message with old message id: ${message.type}, ${message.messageId}`,
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (opts.callbackType && callbacks.type !== opts.callbackType)
|
||||||
|
return undefined;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await opts.run(message, callbacks as any);
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
} satisfies QueryProcessor<T, K, V>;
|
||||||
|
}
|
||||||
|
|
||||||
|
getProcessors() {
|
||||||
|
return this.queryProcessors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up message ID,
|
||||||
|
* sends request to torrential,
|
||||||
|
* and sets up callbacks
|
||||||
|
*/
|
||||||
|
private async createDropletFunction<
|
||||||
|
M extends Message,
|
||||||
|
K extends DropletFunctionCallbacks,
|
||||||
|
KT extends K["type"],
|
||||||
|
>(
|
||||||
|
message: M,
|
||||||
|
schema: GenMessage<M>,
|
||||||
|
messageType: TorrentialBoundType,
|
||||||
|
callbackType: KT,
|
||||||
|
): Promise<Parameters<Extract<K, { type: KT }>["resolve"]>[0]> {
|
||||||
|
const messageId = crypto.randomUUID();
|
||||||
|
|
||||||
|
await TORRENTIAL_SERVICE.writeMessage(messageId, {
|
||||||
|
type: messageType,
|
||||||
|
schema: schema,
|
||||||
|
data: message,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
this.callbacks.set(messageId, {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type: callbackType as any,
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateDropletManifest(
|
||||||
|
versionDir: string,
|
||||||
|
progress: (v: number) => void,
|
||||||
|
log: (v: string) => void,
|
||||||
|
) {
|
||||||
|
const messageId = crypto.randomUUID();
|
||||||
|
const manifestGenerationRequest = create(GenerateManifestSchema, {
|
||||||
|
versionDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
await TORRENTIAL_SERVICE.writeMessage(messageId, {
|
||||||
|
type: TorrentialBoundType.GENERATE_MANIFEST,
|
||||||
|
schema: GenerateManifestSchema,
|
||||||
|
data: manifestGenerationRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await new Promise<string>((resolve, reject) => {
|
||||||
|
this.callbacks.set(messageId, {
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
progress,
|
||||||
|
log,
|
||||||
|
type: "manifest",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateRootCa() {
|
||||||
|
return await this.createDropletFunction(
|
||||||
|
create(RootCertQuerySchema, {}),
|
||||||
|
RootCertQuerySchema,
|
||||||
|
TorrentialBoundType.GENERATE_ROOT_CA,
|
||||||
|
"certificate",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateClientCert(
|
||||||
|
clientId: string,
|
||||||
|
clientName: string,
|
||||||
|
rootCa: CertificateBundle,
|
||||||
|
) {
|
||||||
|
return await this.createDropletFunction(
|
||||||
|
create(ClientCertQuerySchema, {
|
||||||
|
clientId,
|
||||||
|
clientName,
|
||||||
|
rootPriv: rootCa.priv,
|
||||||
|
rootCert: rootCa.cert,
|
||||||
|
}),
|
||||||
|
ClientCertQuerySchema,
|
||||||
|
TorrentialBoundType.GENERATE_CLIENT_CERT,
|
||||||
|
"certificate",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasBackend(path: string) {
|
||||||
|
return await this.createDropletFunction(
|
||||||
|
create(HasBackendQuerySchema, {
|
||||||
|
path,
|
||||||
|
}),
|
||||||
|
HasBackendQuerySchema,
|
||||||
|
TorrentialBoundType.HAS_BACKEND_QUERY,
|
||||||
|
"has_backend",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listFiles(path: string) {
|
||||||
|
return await this.createDropletFunction(
|
||||||
|
create(ListFilesQuerySchema, {
|
||||||
|
path,
|
||||||
|
}),
|
||||||
|
ListFilesQuerySchema,
|
||||||
|
TorrentialBoundType.LIST_FILES_QUERY,
|
||||||
|
"list_files",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async peekFile(path: string, subpath: string) {
|
||||||
|
return await this.createDropletFunction(
|
||||||
|
create(PeekFileQuerySchema, {
|
||||||
|
path: path,
|
||||||
|
filename: subpath,
|
||||||
|
}),
|
||||||
|
PeekFileQuerySchema,
|
||||||
|
TorrentialBoundType.PEEK_FILE_QUERY,
|
||||||
|
"peek_file",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dropletInterface = new DropletInterfaceManager();
|
||||||
|
export default dropletInterface;
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { spawn } from "child_process";
|
||||||
|
import { Service } from "..";
|
||||||
|
import fs from "fs";
|
||||||
|
import { logger } from "../../logging";
|
||||||
|
import type { Socket } from "net";
|
||||||
|
import net from "net";
|
||||||
|
import { create, toBinary, type Message } from "@bufbuild/protobuf";
|
||||||
|
import { fromBinary } from "@bufbuild/protobuf";
|
||||||
|
import { StringValueSchema } from "@bufbuild/protobuf/wkt";
|
||||||
|
import type { GenMessage } from "@bufbuild/protobuf/codegenv2";
|
||||||
|
import {
|
||||||
|
DropBoundSchema,
|
||||||
|
TorrentialBoundSchema,
|
||||||
|
TorrentialBoundType,
|
||||||
|
type DropBound,
|
||||||
|
type DropBoundType,
|
||||||
|
} from "../../proto/torrential/proto/core_pb";
|
||||||
|
|
||||||
|
/// Processors
|
||||||
|
import manifestFetchProcessor from "./manifest-fetch";
|
||||||
|
import serverGamesProcessor from "./server-games";
|
||||||
|
|
||||||
|
const INTERNAL_DEPOT_URL = new URL(
|
||||||
|
process.env.INTERNAL_DEPOT_URL ?? "http://localhost:5000",
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface QueryProcessor<
|
||||||
|
T extends DropBoundType,
|
||||||
|
K extends TorrentialBoundType,
|
||||||
|
V extends Message,
|
||||||
|
> {
|
||||||
|
queryType: T;
|
||||||
|
run: (
|
||||||
|
query: DropBound,
|
||||||
|
) => Promise<{ type: K; schema: GenMessage<V>; data: V } | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TorrentialService extends Service<unknown> {
|
||||||
|
private socket: Socket | undefined;
|
||||||
|
private readbuf: Buffer<ArrayBufferLike> = Buffer.alloc(0);
|
||||||
|
private readingQueue = false;
|
||||||
|
|
||||||
|
private queryProcessors: Map<
|
||||||
|
DropBoundType,
|
||||||
|
QueryProcessor<DropBoundType, TorrentialBoundType, Message>
|
||||||
|
> = new Map();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
"torrential",
|
||||||
|
() => {
|
||||||
|
const localDir = fs.readdirSync(".");
|
||||||
|
if ("torrential" in localDir) {
|
||||||
|
const stat = fs.statSync("./torrential");
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
// in dev and we have the submodule
|
||||||
|
logger.info(
|
||||||
|
"torrential detected in development mode - building from source",
|
||||||
|
);
|
||||||
|
return spawn(
|
||||||
|
"cargo run --manifest-path ./torrential/Cargo.toml",
|
||||||
|
[],
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// binary
|
||||||
|
return spawn("./torrential", [], {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const envPath = process.env.TORRENTIAL_PATH;
|
||||||
|
if (envPath) return spawn(envPath, [], {});
|
||||||
|
|
||||||
|
return spawn("torrential", [], {});
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
if (this.socket) return true;
|
||||||
|
this.socket = net.createConnection({ port: 33148, host: "127.0.0.1" });
|
||||||
|
await new Promise<void>((r) =>
|
||||||
|
this.socket!.on("connect", () => {
|
||||||
|
this.logger.info("connected to torrential socket");
|
||||||
|
r();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.setupRead();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
async () => await $fetch(`${INTERNAL_DEPOT_URL.toString()}healthcheck`),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.queryProcessors.set(
|
||||||
|
manifestFetchProcessor.queryType,
|
||||||
|
manifestFetchProcessor,
|
||||||
|
);
|
||||||
|
this.queryProcessors.set(
|
||||||
|
serverGamesProcessor.queryType,
|
||||||
|
serverGamesProcessor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProcessor(
|
||||||
|
processor: QueryProcessor<DropBoundType, TorrentialBoundType, Message>,
|
||||||
|
) {
|
||||||
|
this.queryProcessors.set(processor.queryType, processor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupRead() {
|
||||||
|
if (!this.socket) return;
|
||||||
|
this.socket.on("data", (data) => {
|
||||||
|
this.readbuf = Buffer.concat([this.readbuf, data]);
|
||||||
|
if (!this.readingQueue) {
|
||||||
|
this.readingQueue = true;
|
||||||
|
this.queueRead().finally(() => {
|
||||||
|
this.readingQueue = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeMessage<T extends Message>(
|
||||||
|
messageId: string,
|
||||||
|
value: {
|
||||||
|
type: TorrentialBoundType;
|
||||||
|
schema: GenMessage<T>;
|
||||||
|
data: T;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const response = create(TorrentialBoundSchema, {
|
||||||
|
messageId: messageId,
|
||||||
|
type: value.type,
|
||||||
|
data: toBinary(value.schema, value.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseBinary = toBinary(TorrentialBoundSchema, response);
|
||||||
|
const responseLength = responseBinary.length;
|
||||||
|
|
||||||
|
const responseLengthBuf = Buffer.allocUnsafe(8);
|
||||||
|
responseLengthBuf.writeBigUInt64LE(BigInt(responseLength), 0);
|
||||||
|
|
||||||
|
this.socket!.write(responseLengthBuf);
|
||||||
|
this.socket!.write(responseBinary);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async queueRead() {
|
||||||
|
if (this.readbuf.length < 8) return;
|
||||||
|
const sizeBytes = this.readbuf.subarray(0, 8);
|
||||||
|
const size = sizeBytes.readBigUInt64LE(0);
|
||||||
|
const end = Number(size + BigInt(8));
|
||||||
|
if (this.readbuf.length < end) return;
|
||||||
|
|
||||||
|
const buffer = this.readbuf.subarray(8, end);
|
||||||
|
this.readbuf = this.readbuf.subarray(end);
|
||||||
|
const query = fromBinary(DropBoundSchema, buffer);
|
||||||
|
const processor = this.queryProcessors.get(query.type);
|
||||||
|
if (!processor) {
|
||||||
|
this.logger.warn(`no processor for query type: ${query.type}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
value = await processor.run(query);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(
|
||||||
|
`process query for ${query.type} failed with error: ${e}`,
|
||||||
|
);
|
||||||
|
value = {
|
||||||
|
type: TorrentialBoundType.ERROR,
|
||||||
|
schema: StringValueSchema,
|
||||||
|
data: create(StringValueSchema, {
|
||||||
|
value: (e as string).toString(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value) await this.writeMessage(query.messageId, value);
|
||||||
|
|
||||||
|
// Call until we can't
|
||||||
|
await this.queueRead();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TORRENTIAL_SERVICE = new TorrentialService();
|
||||||
|
export default TORRENTIAL_SERVICE;
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
VersionQuerySchema,
|
||||||
|
VersionResponse_LibrarySource_LibraryBackend,
|
||||||
|
VersionResponse_LibrarySourceSchema,
|
||||||
|
VersionResponse_Manifest_ChunkData_FileEntrySchema,
|
||||||
|
VersionResponse_Manifest_ChunkDataSchema,
|
||||||
|
VersionResponse_ManifestSchema,
|
||||||
|
VersionResponseSchema,
|
||||||
|
} from "../../proto/torrential/proto/version_pb";
|
||||||
|
import { castManifest } from "../../library/manifest/utils";
|
||||||
|
import { LibraryBackend } from "~/prisma/client/client";
|
||||||
|
import { create, fromBinary } from "@bufbuild/protobuf";
|
||||||
|
import prisma from "../../db/database";
|
||||||
|
import { defineQueryProcessor } from "./utils";
|
||||||
|
import {
|
||||||
|
DropBoundType,
|
||||||
|
TorrentialBoundType,
|
||||||
|
} from "../../proto/torrential/proto/core_pb";
|
||||||
|
|
||||||
|
export default defineQueryProcessor({
|
||||||
|
queryType: DropBoundType.VERSION_QUERY,
|
||||||
|
run: async (query) => {
|
||||||
|
const queryData = fromBinary(VersionQuerySchema, query.data);
|
||||||
|
|
||||||
|
const version = await prisma.gameVersion.findUnique({
|
||||||
|
where: {
|
||||||
|
versionId: queryData.versionId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
dropletManifest: true,
|
||||||
|
versionPath: true,
|
||||||
|
game: {
|
||||||
|
select: {
|
||||||
|
library: true,
|
||||||
|
libraryPath: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!version) throw "Game version not found";
|
||||||
|
|
||||||
|
const manifest = castManifest(version.dropletManifest);
|
||||||
|
|
||||||
|
const mapEnum = (v: LibraryBackend) => {
|
||||||
|
switch (v) {
|
||||||
|
case LibraryBackend.Filesystem:
|
||||||
|
return VersionResponse_LibrarySource_LibraryBackend.FILESYSTEM;
|
||||||
|
case LibraryBackend.FlatFilesystem:
|
||||||
|
return VersionResponse_LibrarySource_LibraryBackend.FLAT_FILESYSTEM;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: TorrentialBoundType.VERSION_RESPONSE,
|
||||||
|
schema: VersionResponseSchema,
|
||||||
|
data: create(VersionResponseSchema, {
|
||||||
|
manifest: create(VersionResponse_ManifestSchema, {
|
||||||
|
version: manifest.version,
|
||||||
|
size: BigInt(manifest.size),
|
||||||
|
key: Buffer.from(manifest.key),
|
||||||
|
chunks: Object.fromEntries(
|
||||||
|
Object.entries(manifest.chunks).map(([id, chunk]) => [
|
||||||
|
id,
|
||||||
|
create(VersionResponse_Manifest_ChunkDataSchema, {
|
||||||
|
checksum: chunk.checksum,
|
||||||
|
iv: Buffer.from(chunk.iv),
|
||||||
|
files: chunk.files.map((file) =>
|
||||||
|
create(VersionResponse_Manifest_ChunkData_FileEntrySchema, {
|
||||||
|
filename: file.filename,
|
||||||
|
start: BigInt(file.start),
|
||||||
|
length: BigInt(file.length),
|
||||||
|
permissions: file.permissions,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
source: create(VersionResponse_LibrarySourceSchema, {
|
||||||
|
options: JSON.stringify(version.game.library.options),
|
||||||
|
id: version.game.library.id,
|
||||||
|
backend: mapEnum(version.game.library.backend),
|
||||||
|
}),
|
||||||
|
libraryPath: version.game.libraryPath,
|
||||||
|
versionPath: version.versionPath!,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import prisma from "../../db/database";
|
||||||
|
import { ServerGamesResponseSchema } from "../../proto/torrential/proto/manifest_pb";
|
||||||
|
import { create } from "@bufbuild/protobuf";
|
||||||
|
import { defineQueryProcessor } from "./utils";
|
||||||
|
import {
|
||||||
|
DropBoundType,
|
||||||
|
TorrentialBoundType,
|
||||||
|
} from "../../proto/torrential/proto/core_pb";
|
||||||
|
|
||||||
|
export default defineQueryProcessor({
|
||||||
|
queryType: DropBoundType.SERVER_GAMES_QUERY,
|
||||||
|
run: async () => {
|
||||||
|
// const queryData = fromBinary(ServerGamesQuerySchema, query.data);
|
||||||
|
const games = await prisma.game.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
versions: {
|
||||||
|
select: {
|
||||||
|
versionId: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
versionPath: {
|
||||||
|
not: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: TorrentialBoundType.SERVER_GAMES_RESPONSE,
|
||||||
|
schema: ServerGamesResponseSchema,
|
||||||
|
data: create(ServerGamesResponseSchema, {
|
||||||
|
games,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Message } from "@bufbuild/protobuf";
|
||||||
|
import type { QueryProcessor } from ".";
|
||||||
|
import type {
|
||||||
|
DropBoundType,
|
||||||
|
TorrentialBoundType,
|
||||||
|
} from "../../proto/torrential/proto/core_pb";
|
||||||
|
|
||||||
|
export function defineQueryProcessor<
|
||||||
|
T extends DropBoundType,
|
||||||
|
K extends TorrentialBoundType,
|
||||||
|
V extends Message,
|
||||||
|
>(opts: QueryProcessor<T, K, V>) {
|
||||||
|
// TORRENTIAL_SERVICE.queryProcessors.set(opts.queryType, opts as any);
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import droplet from "@drop-oss/droplet";
|
|
||||||
import type { MinimumRequestObject } from "~/server/h3";
|
import type { MinimumRequestObject } from "~/server/h3";
|
||||||
import type { GlobalACL } from "../acls";
|
import type { GlobalACL } from "../acls";
|
||||||
import aclManager from "../acls";
|
import aclManager from "../acls";
|
||||||
@@ -212,7 +211,7 @@ class TaskHandler {
|
|||||||
|
|
||||||
await updateAllClients(true);
|
await updateAllClients(true);
|
||||||
|
|
||||||
droplet.callAltThreadFunc(async () => {
|
const taskFunc = async () => {
|
||||||
const taskEntry = this.taskPool.get(task.id);
|
const taskEntry = this.taskPool.get(task.id);
|
||||||
if (!taskEntry) throw new Error("No task entry");
|
if (!taskEntry) throw new Error("No task entry");
|
||||||
const addAction = (action: TaskActionLink) => {
|
const addAction = (action: TaskActionLink) => {
|
||||||
@@ -260,7 +259,9 @@ class TaskHandler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.taskPool.delete(task.id);
|
this.taskPool.delete(task.id);
|
||||||
});
|
};
|
||||||
|
|
||||||
|
taskFunc();
|
||||||
|
|
||||||
return task.id;
|
return task.id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,39 @@
|
|||||||
import authManager from "~/server/internal/auth";
|
import authManager from "~/server/internal/auth";
|
||||||
|
import prisma from "../internal/db/database";
|
||||||
|
import { APITokenMode } from "~/prisma/client/enums";
|
||||||
|
import type { UserACL } from "../internal/acls";
|
||||||
|
|
||||||
|
export const CLIENT_WEBTOKEN_ACLS: UserACL = [
|
||||||
|
"read",
|
||||||
|
"store:read",
|
||||||
|
"object:read",
|
||||||
|
"settings:read",
|
||||||
|
|
||||||
|
"collections:read",
|
||||||
|
"collections:new",
|
||||||
|
"collections:add",
|
||||||
|
"collections:remove",
|
||||||
|
"collections:delete",
|
||||||
|
|
||||||
|
"library:add",
|
||||||
|
"library:remove",
|
||||||
|
];
|
||||||
|
|
||||||
export default defineNitroPlugin(async () => {
|
export default defineNitroPlugin(async () => {
|
||||||
await authManager.init();
|
await authManager.init();
|
||||||
|
|
||||||
|
await prisma.aPIToken.updateMany({
|
||||||
|
where: {
|
||||||
|
mode: APITokenMode.Client,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
acls: CLIENT_WEBTOKEN_ACLS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.aPIToken.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: "torrential",
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import serviceManager from "../internal/services";
|
import serviceManager from "../internal/services";
|
||||||
import { NGINX_SERVICE } from "../internal/services/services/nginx";
|
import { NGINX_SERVICE } from "../internal/services/services/nginx";
|
||||||
import { TORRENTIAL_SERVICE } from "../internal/services/services/torrential";
|
import { TORRENTIAL_SERVICE } from "../internal/services/torrential";
|
||||||
|
|
||||||
export default defineNitroPlugin(async (nitro) => {
|
export default defineNitroPlugin(async (nitro) => {
|
||||||
TORRENTIAL_SERVICE.register();
|
TORRENTIAL_SERVICE.register();
|
||||||
|
|||||||
+1
-1
Submodule server/torrential updated: 0098bee3e0...50e54b6c60
Reference in New Issue
Block a user