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
/prisma/client
/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
pnpm-lock.yaml
torrential/
/torrential/
.data/**
**/.data/**
+1 -1
View File
@@ -33,7 +33,7 @@
"username": "drop"
}
],
"typescript.experimental.useTsgo": true,
"typescript.experimental.useTsgo": false,
// prioritize ArkType's "type" for autoimports
"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 DATA="/data"
ENV NGINX_CONFIG="/nginx.conf"
ENV NUXT_PORT=4000
# NGINX's port
ENV PORT=4000
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,
browserSupportsWebAuthn,
} from "@simplewebauthn/browser";
import type { FetchError } from "ofetch";
import { FetchError } from "ofetch";
const username = ref("");
const password = ref("");
@@ -141,16 +141,19 @@ const router = useRouter();
const route = useRoute();
const { t } = useI18n();
function signin_wrapper() {
async function signin_wrapper() {
loading.value = true;
signin()
.catch((response) => {
const message = response.message || t("errors.unknown");
error.value = message;
})
.finally(() => {
loading.value = false;
});
try {
await signin();
} catch (e) {
if (e instanceof FetchError) {
error.value = e.data.message || t("errors.unknown");
} else {
error.value = e as string;
}
} finally {
loading.value = false;
}
}
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 class="sm:flex sm:items-center">
<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">
Versions versions version, versions versions. Versions.
{{ $t("library.admin.version.description") }}
</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
@@ -36,28 +38,28 @@
scope="col"
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
scope="col"
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
scope="col"
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
scope="col"
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 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>
</tr>
</thead>
@@ -100,13 +102,13 @@
v-if="version.setups.length == 0"
class="text-xs uppercase font-display text-zinc-700 font-semibold"
>
No setups configured.
{{ $t("library.admin.version.noSetups") }}
</li>
</ul>
</td>
<td class="px-3 py-4 text-sm whitespace-nowrap text-gray-400">
<div v-if="version.onlySetup">
Version configured as in setup-only mode.
{{ $t("library.admin.version.setupOnly") }}
</div>
<ul v-else class="space-y-2">
<GameEditorVersionConfig
@@ -131,10 +133,7 @@
class="text-red-400 hover:text-red-300"
@click="() => deleteVersion(version.versionId)"
>
Delete<span class="sr-only"
>,
{{ version.displayName ?? version.versionPath }}</span
>
{{ $t("common.delete") }}
</button>
</td>
</tr></template
+22 -13
View File
@@ -26,8 +26,7 @@
<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"
>
The installation directory is set as the current directory when
launching. It is not prepended to your command.
{{ $t("library.admin.launchRow.currentDirHint") }}
</div>
</div>
{{ $t("library.admin.import.version.installDir") }}
@@ -124,7 +123,7 @@
<span
:class="['block truncate', selected && 'font-semibold']"
>
'{{ launchProcessQuery }}'
{{ launchProcessQuery }}
</span>
<span
@@ -155,11 +154,21 @@
</div>
<div class="ml-2 inline-flex items-center">
<p class="text-sm text-blue-200">
<span
class="font-mono bg-zinc-950 text-zinc-100 py-1 px-0.5 rounded-xl"
>{executor}</span
<i18n-t
keypath="library.admin.launchRow.executorHint"
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>
</div>
</div>
@@ -174,7 +183,7 @@
</SelectorPlatform>
<div v-if="props.type && props.type === 'Game' && props.allowExecutor">
<h1 class="block text-sm font-medium leading-6 text-zinc-100">
Executor
{{ $t("library.admin.launchRow.executorTitle") }}
</h1>
<div class="relative mt-2 space-x-1 inline-flex items-center w-full">
<ExecutorWidget v-if="executor" :executor="executor" />
@@ -182,12 +191,12 @@
v-else
class="font-bold uppercase font-display text-zinc-500 text-sm"
>
No executor selected
{{ $t("library.admin.launchRow.noExecutorSelected") }}
</div>
<div class="grow" />
<LoadingButton :loading="false" @click="selectLaunchOpen = true"
>Select new executor</LoadingButton
>
<LoadingButton :loading="false" @click="selectLaunchOpen = true">{{
$t("library.admin.launchRow.executorSelect")
}}</LoadingButton>
<button
: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"
@@ -199,7 +208,7 @@
</div>
<div v-if="props.type && props.type === 'Executor'">
<p class="block text-sm font-medium leading-6 text-zinc-100">
Auto-suggest extensions
{{ $t("library.admin.launchRow.autosuggestHint") }}
</p>
<SelectorFileExtension
v-model="launchConfiguration.suggestions!"
+2 -15
View File
@@ -59,7 +59,6 @@ const emit = defineEmits<{
const open = defineModel<boolean>({ required: true });
const { t } = useI18n();
const collectionName = ref("");
const createCollectionLoading = ref(false);
const collections = await useCollections();
@@ -74,6 +73,7 @@ async function createCollection() {
const response = await $dropFetch("/api/v1/collection", {
method: "POST",
body: { name: collectionName.value },
failTitle: "Failed to create collection",
});
// Add the game if provided
@@ -83,6 +83,7 @@ async function createCollection() {
>(`/api/v1/collection/${response.id}/entry`, {
method: "POST",
body: { id: props.gameId },
failTitle: "Failed to add game to collection",
});
response.entries.push(entry);
}
@@ -94,20 +95,6 @@ async function createCollection() {
open.value = false;
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 {
createCollectionLoading.value = false;
}
+1 -1
View File
@@ -22,7 +22,7 @@
class="bg-red-600 text-white hover:bg-red-500"
@click="() => deleteCollection()"
>
{{ $t("delete") }}
{{ $t("common.delete") }}
</LoadingButton>
<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"
+1 -1
View File
@@ -22,7 +22,7 @@
class="bg-red-600 text-white hover:bg-red-500"
@click="() => deleteArticle()"
>
{{ $t("delete") }}
{{ $t("common.delete") }}
</LoadingButton>
<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"
+1 -1
View File
@@ -22,7 +22,7 @@
class="bg-red-600 text-white hover:bg-red-500"
@click="() => deleteUser()"
>
{{ $t("delete") }}
{{ $t("common.delete") }}
</LoadingButton>
<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"
+8 -8
View File
@@ -3,17 +3,17 @@
<template #default>
<div>
<h1 as="h3" class="text-lg font-medium leading-6 text-white">
Select a launch option
{{ $t("library.admin.launchSelector.title") }}
</h1>
<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>
<div
v-if="props.filterPlatform"
class="inline-flex items-center mt-2 gap-x-4"
>
<h1 class="block text-sm font-medium leading-6 text-zinc-100">
Only showing launches for:
{{ $t("library.admin.launchSelector.platformFilterHint") }}
</h1>
<span class="flex items-center">
<component
@@ -30,7 +30,7 @@
<div class="mt-2 space-y-4">
<div>
<h1 class="block text-sm font-medium leading-6 text-zinc-100">
Search for an executor
{{ $t("library.admin.launchSelector.search") }}
</h1>
<SelectorGame
:search="search"
@@ -43,11 +43,11 @@
v-if="versions !== undefined && Object.entries(versions).length == 0"
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 v-else-if="versions !== undefined">
<h1 class="block text-sm font-medium leading-6 text-zinc-100">
Select a version
{{ $t("library.admin.launchSelector.selectVersions") }}
</h1>
<SelectorCombox
:search="
@@ -75,7 +75,7 @@
</div>
<div v-if="versions && version">
<h1 class="block text-sm font-medium leading-6 text-zinc-100">
Select a launch command
{{ $t("library.admin.launchSelector.selectCommand") }}
</h1>
<SelectorCombox
:search="
@@ -127,7 +127,7 @@
</template>
<template #buttons>
<LoadingButton :loading="false" :disabled="!launchId" @click="submit">
Select
{{ $t("common.select") }}
</LoadingButton>
<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"
+1 -1
View File
@@ -29,7 +29,7 @@
v-if="results.length == 0"
class="text-zinc-300 uppercase font-display font-bold text-center p-4"
>
No results.
{{ $t("common.noResults") }}
</div>
<ComboboxOption
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"
@click="() => removeFileExtension(extension)"
>
<span class="sr-only">Remove</span>
<span class="sr-only">{{ $t("common.remove") }}</span>
<svg
viewBox="0 0 14 14"
class="size-3.5 stroke-blue-400 group-hover:stroke-blue-300"
@@ -22,9 +22,9 @@
<span class="absolute -inset-1"></span>
</button>
</span>
<span v-if="model.length == 0" class="text-zinc-500 text-xs"
>No extensions selected.</span
>
<span v-if="model.length == 0" class="text-zinc-500 text-xs">{{
$t("library.admin.fileExtSelector.noSelected")
}}</span>
</div>
<Combobox
as="div"
@@ -65,7 +65,11 @@
: 'text-zinc-100',
]"
>
<span> Add "{{ normalize(query) }}" </span>
<span>
{{
$t("library.admin.fileExtSelector.add", [normalize(query)])
}}</span
>
<span
v-if="selected"
+2 -2
View File
@@ -26,7 +26,7 @@
v-if="gameSearchQuery.length < 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
v-else-if="resultsLoading || results === undefined"
@@ -53,7 +53,7 @@
v-else-if="results.length == 0"
class="text-zinc-500 uppercase font-display font-bold text-center p-4"
>
No results
{{ $t("common.noResults") }}
</div>
<ComboboxOption
v-for="result in results"
+1 -1
View File
@@ -128,7 +128,7 @@
class="text-red-500 hover:text-red-400"
@click="() => deleteSource(sourceIdx)"
>
{{ $t("delete") }}
{{ $t("common.delete") }}
<span class="sr-only">
{{ $t("chars.srComma", [source.name]) }}
</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:
postgres:
image: postgres:14-alpine
user: "1000:1000"
ports:
- 5432:5432
volumes:
- ../.data/db:/var/lib/postgresql/data
- postgres-data:/var/lib/postgresql/data
environment:
- POSTGRES_PASSWORD=drop
- POSTGRES_USER=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": {
"confirmPasswordFormat": "Muss mit oben genanntem übereinstimmen",
"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.",
"title": "Erstelle dein Drop Konto",
"usernameFormat": "Muss mindestens 5 Zeichen enthalten und aus Kleinbuchstaben bestehen"
@@ -101,6 +101,7 @@
"close": "Schließen",
"create": "Erstellen",
"date": "Datum",
"delete": "Löschen",
"deleteConfirm": "Möchtest du \"{0}\" wirklich löschen?",
"divider": "{'|'}",
"edit": "Bearbeiten",
@@ -120,7 +121,6 @@
"tags": "Tags",
"today": "Heute"
},
"delete": "Löschen",
"drop": {
"desc": "Eine Open-Source-Plattform für die Verteilung von Spielen, die auf Geschwindigkeit, Flexibilität und Ästhetik ausgelegt ist.",
"drop": "Drop"
+2 -2
View File
@@ -56,7 +56,7 @@
"register": {
"confirmPasswordFormat": "Must be the same as above, savvy?",
"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.",
"title": "Forge yer Drop Mark",
"usernameFormat": "Must be 5 or more marks, and all lowercase, argh!"
@@ -87,6 +87,7 @@
"close": "Shut yer trap!",
"create": "Forge!",
"date": "Date",
"delete": "Scuttle!",
"deleteConfirm": "Are ye sure ye want to scuttle \"{0}\", ye rogue?",
"divider": "{'|'}",
"edit": "Amend",
@@ -104,7 +105,6 @@
"tags": "Marks",
"today": "Today"
},
"delete": "Scuttle!",
"drop": {
"desc": "An open-source game distribution platform built for speed, flexibility and beauty, like a swift brigantine!",
"drop": "Drop"
+121 -11
View File
@@ -9,7 +9,9 @@
"subheader": "Manage the devices authorized to access your Drop account.",
"title": "Devices"
},
"home": { "title": "Home" },
"home": {
"title": "Home"
},
"notifications": {
"all": "View all {arrow}",
"clear": "Clear notifications",
@@ -21,7 +23,35 @@
"title": "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",
"title": "Account Settings",
"token": {
@@ -50,6 +80,31 @@
"adminTitle": "Admin Dashboard - Drop",
"adminTitleTemplate": "{0} - Admin - Drop",
"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": {
"authClient": "Authorize client?",
"authorize": "Authorize",
@@ -72,7 +127,7 @@
"register": {
"confirmPasswordFormat": "Must be the same as above",
"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.",
"title": "Create your Drop account",
"usernameFormat": "Must be 5 or more characters, and lowercase"
@@ -81,12 +136,14 @@
"externalProvider": "external provider",
"forgot": "Forgot password?",
"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",
"pageTitle": "Sign in to Drop",
"rememberMe": "Remember me",
"signin": "Sign in",
"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",
"username": "Username"
@@ -105,6 +162,7 @@
"close": "Close",
"create": "Create",
"date": "Date",
"delete": "Delete",
"deleteConfirm": "Are you sure you want to delete \"{0}\"?",
"divider": "{'|'}",
"edit": "Edit",
@@ -119,12 +177,12 @@
"remove": "Remove",
"save": "Save",
"saved": "Saved",
"select": "Select",
"servers": "Servers",
"srLoading": "Loading…",
"tags": "Tags",
"today": "Today"
},
"delete": "Delete",
"drop": {
"desc": "An open-source game distribution platform built for speed, flexibility and beauty.",
"drop": "Drop"
@@ -158,7 +216,9 @@
"invalidPassState": "Invalid password state. Please contact the server administrator.",
"invalidUserOrPass": "Invalid username or password.",
"inviteIdRequired": "id required in fetching invitation",
"method": { "signinDisabled": "Sign in method not enabled" },
"method": {
"signinDisabled": "Sign in method not enabled"
},
"usernameTaken": "Username already taken."
},
"backHome": "{arrow} Back to home",
@@ -248,12 +308,18 @@
"aboutDrop": "About Drop",
"api": "API documentation",
"comparison": "Comparison",
"docs": { "client": "Client Docs", "server": "Server Docs" },
"docs": {
"client": "Client Docs",
"server": "Server Docs"
},
"documentation": "Documentation",
"findGame": "Find a Game",
"footer": "Footer",
"games": "Games",
"social": { "discord": "Discord", "github": "GitHub" },
"social": {
"discord": "Discord",
"github": "GitHub"
},
"topSellers": "Top Sellers",
"version": "Drop {version} {gitRef}"
},
@@ -303,6 +369,10 @@
"admin": {
"detectedGame": "Drop has detected you have new games to import.",
"detectedVersion": "Drop has detected you have new versions of this game to import.",
"fileExtSelector": {
"add": "Add \"{0}\"",
"noSelected": "No extensions selected."
},
"game": {
"addCarouselNoImages": "No images to add.",
"addDescriptionNoImages": "No images to add.",
@@ -323,10 +393,14 @@
"setCover": "Set as cover"
},
"gameLibrary": "Game Library",
"gameSelector": {
"hint": "Type at least 4 characters to get results"
},
"import": {
"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",
"import": "Import",
"importAs": "Import as",
"link": "Import {arrow}",
"loading": "Loading game results…",
"search": "Search",
@@ -344,6 +418,7 @@
"launchPlaceholder": "game.exe --args",
"loadingVersion": "Loading version metadata…",
"noLaunches": "No launch configurations added.",
"noNameProvided": "No name provided.",
"noSetups": "No setup configurations added.",
"noVersions": "No versions to import",
"platform": "Version platform",
@@ -357,6 +432,23 @@
},
"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.",
"libraryHintDocsLink": "What does this mean? {arrow}",
"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.",
"title": "Libraries",
"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",
@@ -595,6 +697,7 @@
"completedTasksTitle": "Completed tasks",
"dailyScheduledTitle": "Daily scheduled tasks",
"execute": "{arrow} Execute",
"noActions": "No actions",
"noTasksRunning": "No tasks currently running",
"progress": "{0}%",
"runningTasksTitle": "Running tasks",
@@ -628,8 +731,15 @@
},
"userHeader": {
"closeSidebar": "Close sidebar",
"links": { "community": "Community", "library": "Library", "news": "News" },
"profile": { "admin": "Admin Dashboard", "settings": "Account settings" }
"links": {
"community": "Community",
"library": "Library",
"news": "News"
},
"profile": {
"admin": "Admin Dashboard",
"settings": "Account settings"
}
},
"users": {
"admin": {
+2 -2
View File
@@ -70,7 +70,7 @@
"register": {
"confirmPasswordFormat": "Doit être pareil qu'au dessus",
"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.",
"title": "Créer votre compte Drop",
"usernameFormat": "Doit être au moins 5 caractères et en minuscules"
@@ -101,6 +101,7 @@
"close": "Fermer",
"create": "Créer",
"date": "Date",
"delete": "Supprimer",
"deleteConfirm": "Êtes vous sûr de vouloir supprimer \"{0}\" ?",
"divider": "{'|'}",
"edit": "Éditer",
@@ -120,7 +121,6 @@
"tags": "Étiquettes",
"today": "Aujourd'hui"
},
"delete": "Supprimer",
"drop": {
"desc": "Une plateforme de distribution libre conçue pour être rapide, flexible et belle.",
"drop": "Drop"
+2 -2
View File
@@ -70,7 +70,7 @@
"register": {
"confirmPasswordFormat": "Musi być takie samo jak powyżej",
"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.",
"title": "Stwórz swoje konto Drop",
"usernameFormat": "Musi mieć co najmniej 5 znaków i małe litery"
@@ -101,6 +101,7 @@
"close": "Zamknij",
"create": "Utwórz",
"date": "Data",
"delete": "Usuń",
"deleteConfirm": "Czy jesteś pewny że chcesz usunąć \"{0}\"?",
"divider": "{'|'}",
"edit": "Edytuj",
@@ -120,7 +121,6 @@
"tags": "Tagi",
"today": "Dzisiaj"
},
"delete": "Usuń",
"drop": {
"desc": "Platforma typu open source do dystrybucji gier, stworzona z myślą o szybkości, elastyczności i estetyce.",
"drop": "Drop"
+1 -1
View File
@@ -79,6 +79,7 @@
"close": "Закрыть",
"create": "Создать",
"date": "Дата",
"delete": "Удалить",
"deleteConfirm": "Вы точно хотите удалить \"{0}\"?",
"edit": "Редактировать",
"friends": "Друзья",
@@ -94,7 +95,6 @@
"tags": "Теги",
"today": "Сегодня"
},
"delete": "Удалить",
"drop": {
"drop": "Уронить"
},
+2 -1
View File
@@ -9,8 +9,9 @@
</div>
<LazyUserFooter class="z-50" hydrate-on-interaction />
</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 />
<LazyUserHeaderStoreNav />
</div>
</template>
+7 -3
View File
@@ -12,7 +12,7 @@
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare && prisma generate",
"postinstall": "nuxt prepare && prisma generate && buf generate",
"typecheck": "nuxt typecheck",
"lint": "pnpm run lint:eslint && pnpm run lint:prettier",
"lint:eslint": "eslint .",
@@ -20,8 +20,8 @@
"lint:fix": "eslint . --fix && prettier --write --list-different ."
},
"dependencies": {
"@bufbuild/protobuf": "^2.11.0",
"@discordapp/twemoji": "^16.0.1",
"@drop-oss/droplet": "5.3.1",
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5",
"@lobomfz/prismark": "0.0.3",
@@ -44,8 +44,9 @@
"fast-fuzzy": "^1.12.0",
"file-type-mime": "^0.4.3",
"jdenticon": "^3.3.0",
"kjua": "^0.10.0",
"jose": "^6.1.3",
"jsonwebtoken": "^9.0.3",
"kjua": "^0.10.0",
"luxon": "^3.6.1",
"micromark": "^4.0.1",
"normalize-url": "^8.0.2",
@@ -68,10 +69,13 @@
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@bufbuild/buf": "^1.65.0",
"@bufbuild/protoc-gen-es": "^2.11.0",
"@intlify/eslint-plugin-vue-i18n": "^4.0.1",
"@nuxt/eslint": "^1.3.0",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@types/jsonwebtoken": "^9.0.10",
"@types/luxon": "^3.6.2",
"@types/node": "^22.13.16",
"@types/semver": "^7.7.0",
+1 -1
View File
@@ -85,7 +85,7 @@
@click="deleteNotification(notification.id)"
>
<TrashIcon class="size-3" />
{{ $t("delete") }}
{{ $t("common.delete") }}
</button>
</div>
</div>
+47 -22
View File
@@ -13,13 +13,21 @@
</div>
<div class="ml-3">
<p class="text-sm text-yellow-300">
Sign in again to access these settings.
{{ " " }}
{{ $t("account.security.2fa.superlevelHint.title") }}
<NuxtLink
href="/auth/signin?redirect=/account/security&superlevel=true"
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>
</div>
</div>
@@ -31,7 +39,7 @@
</div>
<div class="ml-3">
<p class="text-sm text-green-300">
You have access to these protected actions.
{{ $t("account.security.2fa.superlevelHint.success") }}
</p>
</div>
</div>
@@ -40,7 +48,7 @@
<div></div>
<div class="mt-8 border-b border-white/10 pb-2">
<h3 class="text-base font-semibold text-white">
Two-factor authentication
{{ $t("account.security.2fa.title") }}
</h3>
</div>
<div class="mt-4 flex flex-wrap gap-8">
@@ -67,15 +75,16 @@
class="absolute inset-0"
aria-hidden="true"
></span>
TOTP
{{ $t("account.security.2fa.totp.title") }}
</NuxtLink>
</h3>
<p class="mt-2 text-sm text-gray-400">
TOTP generates one-time codes, completely offline. You can use any
TOTP authenticator you like.
{{ $t("account.security.2fa.totp.description") }}
</p>
<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>
<span
@@ -107,20 +116,21 @@
</span>
</div>
<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">
Otherwise known as passkeys. Authenticate using biometrics, a
device, YubiKeys, or any compatible FIDO2 device.
{{ $t("account.security.2fa.webauthn.description") }}
</p>
<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>
</div>
<LoadingButton
class="mt-3"
:loading="false"
@click="() => (webAuthnOpen = true)"
>Manage</LoadingButton
>{{ $t("account.security.2fa.webauthn.manage") }}</LoadingButton
>
</div>
</div>
@@ -130,9 +140,11 @@
<template #default>
<div class="sm:flex sm:items-center">
<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">
Create new keys or remove existing keys from your account.
{{ $t("account.security.2fa.webauthn.modal.description") }}
</p>
</div>
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
@@ -140,7 +152,7 @@
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"
>
New key
{{ $t("account.security.2fa.webauthn.modal.new") }}
</NuxtLink>
</div>
</div>
@@ -156,17 +168,19 @@
scope="col"
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
scope="col"
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 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>
</tr>
</thead>
@@ -193,9 +207,12 @@
<td
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"
>Delete</a
<button
class="text-blue-400 hover:text-blue-300"
@click="() => deletePasskey(mec.id)"
>
{{ $t("common.delete") }}
</button>
</td>
</tr>
</tbody>
@@ -229,4 +246,12 @@ const superlevel = await $dropFetch("/api/v1/user/superlevel");
const mfa = await $dropFetch("/api/v1/user/mfa");
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>
+4 -18
View File
@@ -173,7 +173,7 @@
:title="t('home.admin.biggestGamesToDownload')"
:subtitle="t('home.admin.latestVersionOnly')"
>
<RankingList :items="biggestGamesLatest.map(gameToRankItem)" />
<!-- <RankingList :items="biggestGamesLatest.map(gameToRankItem)" />-->
</TileWithLink>
</div>
<div class="col-span-6 lg:col-span-2">
@@ -181,7 +181,7 @@
:title="t('home.admin.biggestGamesOnServer')"
:subtitle="t('home.admin.allVersionsCombined')"
>
<RankingList :items="biggestGamesCombined.map(gameToRankItem)" />
<!-- <RankingList :items="biggestGamesCombined.map(gameToRankItem)" />-->
</TileWithLink>
</div>
</div>
@@ -196,8 +196,6 @@ import DropLogo from "~/components/DropLogo.vue";
import { ServerStackIcon, UserGroupIcon } from "@heroicons/vue/24/outline";
import { getPercentage } from "~/utils/utils";
import { getBarColor } from "~/utils/colors";
import type { GameSize } from "~/server/internal/gamesize";
import type { RankItem } from "~/components/RankingList.vue";
definePageMeta({
layout: "admin",
@@ -211,20 +209,8 @@ const { t } = useI18n();
const systemData = useSystemData();
const {
version,
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 { version, gameCount, sources, userStats } =
await $dropFetch("/api/v1/admin/home");
const pieChartData = [
{
+3 -3
View File
@@ -191,9 +191,9 @@
<span v-if="launch.name" class="text-sm font-semibold">{{
launch.name
}}</span>
<span v-else class="text-sm text-zinc-500 italic"
>No name provided.</span
>
<span v-else class="text-sm text-zinc-500 italic">{{
$t("library.admin.import.version.noNameProvided")
}}</span>
<span class="ml-auto flex h-7 items-center">
<PlusIcon v-if="!open" 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">
<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">
<label
v-for="[type, meta] in Object.entries(importModes)"
:key="type"
: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"
>
<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"
@click="() => deleteGame(game.id)"
>
{{ $t("delete") }}
{{ $t("common.delete") }}
</button>
</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"
@click="() => deleteCompany(company.id)"
>
{{ $t("delete") }}
{{ $t("common.delete") }}
</button>
</div>
</div>
+24 -28
View File
@@ -11,39 +11,35 @@
</i18n-t>
</NuxtLink>
<div
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">
<div v-if="task" class="flex flex-col w-full gap-y-4">
<h1
class="inline-flex items-center gap-x-3 text-3xl text-zinc-100 font-bold font-display"
>
<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>
{{ task.name }}
</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">
<li
v-for="[name, link] in task.actions.map((v) => v.split(':'))"
@@ -57,7 +53,7 @@
v-if="task.actions.length == 0"
class="text-md uppercase font-display font-bold text-zinc-700"
>
No actions
{{ $t("tasks.admin.noActions") }}
</li>
</ul>
@@ -95,8 +91,8 @@
</template>
<script setup lang="ts">
import { CheckCircleIcon } from "@heroicons/vue/16/solid";
import { ExclamationCircleIcon } from "@heroicons/vue/24/solid";
import { CheckCircleIcon } from "@heroicons/vue/24/solid";
import { XMarkIcon, XCircleIcon } from "@heroicons/vue/24/outline";
const route = useRoute();
const taskId = route.params.id.toString();
+7 -4
View File
@@ -5,11 +5,10 @@
<h1
class="mt-4 text-3xl font-semibold tracking-tight text-balance text-white sm:text-4xl"
>
Two-factor authentication
{{ $t("auth.2fa.title") }}
</h1>
<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
options below to continue.
{{ $t("auth.2fa.description") }}
</p>
</div>
<div class="mx-auto mt-16 flow-root max-w-lg sm:mt-20">
@@ -18,7 +17,11 @@
<NuxtLink
:href="{ path: '/auth/mfa', query: route.query }"
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>
+4 -5
View File
@@ -13,11 +13,11 @@
<h3 class="text-sm/6 font-semibold text-white">
<NuxtLink :to="{ path: '/auth/mfa/totp', query: route.query }">
<span class="absolute inset-0" aria-hidden="true"></span>
TOTP
{{ $t("auth.2fa.totp.title") }}
</NuxtLink>
</h3>
<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>
</div>
<div class="flex-none self-center">
@@ -34,12 +34,11 @@
<h3 class="text-sm/6 font-semibold text-white">
<NuxtLink :to="{ path: '/auth/mfa/webauthn', query: route.query }">
<span class="absolute inset-0" aria-hidden="true"></span>
WebAuthn
{{ $t("auth.2fa.passkey.title") }}
</NuxtLink>
</h3>
<p class="mt-2 text-sm/6 text-zinc-400">
Use a passkey, like biometrics, a hardware security device, or other
compatible device to sign in to your Drop account.
{{ $t("auth.2fa.passkey.description") }}
</p>
</div>
<div class="flex-none self-center">
+2 -2
View File
@@ -24,8 +24,8 @@
<div v-else class="inline-flex gap-x-2">
<LoadingButton :loading="false" @click="() => tryAuthWrapper()">
Sign in with WebAuthn</LoadingButton
>
{{ $t("auth.2fa.passkey.signinButton") }}
</LoadingButton>
</div>
<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),
);
const passwordValidator = type("string >= 14");
const passwordValidator = type("string >= 8");
const validPassword = computed(
() =>
!((passwordValidator(password.value) as unknown) instanceof type.errors),
+2 -2
View File
@@ -11,14 +11,14 @@
>
{{
superlevel
? "Sign in to access protected action"
? $t("auth.signin.titleProtected")
: $t("auth.signin.title")
}}
</h2>
<p class="mt-2 text-sm leading-6 text-zinc-400">
{{
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")
}}
</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" />
<div class="mt-3 text-center sm:mt-5">
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
Added your 2FA method!
{{ $t("auth.2fa.success.title") }}
</h1>
<div class="mt-4">
<p class="mx-auto text-sm text-zinc-400 max-w-sm">
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.
{{ $t("auth.2fa.success.description") }}
</p>
<div class="mt-10 flex justify-center">
<NuxtLink
href="/account/security"
class="text-sm/6 font-semibold text-blue-400"
><span aria-hidden="true">&larr;</span> Back to account
security</NuxtLink
><i18n-t
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>
+3 -4
View File
@@ -7,15 +7,14 @@
<h1
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>
<p class="mt-6 text-base leading-7 text-zinc-400">
Use your TOTP authenticator, like Google Authenticator, Aegis, or
Bitwarden, to add 2FA to your Drop account.
{{ $t("auth.2fa.totp.createDescription") }}
</p>
<div class="mt-8">
<p class="text-xs leading-7 text-zinc-200">
Enter the generated code to enable TOTP
{{ $t("auth.2fa.totp.createHint") }}
</p>
<div class="mt-2 flex flex-row gap-2">
<CodeInput
+6 -7
View File
@@ -7,11 +7,10 @@
<h2
class="mt-10 text-center text-2xl/9 font-bold tracking-tight text-white"
>
Create a passkey
{{ $t("auth.2fa.passkey.createTitle") }}
</h2>
<p class="text-sm text-center text-zinc-400">
WebAuthn, or passkeys, allow you to sign in or complete 2FA with
biometrics or hardware security devices.
{{ $t("auth.2fa.passkey.createDescription") }}
</p>
</div>
@@ -23,9 +22,9 @@
@submit.prevent="attemptPasskeyWrapper"
>
<div>
<label for="name" class="block text-sm/6 font-medium text-gray-100"
>Name</label
>
<label for="name" class="block text-sm/6 font-medium text-gray-100">{{
$t("auth.2fa.passkey.passkeyNameTag")
}}</label>
<div class="mt-2">
<input
id="name"
@@ -41,7 +40,7 @@
<div>
<LoadingButton :disabled="disabled" :loading="loading" class="w-full">
Create
{{ $t("common.create") }}
</LoadingButton>
</div>
+26 -5
View File
@@ -39,7 +39,7 @@
<AddLibraryButton :game-id="game.id" />
</div>
<NuxtLink
v-if="user?.admin"
v-if="user?.admin && !isClient"
:href="`/admin/library/${game.id}`"
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"
@@ -93,10 +93,26 @@
{{ $t("store.size") }}
</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"
>
{{ 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
v-else
@@ -243,7 +259,7 @@
<script setup lang="ts">
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 { formatBytes } from "~/server/internal/utils/files";
@@ -254,10 +270,15 @@ const user = useUser();
const { game, rating, size } = await $dropFetch(`/api/v1/games/${gameId}`);
const isClient = isClientRequest();
const descriptionHTML = micromark(game.mDescription);
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()
.filter((e, i, u) => u.indexOf(e) === i);
+230 -109
View File
@@ -8,12 +8,12 @@ importers:
.:
dependencies:
'@bufbuild/protobuf':
specifier: ^2.11.0
version: 2.11.0
'@discordapp/twemoji':
specifier: ^16.0.1
version: 16.0.1
'@drop-oss/droplet':
specifier: 5.3.1
version: 5.3.1
'@headlessui/vue':
specifier: ^1.7.23
version: 1.7.23(vue@3.5.27(typescript@5.8.3))
@@ -83,6 +83,9 @@ importers:
jose:
specifier: ^6.1.3
version: 6.1.3
jsonwebtoken:
specifier: ^9.0.3
version: 9.0.3
kjua:
specifier: ^0.10.0
version: 0.10.0
@@ -147,6 +150,12 @@ importers:
specifier: ^4.1.0
version: 4.1.0(vue@3.5.27(typescript@5.8.3))
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':
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)
@@ -159,6 +168,9 @@ importers:
'@tailwindcss/typography':
specifier: ^0.5.15
version: 0.5.16(tailwindcss@4.1.11)
'@types/jsonwebtoken':
specifier: ^9.0.10
version: 9.0.10
'@types/luxon':
specifier: ^3.6.2
version: 3.6.2
@@ -399,6 +411,69 @@ packages:
resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==}
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':
resolution: {integrity: sha512-Ju2I/Qn3c1OaU8FgeW4Tc22D4C9NwyVfKzNmzst59bvxBjPoLYNZMqFYn+HvCtn4MpXwiaDtCE8fNuQLpdi9yA==}
@@ -433,69 +508,6 @@ packages:
'@discordapp/twemoji@16.0.1':
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':
resolution: {integrity: sha512-RNpJjDZs9+JcT9N87AnOuHsNM75DEd58itADNd/s1LIF6BZbTLZV0xxilJZb55lntn4TYvscTaXLCBX2fq9CXg==}
@@ -2459,6 +2471,9 @@ packages:
'@types/json-schema@7.0.15':
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':
resolution: {integrity: sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==}
@@ -2589,6 +2604,11 @@ packages:
resolution: {integrity: sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==}
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':
resolution: {integrity: sha512-7BYjHfOaoZ9+ARJkT10Q2TjnTUqDXmMpfakIAsD/hXiuff1oqWg1xeXT5+MomhNcC15HbiABpbbBmITLSHxdKg==}
peerDependencies:
@@ -3104,6 +3124,9 @@ packages:
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
engines: {node: '>=8.0.0'}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@@ -3658,6 +3681,9 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -4526,10 +4552,20 @@ packages:
jsonfile@5.0.0:
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:
resolution: {integrity: sha512-Qush0uP+G8ZScpGMZvHUiRfI0YBWuB3gVBYlI0v0vvOJt5FLicco+IkP0a50LqTTQhmts/m6tP5SWE+USyIvcQ==}
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:
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
engines: {node: '>=18'}
@@ -4678,18 +4714,36 @@ packages:
lodash.defaults@4.2.0:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
lodash.isarguments@3.1.0:
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:
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:
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
lodash.uniq@4.5.0:
resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==}
@@ -6255,6 +6309,11 @@ packages:
type-level-regexp@0.1.17:
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:
resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==}
engines: {node: '>=14.17'}
@@ -7125,6 +7184,55 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@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/unpack@2.4.0':
@@ -7172,49 +7280,6 @@ snapshots:
jsonfile: 5.0.0
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)':
dependencies:
'@dxup/unimport': 0.1.2
@@ -9249,6 +9314,11 @@ snapshots:
'@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/ms@2.1.0': {}
@@ -9422,6 +9492,13 @@ snapshots:
'@typescript-eslint/types': 8.50.0
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))':
dependencies:
hookable: 5.5.3
@@ -10028,6 +10105,8 @@ snapshots:
buffer-crc32@1.0.0: {}
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {}
buffer@5.7.1:
@@ -10569,6 +10648,10 @@ snapshots:
eastasianwidth@0.2.0: {}
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
ee-first@1.1.1: {}
electron-to-chromium@1.5.194: {}
@@ -11573,8 +11656,32 @@ snapshots:
optionalDependencies:
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: {}
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: {}
keyv@4.5.4:
@@ -11711,14 +11818,26 @@ snapshots:
lodash.defaults@4.2.0: {}
lodash.includes@4.3.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.isstring@4.0.1: {}
lodash.memoize@4.1.2: {}
lodash.merge@4.6.2: {}
lodash.once@4.1.1: {}
lodash.uniq@4.5.0: {}
lodash@4.17.21: {}
@@ -13762,6 +13881,8 @@ snapshots:
type-level-regexp@0.1.17: {}
typescript@5.4.5: {}
typescript@5.8.3: {}
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 aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import gameSizeManager from "~/server/internal/gamesize";
import type { UnimportedVersionInformation } from "~/server/internal/library";
import libraryManager from "~/server/internal/library";
async function getGameVersionSize<
T extends Omit<GameVersion, "dropletManifest">,
>(gameId: string, version: T) {
const size = await libraryManager.getGameVersionSize(
gameId,
version.versionId,
);
return { ...version, size };
const clientSize = await gameSizeManager.getVersionSize(version.versionId);
const diskSize = await gameSizeManager.getVersionDiskSize(version.versionId);
return { ...version, diskSize, clientSize };
}
export type AdminFetchGameType = Prisma.GameGetPayload<{
@@ -10,18 +10,10 @@ export default defineEventHandler(async (h3) => {
const sources = await libraryManager.fetchLibraries();
const userStats = await userStatsManager.getUserStats();
const biggestGamesCombined =
await libraryManager.getBiggestGamesCombinedVersions(5);
const biggestGamesLatest =
await libraryManager.getBiggestGamesLatestVersions(5);
return {
gameCount: await prisma.game.count(),
version: systemConfig.getDropVersion(),
userStats,
sources,
biggestGamesLatest,
biggestGamesCombined,
};
});
@@ -50,7 +50,12 @@ export default defineEventHandler(async (h3) => {
where: {
gameId: body.id,
delta: false,
launches: { some: { platform: platformObject.platform } },
OR: [
{ launches: { some: { platform: platformObject.platform } } },
{
setups: { some: { platform: platformObject.platform } },
},
],
},
});
if (validOverlayVersions == 0)
@@ -23,7 +23,7 @@ export default defineEventHandler<{
if (!authManager.getAuthProviders().Simple)
throw createError({
statusCode: 403,
statusMessage: t("errors.auth.method.signinDisabled"),
message: t("errors.auth.method.signinDisabled"),
});
const body = signinValidator(await readBody(h3));
@@ -33,7 +33,7 @@ export default defineEventHandler<{
throw createError({
statusCode: 400,
statusMessage: body.summary,
message: body.summary,
});
}
@@ -57,13 +57,13 @@ export default defineEventHandler<{
if (!authMek)
throw createError({
statusCode: 401,
statusMessage: t("errors.auth.invalidUserOrPass"),
message: t("errors.auth.invalidUserOrPass"),
});
if (!authMek.user.enabled)
throw createError({
statusCode: 403,
statusMessage: t("errors.auth.disabled"),
message: t("errors.auth.disabled"),
});
// LEGACY bcrypt
@@ -74,13 +74,13 @@ export default defineEventHandler<{
if (!hash)
throw createError({
statusCode: 500,
statusMessage: t("errors.auth.invalidPassState"),
message: t("errors.auth.invalidPassState"),
});
if (!(await checkHashBcrypt(body.password, hash)))
throw createError({
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
@@ -101,13 +101,13 @@ export default defineEventHandler<{
if (!hash || typeof hash !== "string")
throw createError({
statusCode: 500,
statusMessage: t("errors.auth.invalidPassState"),
message: t("errors.auth.invalidPassState"),
});
if (!(await checkHashArgon2(body.password, hash)))
throw createError({
statusCode: 401,
statusMessage: t("errors.auth.invalidUserOrPass"),
message: t("errors.auth.invalidUserOrPass"),
});
const result = await sessionHandler.signin(h3, authMek.userId, {
@@ -15,7 +15,7 @@ export const SharedRegisterValidator = type({
const CreateUserValidator = SharedRegisterValidator.and({
invitation: "string",
password: "string >= 14",
password: "string >= 8",
"displayName?": "string | undefined",
}).configure(throwingArktype);
@@ -27,7 +27,7 @@ export default defineEventHandler<{
if (!authManager.getAuthProviders().Simple)
throw createError({
statusCode: 403,
statusMessage: t("errors.auth.method.signinDisabled"),
message: t("errors.auth.method.signinDisabled"),
});
const user = await readValidatedBody(h3, CreateUserValidator);
@@ -38,7 +38,7 @@ export default defineEventHandler<{
if (!invitation)
throw createError({
statusCode: 401,
statusMessage: t("errors.auth.invalidInvite"),
message: t("errors.auth.invalidInvite"),
});
// reuse items from invite
@@ -51,7 +51,7 @@ export default defineEventHandler<{
if (existing > 0)
throw createError({
statusCode: 400,
statusMessage: t("errors.auth.usernameTaken"),
message: t("errors.auth.usernameTaken"),
});
const userId = randomUUID();
@@ -1,6 +1,5 @@
import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library";
export default defineClientEventHandler(async (h3) => {
const id = getRouterParam(h3, "id");
@@ -57,8 +56,5 @@ export default defineClientEventHandler(async (h3) => {
})),
};
return {
...gameVersionMapped,
size: libraryManager.getGameVersionSize(id, version),
};
return gameVersionMapped;
});
@@ -1,6 +1,7 @@
import type { Platform } from "~/prisma/client/enums";
import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
import prisma from "~/server/internal/db/database";
import type { GameVersionSize } from "~/server/internal/gamesize";
import gameSizeManager from "~/server/internal/gamesize";
type VersionDownloadOption = {
@@ -8,24 +9,23 @@ type VersionDownloadOption = {
displayName?: string | undefined;
versionPath?: string | undefined;
platform: Platform;
size: number;
size: GameVersionSize;
requiredContent: Array<{
gameId: string;
versionId: string;
name: string;
iconObjectId: string;
shortDescription: string;
size: number;
size: GameVersionSize;
}>;
};
export default defineClientEventHandler(async (h3) => {
const query = getQuery(h3);
const id = query.id?.toString();
const id = getRouterParam(h3, "id")!;
if (!id)
throw createError({
statusCode: 400,
statusMessage: "No ID in request query",
statusMessage: "No ID in router params",
});
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"]
> = new Map();
for (const launch of v.launches) {
for (const launch of [...v.launches, ...v.setups]) {
if (!platformOptions.has(launch.platform))
platformOptions.set(launch.platform, []);
if (launch.executor) {
if ("executor" in launch && launch.executor) {
const old = platformOptions.get(launch.platform)!;
old.push({
gameId: launch.executor.gameVersion.game.id,
@@ -86,19 +87,14 @@ export default defineClientEventHandler(async (h3) => {
iconObjectId: launch.executor.gameVersion.game.mIconObjectId,
shortDescription:
launch.executor.gameVersion.game.mShortDescription,
size:
(await gameSizeManager.getGameVersionSize(
launch.executor.gameVersion.game.id,
launch.executor.gameVersion.versionId,
)) ?? 0,
size: (await gameSizeManager.getVersionSize(
launch.executor.gameVersion.versionId,
))!,
});
}
}
const size = await gameSizeManager.getGameVersionSize(
v.gameId,
v.versionId,
);
const size = await gameSizeManager.getVersionSize(v.versionId);
return platformOptions
.entries()
@@ -1,29 +1,21 @@
import { APITokenMode } from "~/prisma/client/enums";
import { DateTime } from "luxon";
import type { UserACL } from "~/server/internal/acls";
import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
import prisma from "~/server/internal/db/database";
import { CLIENT_WEBTOKEN_ACLS } from "~/server/plugins/04.auth-init";
export default defineClientEventHandler(
async (h3, { fetchUser, fetchClient, clientId }) => {
const user = await fetchUser();
const client = await fetchClient();
const acls: UserACL = [
"read",
"store:read",
"collections:read",
"object:read",
"settings:read",
];
const token = await prisma.aPIToken.create({
data: {
name: `${client.name} Web Access Token ${DateTime.now().toISO()}`,
clientId,
userId: user.id,
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 userLibraryManager from "~/server/internal/userlibrary";
const CreateCollection = type({
name: "string",
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["collections:read"]);
const userId = await aclManager.getUserIdACL(h3, ["collections:new"]);
if (!userId)
throw createError({
statusCode: 403,
});
const body = await readBody(h3);
const name = body.name;
if (!name)
throw createError({ statusCode: 400, statusMessage: "Requires name" });
const body = await readDropValidatedBody(h3, CreateCollection);
// Create the collection using the manager
const newCollection = await userLibraryManager.collectionCreate(name, userId);
const newCollection = await userLibraryManager.collectionCreate(
body.name,
userId,
);
return newCollection;
});
+2 -2
View File
@@ -1,6 +1,6 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library";
import gameSizeManager from "~/server/internal/gamesize";
export default defineEventHandler(async (h3) => {
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 };
});
+16 -7
View File
@@ -32,6 +32,11 @@ export default defineEventHandler(async (h3) => {
if (options instanceof ArkErrors)
throw createError({ statusCode: 400, statusMessage: options.summary });
const filterPlatforms = options.platform
?.split(",")
.map(parsePlatform)
.filter((e) => e !== undefined);
/**
* Generic filters
*/
@@ -46,23 +51,27 @@ export default defineEventHandler(async (h3) => {
},
}
: undefined;
const platformFilter = options.platform
? {
const platformFilter = filterPlatforms
? ({
versions: {
some: {
launches: {
some: {
platform: {
in: options.platform
.split(",")
.map(parsePlatform)
.filter((e) => e !== undefined),
in: filterPlatforms,
},
},
},
setups: {
some: {
platform: {
in: filterPlatforms,
},
},
},
},
},
}
} satisfies Prisma.GameWhereInput)
: 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() {
for (const [key, init] of Object.entries(this.initFuncs)) {
try {
@@ -42,6 +38,8 @@ class AuthManager {
if (!this.authProviders[AuthMec.OpenID]) {
this.authProviders[AuthMec.Simple] = true;
}
logger.info("AuthManager initialized");
}
getAuthProviders() {
+9 -13
View File
@@ -1,5 +1,5 @@
import droplet from "@drop-oss/droplet";
import type { CertificateStore } from "./ca-store";
import { dropletInterface } from "../services/torrential/droplet-interface";
export type CertificateBundle = {
priv: string;
@@ -23,8 +23,7 @@ export class CertificateAuthority {
const root = await store.fetch("ca");
let ca;
if (root === undefined) {
const [cert, priv] = droplet.generateRootCa();
const bundle: CertificateBundle = { priv, cert };
const bundle: CertificateBundle = await dropletInterface.generateRootCa();
await store.store("ca", bundle);
ca = new CertificateAuthority(store, bundle);
} else {
@@ -43,16 +42,13 @@ export class CertificateAuthority {
const caCertificate = await this.certificateStore.fetch("ca");
if (!caCertificate)
throw new Error("Certificate authority not initialised");
const [cert, priv] = droplet.generateClientCertificate(
clientId,
clientName,
caCertificate.cert,
caCertificate.priv,
);
const certBundle: CertificateBundle = {
priv,
cert,
};
const certBundle: CertificateBundle =
await dropletInterface.generateClientCert(
clientId,
clientName,
caCertificate,
);
return certBundle;
}
+14 -38
View File
@@ -1,8 +1,8 @@
import type { ClientModel, UserModel } from "~/prisma/client/models";
import type { EventHandlerRequest, H3Event } from "h3";
import droplet from "@drop-oss/droplet";
import prisma from "../db/database";
import { useCertificateAuthority } from "~/server/plugins/ca";
import jwt from "jsonwebtoken";
export type EventHandlerFunction<T> = (
h3: H3Event<EventHandlerRequest>,
@@ -15,7 +15,8 @@ type ClientUtils = {
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>) {
return defineEventHandler(async (h3) => {
@@ -25,39 +26,11 @@ export function defineClientEventHandler<T>(handler: EventHandlerFunction<T>) {
let clientId: string;
switch (method) {
case "Debug": {
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": {
case "JWT": {
clientId = parts[0];
const nonce = parts[1];
const signature = parts[2];
const jwtToken = parts[1];
if (!clientId || !nonce || !signature)
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",
});
}
if (!clientId || !jwtToken) throw createError({ statusCode: 403 });
const certificateAuthority = useCertificateAuthority();
const certBundle =
@@ -66,21 +39,24 @@ export function defineClientEventHandler<T>(handler: EventHandlerFunction<T>) {
if (!certBundle)
throw createError({
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)
throw createError({
statusCode: 403,
statusMessage: "Invalid nonce signature.",
message: "Invalid nonce signature.",
});
break;
}
default: {
throw createError({
statusCode: 403,
statusMessage: "No authentication",
message: "No authentication",
});
}
}
@@ -88,7 +64,7 @@ export function defineClientEventHandler<T>(handler: EventHandlerFunction<T>) {
if (clientId === undefined)
throw createError({
statusCode: 500,
statusMessage: "Failed to execute authentication pipeline.",
message: "Failed to execute authentication pipeline.",
});
async function fetchClient() {
+91 -203
View File
@@ -1,228 +1,116 @@
import cacheHandler from "../cache";
import prisma from "../db/database";
import { sum } from "../../../utils/array";
import type { Game, GameVersion } from "~/prisma/client/client";
import { castManifest } from "../library/manifest";
import { createDownloadManifestDetails } from "../library/manifest";
import { castManifest } from "../library/manifest/utils";
export type GameSize = {
gameName: string;
size: number;
gameId: string;
export type GameVersionSize = {
versionId: string;
installSize: number;
downloadSize: number;
};
export type VersionSize = GameSize & {
latest: boolean;
};
type VersionsSizes = {
[versionName: string]: VersionSize;
};
type GameVersionsSize = {
[gameId: string]: VersionsSizes;
export type GameSizeBreakdown = {
diskSize: number;
versions: Array<GameVersionSize & { diskSize: number; name: string }>;
};
class GameSizeManager {
private gameVersionsSizesCache =
cacheHandler.createCache<GameVersionsSize>("gameVersionsSizes");
// All versions sizes combined
private gameSizesCache = cacheHandler.createCache<GameSize>("gameSizes");
cacheHandler.createCache<GameVersionSize>("versionSizes");
private gameBreakdownCache =
cacheHandler.createCache<GameSizeBreakdown>("gameBreakdown");
private async clearGameVersionsSizesCache() {
(await this.gameVersionsSizesCache.getKeys()).map((key) =>
this.gameVersionsSizesCache.remove(key),
);
}
private async clearGameSizesCache() {
(await this.gameSizesCache.getKeys()).map((key) =>
this.gameSizesCache.remove(key),
);
}
// All versions of a game combined
async getCombinedGameSize(gameId: string) {
const versions = await prisma.gameVersion.findMany({
where: { gameId },
});
const sizes = await Promise.all(
versions.map((version) => castManifest(version.dropletManifest).size),
);
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;
/***
* Gets the size of the game to the user:
* - installSize: size on disk after install
* - downloadSize: how many bytes are downloaded (but not necessarily stored)
*/
async getVersionSize(versionId: string): Promise<GameVersionSize | null> {
if (await this.gameVersionsSizesCache.has(versionId))
return await this.gameVersionsSizesCache.get(versionId);
try {
const { downloadSize, installSize } =
await createDownloadManifestDetails(versionId);
const result = {
downloadSize,
installSize,
versionId,
} satisfies GameVersionSize;
await this.gameVersionsSizesCache.set(versionId, result);
return result;
} catch {
return null;
}
const { dropletManifest } = (await prisma.gameVersion.findUnique({
where: { versionId },
}))!;
return castManifest(dropletManifest).size;
}
private async isLatestVersion(
gameVersions: GameVersion[],
version: GameVersion,
): Promise<boolean> {
return gameVersions.length > 0
? gameVersions[0].versionId === version.versionId
: false;
}
async getBiggestGamesLatestVersion(top: number): Promise<VersionSize[]> {
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,
},
/***
* Get the size of the game on disk
*/
async getVersionDiskSize(versionId: string): Promise<number | null> {
const version = await prisma.gameVersion.findUnique({
where: {
versionId,
},
select: {
dropletManifest: true,
},
});
await Promise.all(games.map((game) => this.cacheGameVersion(game)));
if (!version) return null;
return castManifest(version.dropletManifest).size;
}
async cacheGameVersion(
game: Game & { versions: GameVersion[] },
versionId?: string,
) {
const cacheVersion = async (version: GameVersion) => {
const size = await this.getGameVersionSize(game.id, version.versionId);
if (!version.versionId || !size) {
return;
}
const versionsSizes = {
[version.versionId]: {
size,
gameName: game.mName,
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)),
/**
* Calculate the total disk usage of a game
* @param gameId Game ID to calculate
* @returns Total **disk** size of the game
*/
async getGameDiskSize(gameId: string): Promise<number> {
const versions = await prisma.gameVersion.findMany({
where: { gameId },
select: {
versionId: true,
},
});
const sizes = await Promise.all(
versions.map((version) => this.getVersionDiskSize(version.versionId)),
);
return allGames
.filter((game) => game !== null)
.sort((gameA, gameB) => gameB.size - gameA.size)
.slice(0, top);
return sum(sizes.filter((v) => v !== null));
}
async deleteGameVersion(gameId: string, version: string) {
const game = await prisma.game.findFirst({ where: { id: gameId } });
if (game) {
await this.cacheCombinedGame(game);
}
const versionsSizes = await this.gameVersionsSizesCache.get(gameId);
if (!versionsSizes) {
return;
}
// Remove the version from the VersionsSizes object
const { [version]: _, ...updatedVersionsSizes } = versionsSizes;
await this.gameVersionsSizesCache.set(gameId, updatedVersionsSizes);
}
async getGameBreakdown(gameId: string): Promise<GameSizeBreakdown | null> {
const versions = await prisma.gameVersion.findMany({
where: { gameId },
orderBy: { versionIndex: "desc" },
select: { versionId: true, displayName: true, versionPath: true },
});
if (!versions) return null;
async deleteGame(gameId: string) {
this.gameSizesCache.remove(gameId);
this.gameVersionsSizesCache.remove(gameId);
const breakdownKey = `${gameId} ${versions.map((v) => v.versionId).join(" ")}`;
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 default manager;
export const gameSizeManager = new GameSizeManager();
export default gameSizeManager;
+9 -68
View File
@@ -16,10 +16,9 @@ import type { GameModel } from "~/prisma/client/models";
import { createHash } from "node:crypto";
import type { WorkingLibrarySource } from "~/server/api/v1/admin/library/sources/index.get";
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 { GameType, type Platform } from "~/prisma/client/enums";
import { castManifest } from "./manifest";
import { castManifest } from "./manifest/utils";
export function createGameImportTaskId(libraryId: string, libraryPath: string) {
return createHash("md5")
@@ -417,12 +416,10 @@ class LibraryManager {
manifest = await library.generateDropletManifest(
game.libraryPath,
versionPath,
(err, value) => {
if (err) throw err;
(value) => {
progress(value * 0.9);
},
(err, value) => {
if (err) throw err;
(value) => {
logger.info(value);
},
);
@@ -500,13 +497,12 @@ class LibraryManager {
acls: ["system:import:version:read"],
});
await libraryManager.cacheCombinedGameSize(gameId);
await libraryManager.cacheGameVersionSize(gameId, newVersion.versionId);
await TORRENTIAL_SERVICE.utils().invalidate(
gameId,
newVersion.versionId,
);
// Ensure cache is filled (also pre-caches the manifest)
try {
await gameSizeManager.getVersionSize(newVersion.versionId);
} catch (e) {
logger.warn(`Failed to pre-cache game size and manifest: ${e}`);
}
if (version.type === "depot") {
// 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);
}
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) {
await prisma.gameVersion.deleteMany({
where: {
@@ -552,8 +536,6 @@ class LibraryManager {
versionId: version,
},
});
await gameSizeManager.deleteGameVersion(gameId, version);
}
async deleteGame(gameId: string) {
@@ -562,7 +544,6 @@ class LibraryManager {
id: gameId,
},
});
await gameSizeManager.deleteGame(gameId);
// Delete all game versions that depended on this game
await prisma.gameVersion.deleteMany({
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();
@@ -1,14 +1,27 @@
import cacheHandler from "../../cache";
import prisma from "../../db/database";
import { castManifest, type DropletManifest } from "../manifest";
import { castManifest, type DropletManifest } from "./utils";
export type DownloadManifestDetails = {
/***
* Version ID to manifest
*/
manifests: { [key: string]: DropletManifest };
/***
* File name to version ID
*/
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 } {
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(
versionId: string,
refresh = false,
): Promise<DownloadManifestDetails> {
if ((await manifestCache.has(versionId)) && !refresh)
return (await manifestCache.get(versionId))!;
const mainVersion = await prisma.gameVersion.findUnique({
where: { versionId },
select: {
@@ -35,7 +51,7 @@ export async function createDownloadManifestDetails(
const collectedVersions = [];
let versionIndex = mainVersion.versionIndex;
while (true) {
while (mainVersion.delta) {
const nextVersion = await prisma.gameVersion.findFirst({
where: { gameId: mainVersion.gameId, versionIndex: { lt: versionIndex } },
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
const manifests = new Map<string, DropletManifest>();
for (const version of versionOrder) {
@@ -86,9 +105,22 @@ export async function createDownloadManifestDetails(
const fileNames = Object.fromEntries(files);
const manifest = castManifest(version.dropletManifest);
const filteredChunks = Object.fromEntries(
Object.entries(manifest.chunks).filter(([, chunkData]) =>
chunkData.files.some((fileEntry) => !!fileNames[fileEntry.filename]),
),
Object.entries(manifest.chunks).filter(([, chunkData]) => {
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, {
...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(
game: string,
version: string,
progress: (err: Error | null, v: number) => void,
log: (err: Error | null, v: string) => void,
progress: (v: number) => void,
log: (v: string) => void,
): Promise<string>;
abstract peekFile(
@@ -54,13 +54,6 @@ export abstract class LibraryProvider<CFG> {
filename: string,
): 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;
}
@@ -7,13 +7,8 @@ import {
import { LibraryBackend } from "~/prisma/client/enums";
import fs from "fs";
import path from "path";
import droplet, {
hasBackendForPath,
listFiles,
peekFile,
readFile,
} from "@drop-oss/droplet";
import { fsStats } from "~/server/internal/utils/files";
import { dropletInterface } from "../../services/torrential/droplet-interface";
export const FilesystemProviderConfig = type({
baseDir: "string",
@@ -64,57 +59,49 @@ export class FilesystemProvider
const gameDir = path.join(this.config.baseDir, game);
if (!fs.existsSync(gameDir)) throw new GameNotFoundError();
const versionDirs = fs.readdirSync(gameDir);
const validVersionDirs = versionDirs.filter((e) => {
if (ignoredVersions && ignoredVersions.includes(e)) return false;
const fullDir = path.join(this.config.baseDir, game, e);
return hasBackendForPath(fullDir);
});
const validVersionDirs = [];
for (const versionDir of versionDirs) {
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;
}
async versionReaddir(game: string, version: string): Promise<string[]> {
const versionDir = path.join(this.config.baseDir, game, version);
if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
return await listFiles(versionDir);
return await dropletInterface.listFiles(versionDir);
}
async generateDropletManifest(
game: string,
version: string,
progress: (err: Error | null, v: number) => void,
log: (err: Error | null, v: string) => void,
progress: (v: number) => void,
log: (v: string) => void,
): Promise<string> {
const versionDir = path.join(this.config.baseDir, game, version);
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;
}
async peekFile(game: string, version: string, filename: string) {
const filepath = path.join(this.config.baseDir, game, version);
if (!fs.existsSync(filepath)) return undefined;
const stat = await peekFile(filepath, filename);
const stat = await dropletInterface.peekFile(filepath, filename);
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() {
return fsStats(this.config.baseDir);
}
@@ -4,13 +4,8 @@ import { VersionNotFoundError } from "../provider";
import { LibraryBackend } from "~/prisma/client/enums";
import fs from "fs";
import path from "path";
import droplet, {
hasBackendForPath,
listFiles,
peekFile,
readFile,
} from "@drop-oss/droplet";
import { fsStats } from "~/server/internal/utils/files";
import { dropletInterface } from "../../services/torrential/droplet-interface";
export const FlatFilesystemProviderConfig = type({
baseDir: "string",
@@ -50,10 +45,15 @@ export class FlatFilesystemProvider
*/
async listGames() {
const versionDirs = fs.readdirSync(this.config.baseDir);
const validVersionDirs = versionDirs.filter((e) => {
const fullDir = path.join(this.config.baseDir, e);
return hasBackendForPath(fullDir);
});
const validVersionDirs = [];
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;
}
@@ -69,44 +69,31 @@ export class FlatFilesystemProvider
async versionReaddir(game: string, _version: string) {
const versionDir = path.join(this.config.baseDir, game);
if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
return await listFiles(versionDir);
return await dropletInterface.listFiles(versionDir);
}
async generateDropletManifest(
game: string,
_version: string,
progress: (err: Error | null, v: number) => void,
log: (err: Error | null, v: string) => void,
progress: (v: number) => void,
log: (v: string) => void,
) {
const versionDir = path.join(this.config.baseDir, game);
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;
}
async peekFile(game: string, _version: string, filename: string) {
const filepath = path.join(this.config.baseDir, game);
if (!fs.existsSync(filepath)) return undefined;
const stat = await peekFile(filepath, filename);
const stat = await dropletInterface.peekFile(filepath, filename);
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() {
return fsStats(this.config.baseDir);
+5 -1
View File
@@ -38,7 +38,7 @@ export class Service<T> {
private setup: Setup | undefined;
private healthcheck: Healthcheck | undefined;
private logger: Logger<never>;
logger: Logger<never>;
private currentProcess: ChildProcess | undefined;
@@ -90,6 +90,7 @@ export class Service<T> {
if (!process.env[disableEnv]) {
const serviceProcess = this.executor();
this.logger.info("service launched");
serviceProcess.on("close", async (code, signal) => {
serviceProcess.kill();
this.currentProcess = undefined;
@@ -99,12 +100,15 @@ export class Service<T> {
await new Promise((r) => setTimeout(r, 5000));
if (this.spun) this.launch();
});
serviceProcess.stdout?.on("data", (data) =>
this.logger.info(data.toString().trim()),
);
serviceProcess.stderr?.on("data", (data) =>
this.logger.error(data.toString().trim()),
);
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 { GlobalACL } from "../acls";
import aclManager from "../acls";
@@ -212,7 +211,7 @@ class TaskHandler {
await updateAllClients(true);
droplet.callAltThreadFunc(async () => {
const taskFunc = async () => {
const taskEntry = this.taskPool.get(task.id);
if (!taskEntry) throw new Error("No task entry");
const addAction = (action: TaskActionLink) => {
@@ -260,7 +259,9 @@ class TaskHandler {
});
this.taskPool.delete(task.id);
});
};
taskFunc();
return task.id;
}
+34
View File
@@ -1,5 +1,39 @@
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 () => {
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 { 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) => {
TORRENTIAL_SERVICE.register();
Submodule server/torrential updated: 0098bee3e0...50e54b6c60