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:
DecDuck
2026-02-06 00:12:24 +11:00
committed by GitHub
parent 6b614acfd8
commit 13c97cfcfc
82 changed files with 1737 additions and 967 deletions
+2
View File
@@ -35,3 +35,5 @@ deploy-template/*
# generated prisma client # generated prisma client
/prisma/client /prisma/client
/prisma/validate /prisma/validate
/server/internal/proto
+1 -1
View File
@@ -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/**
+1 -1
View File
@@ -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
View File
@@ -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"]
+5
View File
@@ -0,0 +1,5 @@
version: v1
plugins:
- plugin: es
out: server/internal/proto
opt: target=ts
+13 -10
View File
@@ -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() {
+12 -13
View File
@@ -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
+22 -13
View File
@@ -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!"
+2 -15
View File
@@ -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;
} }
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+8 -8
View File
@@ -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"
+1 -1
View File
@@ -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"
+9 -5
View File
@@ -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"
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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>
+39
View File
@@ -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>
+4 -2
View File
@@ -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
View File
@@ -1 +0,0 @@
DELETE FROM "Session" WHERE 1=1;
+2 -2
View File
@@ -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"
+2 -2
View File
@@ -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
View File
@@ -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": {
+2 -2
View File
@@ -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"
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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": "Уронить"
}, },
+2 -1
View File
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -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>
+47 -22
View File
@@ -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 &rarr;</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>
+4 -18
View File
@@ -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 = [
{ {
+3 -3
View File
@@ -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" />
+3 -2
View File
@@ -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
+1 -1
View File
@@ -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>
+24 -28
View File
@@ -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();
+7 -4
View File
@@ -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">&larr;</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>
+4 -5
View File
@@ -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">
+2 -2
View File
@@ -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">
+1 -1
View File
@@ -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),
+2 -2
View File
@@ -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>
+11 -6
View File
@@ -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">&larr;</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>
+3 -4
View File
@@ -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
+6 -7
View File
@@ -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>
+26 -5
View File
@@ -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);
+230 -109
View File
@@ -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),
};
}); });
@@ -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,
}, },
}); });
+12 -7
View File
@@ -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;
}); });
+2 -2
View File
@@ -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 };
}); });
+16 -7
View File
@@ -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,
},
});
});
+2 -4
View File
@@ -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() {
+9 -13
View File
@@ -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;
} }
+14 -38
View File
@@ -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() {
+91 -203
View File
@@ -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;
+9 -68
View File
@@ -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;
} }
+2 -9
View File
@@ -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);
+5 -1
View File
@@ -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;
}
+4 -3
View File
@@ -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;
} }
+34
View File
@@ -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 -1
View File
@@ -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();
Submodule server/torrential updated: 0098bee3e0...50e54b6c60