diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8045e05..a3fb031 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,7 @@ on: jobs: web: - name: Push website Docker image to registry + name: Build Docker image runs-on: ubuntu-latest permissions: packages: write diff --git a/.gitmodules b/.gitmodules index e24bb0c..07aa3da 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "drop-base"] path = drop-base url = https://github.com/Drop-OSS/drop-base.git +[submodule "torrential"] + path = torrential + url = https://github.com/Drop-OSS/torrential.git diff --git a/.prettierignore b/.prettierignore index df3c464..20d033a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,5 @@ drop-base/ # file is fully managed by pnpm, no reason to break it pnpm-lock.yaml + +torrential/ diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..30581eb --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "jsonRecursiveSort": true, + "jsonSortOrder": "{\"/.*/\": \"lexical\"}", + "plugins": ["prettier-plugin-sort-json"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 76d084c..0d0624e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,37 +1,39 @@ { - "spellchecker.ignoreWordsList": ["mTLS", "Wireguard"], - "sqltools.connections": [ - { - "previewLimit": 50, - "server": "localhost", - "port": 5432, - "driver": "PostgreSQL", - "name": "drop", - "database": "drop", - "username": "drop", - "password": "drop" - } - ], // allow autocomplete for ArkType expressions like "string | num" "editor.quickSuggestions": { "strings": "on" }, - // prioritize ArkType's "type" for autoimports - "typescript.preferences.autoImportSpecifierExcludeRegexes": ["^(node:)?os$"], - // i18n Ally settings - "i18n-ally.sortKeys": true, - "i18n-ally.keepFulfilled": true, "i18n-ally.extract.autoDetect": true, - "i18n-ally.localesPaths": ["i18n", "i18n/locales"], - "i18n-ally.keystyle": "nested", "i18n-ally.extract.ignored": [ "string >= 14", "string.alphanumeric >= 5", "/api/v1/admin/import/version/preload?id=${encodeURIComponent(\n gameId,\n )}&version=${encodeURIComponent(version)}" ], "i18n-ally.extract.ignoredByFiles": { - "pages/admin/library/sources/index.vue": ["Filesystem"], "components/NewsArticleCreateButton.vue": ["[", "`", "Enter"], + "pages/admin/library/sources/index.vue": ["Filesystem"], "server/api/v1/auth/signin/simple.post.ts": ["boolean | undefined"] - } + }, + "i18n-ally.keepFulfilled": true, + "i18n-ally.keystyle": "nested", + "i18n-ally.localesPaths": ["i18n", "i18n/locales"], + // i18n Ally settings + "i18n-ally.sortKeys": true, + "prisma.pinToPrisma6": true, + "spellchecker.ignoreWordsList": ["mTLS", "Wireguard"], + "sqltools.connections": [ + { + "database": "drop", + "driver": "PostgreSQL", + "name": "drop", + "password": "drop", + "port": 5432, + "previewLimit": 50, + "server": "localhost", + "username": "drop" + } + ], + "typescript.experimental.useTsgo": true, + // prioritize ArkType's "type" for autoimports + "typescript.preferences.autoImportSpecifierExcludeRegexes": ["^(node:)?os$"] } diff --git a/Dockerfile b/Dockerfile index 5003fb4..fdfdaea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,46 +6,54 @@ ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable WORKDIR /app -# so corepack knows pnpm's version +## so corepack knows pnpm's version COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -# prevent prompt to download +## prevent prompt to download ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 -# setup for offline +## setup for offline RUN corepack pack -# don't call out to network anymore +## don't call out to network anymore ENV COREPACK_ENABLE_NETWORK=0 -### Unified deps builder +### INSTALL DEPS ONCE FROM base AS deps RUN pnpm install --frozen-lockfile --ignore-scripts -### Build for app +### BUILD TORRENTIAL +FROM rustlang/rust:nightly-alpine AS torrential-build +RUN apk add musl-dev +WORKDIR /build +COPY torrential . +RUN cargo build --release + +### BUILD APP FROM base AS build-system ENV NODE_ENV=production ENV NUXT_TELEMETRY_DISABLED=1 -# add git so drop can determine its git ref at build +## add git so drop can determine its git ref at build RUN apk add --no-cache git -# copy deps and rest of project files +## copy deps and rest of project files COPY --from=deps /app/node_modules ./node_modules COPY . . ARG BUILD_DROP_VERSION ARG BUILD_GIT_REF -# build +## build RUN pnpm run postinstall && pnpm run build -### create run environment for Drop + +# create run environment for Drop FROM base AS run-system ENV NODE_ENV=production ENV NUXT_TELEMETRY_DISABLED=1 # RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn add --network-timeout 1000000 --no-lockfile --ignore-scripts prisma@6.11.1 -RUN apk add --no-cache pnpm 7zip +RUN apk add --no-cache pnpm 7zip nginx RUN pnpm install prisma@6.11.1 # init prisma to download all required files RUN pnpm prisma init @@ -54,8 +62,11 @@ COPY --from=build-system /app/prisma.config.ts ./ COPY --from=build-system /app/.output ./app COPY --from=build-system /app/prisma ./prisma COPY --from=build-system /app/build ./startup +COPY --from=build-system /app/build/nginx.conf /nginx.conf +COPY --from=torrential-build /build/target/release/torrential /usr/bin/ ENV LIBRARY="/library" ENV DATA="/data" +ENV NGINX_CONFIG="/nginx.conf" CMD ["sh", "/app/startup/launch.sh"] diff --git a/app.vue b/app.vue index 26c93ff..a64e442 100644 --- a/app.vue +++ b/app.vue @@ -29,10 +29,11 @@ await updateUser(); const user = useUser(); const apiDetails = await $dropFetch("/api/v1"); +const clientMode = isClientRequest(); const showExternalUrlWarning = ref(false); function checkExternalUrl() { - if (!import.meta.client) return; + if (!import.meta.client || clientMode) return; const realOrigin = window.location.origin.trim(); const chosenOrigin = apiDetails.external.trim(); const ignore = window.localStorage.getItem("ignoreExternalUrl"); @@ -51,15 +52,3 @@ if (user.value?.admin) { }); } - - diff --git a/build/nginx.conf b/build/nginx.conf new file mode 100644 index 0000000..d5f678d --- /dev/null +++ b/build/nginx.conf @@ -0,0 +1,41 @@ +worker_processes 1; + +events { + worker_connections 1024; +} + +pid nginx.pid; +error_log stderr; +daemon off; + +http { + default_type application/octet-stream; + + sendfile on; + server_tokens off; + + access_log nginx_host.access.log; + client_body_temp_path client_body; + fastcgi_temp_path fastcgi_temp; + proxy_temp_path proxy_temp; + scgi_temp_path scgi_temp; + uwsgi_temp_path uwsgi_temp; + + server { + listen 8080; + server_name localhost; + + location / { + proxy_pass http://localhost:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + location /api/v1/depot/ { + proxy_pass http://localhost:5000; + proxy_read_timeout 300s; + proxy_connect_timeout 75s; + } + } +} diff --git a/components/AccountSidebar.vue b/components/AccountSidebar.vue index 2c0a3ec..b4e2b89 100644 --- a/components/AccountSidebar.vue +++ b/components/AccountSidebar.vue @@ -53,10 +53,17 @@ import type { Component } from "vue"; const notifications = useNotifications(); const { t } = useI18n(); -const navigation: (NavigationItem & { icon: Component; count?: number })[] = [ - { label: t("home"), route: "/account", icon: HomeIcon, prefix: "/account" }, +const navigation: Ref< + (NavigationItem & { icon: Component; count?: number })[] +> = computed(() => [ { - label: t("security"), + label: t("account.home.title"), + route: "/account", + icon: HomeIcon, + prefix: "/account", + }, + { + label: t("account.security.title"), route: "/account/security", prefix: "/account/security", icon: LockClosedIcon, @@ -67,6 +74,12 @@ const navigation: (NavigationItem & { icon: Component; count?: number })[] = [ prefix: "/account/devices", icon: DevicePhoneMobileIcon, }, + { + label: t("account.token.title"), + route: "/account/tokens", + prefix: "/account/tokens", + icon: CodeBracketIcon, + }, { label: t("account.notifications.notifications"), route: "/account/notifications", @@ -74,19 +87,13 @@ const navigation: (NavigationItem & { icon: Component; count?: number })[] = [ icon: BellIcon, count: notifications.value.length, }, - { - label: t("account.token.title"), - route: "/account/tokens", - prefix: "/account/tokens", - icon: CodeBracketIcon, - }, { label: t("account.settings"), route: "/account/settings", prefix: "/account/settings", icon: WrenchScrewdriverIcon, }, -]; +]); -const currentPageIndex = useCurrentNavigationIndex(navigation); +const currentPageIndex = useCurrentNavigationIndex(navigation.value); diff --git a/components/Auth/Simple.vue b/components/Auth/Simple.vue index 7324467..2d9a50a 100644 --- a/components/Auth/Simple.vue +++ b/components/Auth/Simple.vue @@ -12,7 +12,7 @@ v-model="username" name="username" type="username" - autocomplete="username" + autocomplete="username webauthn" required class="block w-full rounded-md border-0 py-1.5 px-3 shadow-sm bg-zinc-950/20 text-zinc-300 ring-1 ring-inset ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" /> @@ -86,25 +86,60 @@ diff --git a/components/CodeInput.vue b/components/CodeInput.vue new file mode 100644 index 0000000..4fbec97 --- /dev/null +++ b/components/CodeInput.vue @@ -0,0 +1,86 @@ + + keydown(i - 1, v)" + @input="() => input(i - 1)" + @focusin="() => select(i - 1)" + @paste="(v) => paste(i - 1, v)" + /> + + + diff --git a/components/ExecutorWidget.vue b/components/ExecutorWidget.vue new file mode 100644 index 0000000..a7d466c --- /dev/null +++ b/components/ExecutorWidget.vue @@ -0,0 +1,41 @@ + + + + + {{ executor.gameName }} + + + + + + {{ executor.versionName }} + + + + + + {{ executor.launchName }} + + + + + diff --git a/components/GameEditor/Metadata.vue b/components/GameEditor/Metadata.vue index be224af..d4bb165 100644 --- a/components/GameEditor/Metadata.vue +++ b/components/GameEditor/Metadata.vue @@ -1,7 +1,7 @@ - + - + {{ game.mName }} - + {{ game.mShortDescription }} @@ -28,7 +30,7 @@ - + diff --git a/components/ImportVersionLaunchRow.vue b/components/ImportVersionLaunchRow.vue new file mode 100644 index 0000000..c51432b --- /dev/null +++ b/components/ImportVersionLaunchRow.vue @@ -0,0 +1,253 @@ + + + + + + + + + + + + + + The installation directory is set as the current directory when + launching. It is not prepended to your command. + + + {{ $t("library.admin.import.version.installDir") }} + + updateLaunchCommand(v)" + > + + + + + + + + + + + {{ guess.filename }} + + + + + + + + + + + + {{ launchProcessQuery }} + + + + + + + + + + + + + + {{ $t("library.admin.import.version.platform") }} + + + + Executor + + + + + No executor selected + + + Select new executor + (executor = undefined)" + > + + + + + (executor = v)" + /> + + + + diff --git a/components/Modal/AddCompanyGame.vue b/components/Modal/AddCompanyGame.vue index a7569bd..7ee8c47 100644 --- a/components/Modal/AddCompanyGame.vue +++ b/components/Modal/AddCompanyGame.vue @@ -11,66 +11,7 @@ addGame()"> - - {{ $t("library.admin.import.selectGameSearch") }} - - - - - {{ $t("library.admin.import.selectGamePlaceholder") }} - - - - - - - - - - - - - - - {{ $t("library.admin.metadata.companies.addGame.noGames") }} - - - - - + import { ref } from "vue"; import type { GameModel } from "~/prisma/client/models"; -import { - DialogTitle, - Listbox, - ListboxButton, - ListboxLabel, - ListboxOption, - ListboxOptions, -} from "@headlessui/vue"; -import type { GameMetadataSearchResult } from "~/server/internal/metadata/types"; +import { DialogTitle } from "@headlessui/vue"; import { FetchError } from "ofetch"; import type { SerializeObject } from "nitropack"; import { XCircleIcon } from "@heroicons/vue/24/solid"; +import type { GameMetadataSearchResult } from "~/server/internal/metadata/types"; const props = defineProps<{ companyId: string; @@ -189,26 +123,11 @@ const emit = defineEmits<{ ]; }>(); -const games = await $dropFetch("/api/v1/admin/game"); -const metadataGames = computed(() => - games - .filter((e) => !(props.exclude ?? []).includes(e.id)) - .map( - (e) => - ({ - id: e.id, - name: e.mName, - icon: useObject(e.mIconObjectId), - description: e.mShortDescription, - }) satisfies Omit, - ), -); - const { t } = useI18n(); const open = defineModel({ required: true }); -const currentGame = ref<(typeof metadataGames.value)[number]>(); +const currentGame = ref(); const developed = ref(false); const published = ref(false); const addGameLoading = ref(false); @@ -243,4 +162,8 @@ async function addGame() { open.value = false; } } + +async function search(query: string) { + return await $dropFetch("/api/v1/admin/search/game", { query: { q: query } }); +} diff --git a/components/Modal/SelectLaunch.vue b/components/Modal/SelectLaunch.vue new file mode 100644 index 0000000..e90c7de --- /dev/null +++ b/components/Modal/SelectLaunch.vue @@ -0,0 +1,229 @@ + + + + + + Pick a launch option + + + Select a launch option as an executor for your new launch option. + + + + Only showing launches for: + + + + {{ + props.filterPlatform + }} + + + + + + Game: + updateGame(value)" + /> + + + No versions imported. + + + Version: + + + {{ value.name }} + + + + + Launch: + (launchId = v)" + > + + + + {{ value.name }} + + {{ value.command }} + + + + + + + + + + + + + + {{ error }} + + + + + + + + Select + + (open = false)" + > + {{ $t("cancel") }} + + + + + + diff --git a/components/NotificationItem.vue b/components/NotificationItem.vue index b81ee30..ba9cee3 100644 --- a/components/NotificationItem.vue +++ b/components/NotificationItem.vue @@ -24,7 +24,6 @@ > {{ name }} - diff --git a/components/Selector/Combox.vue b/components/Selector/Combox.vue new file mode 100644 index 0000000..eb32fc8 --- /dev/null +++ b/components/Selector/Combox.vue @@ -0,0 +1,91 @@ + + + + + + + + + + + No results. + + + + + + + + + + + + + + + + + + diff --git a/components/Selector/Game.vue b/components/Selector/Game.vue new file mode 100644 index 0000000..8505889 --- /dev/null +++ b/components/Selector/Game.vue @@ -0,0 +1,132 @@ + + + + + + + + + + + Type at least 4 characters to get results + + + + + + + + + No results. + + + + + + + + + + + + + + + + + + diff --git a/components/LanguageSelector.vue b/components/Selector/Language.vue similarity index 95% rename from components/LanguageSelector.vue rename to components/Selector/Language.vue index 3f84e80..d9b99a4 100644 --- a/components/LanguageSelector.vue +++ b/components/Selector/Language.vue @@ -1,6 +1,6 @@ - + diff --git a/components/PlatformSelector.vue b/components/Selector/Platform.vue similarity index 92% rename from components/PlatformSelector.vue rename to components/Selector/Platform.vue index 1168b04..4b73c18 100644 --- a/components/PlatformSelector.vue +++ b/components/Selector/Platform.vue @@ -32,7 +32,7 @@ class="absolute z-10 mt-1 max-h-56 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-zinc-950 ring-opacity-5 focus:outline-none sm:text-sm" > (); +const model = defineModel(); -const typedModel = computed({ +const typedModel = computed({ get() { return model.value || null; }, @@ -95,5 +96,5 @@ const typedModel = computed({ }, }); -const values = Object.fromEntries(Object.entries(PlatformClient)); +const values = Object.entries(Platform); diff --git a/components/SourceTable.vue b/components/SourceTable.vue index e9491e5..b794eeb 100644 --- a/components/SourceTable.vue +++ b/components/SourceTable.vue @@ -56,18 +56,14 @@ - + {{ source.name }} - {{ option.name }} - {{ sortOrder === "asc" ? $t("↑") : $t("↓") }} + {{ + sortOrder === "asc" + ? $t("chars.arrowUp") + : $t("chars.arrowDown") + }} @@ -291,7 +295,7 @@ > - = [ name: "Platform", param: "platform", multiple: true, - options: Object.values(PlatformClient).map((e) => ({ name: e, param: e })), + options: Object.values(Platform).map((e) => ({ name: e, param: e })), }, ...(props.extraOptions ?? []), ]; diff --git a/components/TaskWidget.vue b/components/TaskWidget.vue index 3738e10..fcd93c6 100644 --- a/components/TaskWidget.vue +++ b/components/TaskWidget.vue @@ -29,6 +29,15 @@ + + {{ name }} + - + string; + } + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface ComponentCustomOptions extends _ComponentCustomOptions {} +} + +export interface ExecutorLaunchObject { + launchId: string; + gameName: string; + gameIcon: string; + versionName: string; + launchName: string; + platform: Platform; +} diff --git a/composables/icons.ts b/composables/icons.ts index 247cf83..d56cfcf 100644 --- a/composables/icons.ts +++ b/composables/icons.ts @@ -1,8 +1,8 @@ import { IconsLinuxLogo, IconsWindowsLogo, IconsMacLogo } from "#components"; -import { PlatformClient } from "./types"; +import { Platform } from "~/prisma/client/enums"; export const PLATFORM_ICONS = { - [PlatformClient.Linux]: IconsLinuxLogo, - [PlatformClient.Windows]: IconsWindowsLogo, - [PlatformClient.macOS]: IconsMacLogo, + [Platform.Linux]: IconsLinuxLogo, + [Platform.Windows]: IconsWindowsLogo, + [Platform.macOS]: IconsMacLogo, }; diff --git a/composables/kjua.d.ts b/composables/kjua.d.ts new file mode 100644 index 0000000..fd0625f --- /dev/null +++ b/composables/kjua.d.ts @@ -0,0 +1 @@ +declare module "kjua"; diff --git a/composables/request.ts b/composables/request.ts index 2283366..1b5a8ea 100644 --- a/composables/request.ts +++ b/composables/request.ts @@ -16,7 +16,7 @@ interface DropFetch< O extends NitroFetchOptions = NitroFetchOptions, >( request: R, - opts?: O & { failTitle?: string }, + opts?: O & { failTitle?: string; params?: { [key: string]: string } }, ): Promise< // sometimes there is an error, other times there isn't // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -60,7 +60,7 @@ export const $dropFetch: DropFetch = async (rawRequest, opts) => { { title: opts.failTitle, description: - (e as FetchError)?.statusMessage ?? (e as string).toString(), + (e as FetchError)?.data?.message ?? (e as string).toString(), //buttonText: $t("common.close"), }, (_, c) => c(), @@ -89,3 +89,15 @@ export const $dropFetch: DropFetch = async (rawRequest, opts) => { if (import.meta.server) state.value = data; return data; }; + +export function isClientRequest() { + const existingState = useState("clientMode", () => false); + if (import.meta.server) { + const headers = useRequestHeaders(["User-Agent"]); + const calculatedClientRequest = + headers["user-agent"] == "Drop Desktop Client"; + existingState.value = calculatedClientRequest; + } + + return existingState.value; +} diff --git a/composables/task.ts b/composables/task.ts index e4405be..fccd4be 100644 --- a/composables/task.ts +++ b/composables/task.ts @@ -52,6 +52,7 @@ websocketHandler.listen((message) => { progress: 0, error: undefined, log: [], + actions: [], }; state.value.error = { title, description }; break; diff --git a/composables/types.ts b/composables/types.ts index 87b2a25..26350f3 100644 --- a/composables/types.ts +++ b/composables/types.ts @@ -11,9 +11,3 @@ export type QuickActionNav = { notifications?: Ref; action: () => Promise; }; - -export enum PlatformClient { - Windows = "Windows", - Linux = "Linux", - macOS = "macOS", -} diff --git a/composables/user.ts b/composables/user.ts index 6851398..c29b069 100644 --- a/composables/user.ts +++ b/composables/user.ts @@ -11,3 +11,12 @@ export const updateUser = async () => { user.value = await $dropFetch("/api/v1/user"); }; + +export async function completeSignin() { + const route = useRoute(); + const router = useRouter(); + + const user = useUser(); + user.value = await $dropFetch("/api/v1/user"); + router.push(route.query.redirect?.toString() ?? "/"); +} diff --git a/deploy-template/compose.yml b/deploy-template/compose.yml index 16ca2f4..423c7de 100644 --- a/deploy-template/compose.yml +++ b/deploy-template/compose.yml @@ -2,8 +2,6 @@ services: postgres: # using alpine image to reduce image size image: postgres:alpine - ports: - - 5432:5432 healthcheck: test: pg_isready -d drop -U drop interval: 30s diff --git a/drop-base b/drop-base index 06bea06..2f0ed58 160000 --- a/drop-base +++ b/drop-base @@ -1 +1 @@ -Subproject commit 06bea063633ed4bf7513e29ad7149bc067561199 +Subproject commit 2f0ed58cb3f678bc2aec9f546e09ea9b91f9fcfa diff --git a/drop.session.sql b/drop.session.sql new file mode 100644 index 0000000..b12df24 --- /dev/null +++ b/drop.session.sql @@ -0,0 +1 @@ +DELETE FROM "Session" WHERE 1=1; diff --git a/error.vue b/error.vue index 94c0cf1..ce00b95 100644 --- a/error.vue +++ b/error.vue @@ -10,10 +10,10 @@ const props = defineProps({ const { t } = useI18n(); const route = useRoute(); -const user = useUser(); const statusCode = props.error?.statusCode; -const message = - props.error?.message || props.error?.statusMessage || t("errors.unknown"); +const message = props.error?.data + ? JSON.parse(props.error.data as string).message + : props.error.cause || props.error?.statusMessage || t("errors.unknown"); const showSignIn = statusCode ? statusCode == 403 || statusCode == 401 : false; async function signIn() { @@ -49,7 +49,7 @@ if (import.meta.client) { class="mx-auto w-full max-w-7xl px-6 py-24 sm:py-32 lg:col-span-2 lg:col-start-1 lg:row-start-2 lg:px-8" > - + {{ error?.statusCode }} {{ message }} + {{ $t("errors.occurred") }} - - + @@ -79,7 +80,7 @@ if (import.meta.client) { {{ $t("chars.arrowBack") }} - + '}", "developed": "Developed", "libraryDescription": "Add, remove, or customise what this company has developed and/or published.", "libraryTitle": "Game Library", @@ -421,8 +410,6 @@ }, "metadataProvider": "Metadata provider", "noGames": "No games imported", - "libraryHint": "No libraries configured.", - "libraryHintDocsLink": "What does this mean? {arrow}", "offline": "Drop couldn't access this game.", "offlineTitle": "Game offline", "openEditor": "Open in Editor {arrow}", @@ -434,6 +421,7 @@ "desc": "Configure your library sources, where Drop will look for new games and versions to import.", "documentationLink": "Documentation {arrow}", "edit": "Edit source", + "freeSpace": "Free space", "fsDesc": "Imports games from a path on disk. Requires version-based folder structure, and supports archived games.", "fsFlatDesc": "Imports games from a path on disk, but without a separate version subfolder. Useful when migrating an existing library to Drop.", "fsFlatTitle": "Compatibility", @@ -445,21 +433,16 @@ "nameDesc": "The name of your source, for reference.", "namePlaceholder": "My New Source", "sources": "Library Sources", - "typeDesc": "The type of your source. Changes the required options.", - "working": "Working?", - "freeSpace": "Free space", "totalSpace": "Total space", + "typeDesc": "The type of your source. Changes the required options.", "utilizationPercentage": "Utilization percentage", - "percentage": "{number}%" + "working": "Working?" }, "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": { - "delta": "Upgrade mode", - "noVersions": "You have no versions of this game available.", - "noVersionsAdded": "no versions added" - }, - "versionPriority": "Version priority" + "noVersions": "You have no versions of this game available." + } }, "back": "Back to Library", "collection": { @@ -482,7 +465,6 @@ "search": "Search library…", "subheader": "Organize your games into collections for easy access, and access all your games." }, - "lowest": "lowest", "news": { "article": { "add": "Add", @@ -515,11 +497,19 @@ "title": "Latest News" }, "options": "Options", - "security": "Security", "selectLanguage": "Select language", + "services": { + "nginx": { + "description": "Built-in simple reverse proxy to connect all the Drop components together.", + "title": "NGINX" + }, + "torrential": { + "description": "The internal download server for Drop.", + "title": "Torrential" + } + }, "settings": { "admin": { - "description": "Configure Drop settings", "store": { "dropGameAltPlaceholder": "Example Game icon", "dropGameDescriptionPlaceholder": "This is an example game. It will be replaced if you import a game.", @@ -563,10 +553,8 @@ "welcomeDescription": "Welcome to Drop setup wizard. It will walk you through configuring Drop for the first time, and how it works." }, "store": { - "about": "About", "commingSoon": "coming soon", "developers": "Developers | Developer | Developers", - "exploreMore": "Explore more {arrow}", "featured": "Featured", "images": "Game Images", "lookAt": "Check it out", @@ -580,15 +568,12 @@ "openFeatured": "Star games in Admin Library {arrow}", "platform": "Platform | Platform | Platforms", "publishers": "Publishers | Publisher | Publishers", - "size": "Size", "rating": "Rating", - "readLess": "Click to read less", - "readMore": "Click to read more", "recentlyAdded": "Recently Added", "recentlyReleased": "Recently released", - "recentlyUpdated": "Recently Updated", "released": "Released", "reviews": "({0} Reviews)", + "size": "Size", "tags": "Tags", "title": "Store", "view": { @@ -597,15 +582,16 @@ "srGames": "Games", "srViewGrid": "View grid" }, - "viewInStore": "View in Store", - "website": "Website" + "viewInStore": "View in Store" }, "tasks": { "admin": { "back": "{arrow} Back to Tasks", "completedTasksTitle": "Completed tasks", "dailyScheduledTitle": "Daily scheduled tasks", + "execute": "{arrow} Execute", "noTasksRunning": "No tasks currently running", + "progress": "{0}%", "runningTasksTitle": "Running tasks", "scheduled": { "checkUpdateDescription": "Check if Drop has an update.", @@ -618,9 +604,7 @@ "cleanupSessionsName": "Clean up sessions." }, "viewTask": "View {arrow}", - "weeklyScheduledTitle": "Weekly scheduled tasks", - "progress": "{0}%", - "execute": "{arrow} Execute" + "weeklyScheduledTitle": "Weekly scheduled tasks" } }, "title": "Drop", @@ -630,29 +614,23 @@ "upload": "Upload", "uploadFile": "Upload file", "user": { - "unknown": "Unknown user", "editProfile": "Edit profile", + "noActivity": "No recent activity", + "notFound": "User not found", "recent": "Recent activity (TODO)", "recentSub": "Recent activity by this user", - "notFound": "User not found", - "noActivity": "No recent activity" + "unknown": "Unknown user" }, "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": { "adminHeader": "Admin?", "adminUserLabel": "Admin user", + "authLink": "Authentication {arrow}", "authentication": { "configure": "Configure", "description": "Drop supports a variety of \"authentication mechanisms\". As you enable or disable them, they are shown on the sign in screen for users to select from. Click the dot menu to configure the authentication mechanism.", @@ -664,7 +642,6 @@ "srOpenOptions": "Open options", "title": "Authentication" }, - "authLink": "Authentication {arrow}", "authoptionsHeader": "Auth Options", "delete": "Delete", "deleteUser": "Delete user {0}", diff --git a/i18n/locales/fr.json b/i18n/locales/fr.json index 9424c27..91a87b4 100644 --- a/i18n/locales/fr.json +++ b/i18n/locales/fr.json @@ -341,7 +341,7 @@ "installDir": "(install_dir)/", "launchCmd": "Lancer l'exécutable/commande", "launchDesc": "Exécutable pour lancer le jeu", - "launchPlaceholder": "jeu.exe", + "launchPlaceholder": "jeu.exe --args", "loadingVersion": "Chargement des métadonnées de la version…", "noAdv": "Pas d'option avancée pour cette configuration.", "noVersions": "Pas de version à importer", @@ -479,7 +479,6 @@ "search": "Chercher bibliothèque…", "subheader": "Organiser vos jeux en collections pour un accès facile, et accéder à tous vos jeux." }, - "lowest": "le plus bas", "news": { "article": { "add": "Ajouter", @@ -512,7 +511,6 @@ "title": "Dernières Nouvelles" }, "options": "Options", - "security": "Sécurité", "selectLanguage": "Sélectionner la langue", "settings": { "admin": { diff --git a/i18n/scripts/detect-keys.ts b/i18n/scripts/detect-keys.ts new file mode 100644 index 0000000..54f3e4b --- /dev/null +++ b/i18n/scripts/detect-keys.ts @@ -0,0 +1,32 @@ +import type { Localisation } from "./utils"; +import { + allLocalisableFiles, + flattenLocalisation, + keysFromContent, + stripEquivalence, +} from "./utils"; +import fs from "node:fs"; + +const files = allLocalisableFiles(); + +const keySet = new Map(); + +for (const file of files) { + const content = fs.readFileSync(file, "utf-8"); + const keys = keysFromContent(content); + keys.forEach((key) => keySet.set(key, file)); +} + +const localeFile: Localisation = JSON.parse( + fs.readFileSync("./i18n/locales/en_us.json", "utf-8"), +); +const flattenedLocalisation = flattenLocalisation(localeFile); + +for (const [key, file] of keySet.entries()) { + console.log(stripEquivalence(flattenedLocalisation.get(key)!)); + + if (!flattenedLocalisation.delete(key)) + throw new Error( + `Found key "${key}" in file ${file} that doesn't exist in localisation`, + ); +} diff --git a/i18n/scripts/rewrite-keys.ts b/i18n/scripts/rewrite-keys.ts new file mode 100644 index 0000000..e77406a --- /dev/null +++ b/i18n/scripts/rewrite-keys.ts @@ -0,0 +1,54 @@ +import fs from "node:fs"; +import type { Localisation } from "./utils"; +import { + allLocalisableFiles, + fetchLocalisation, + keysFromContent, +} from "./utils"; + +const files = allLocalisableFiles(); +const localeFile: Localisation = JSON.parse( + fs.readFileSync("./i18n/locales/en_us.json", "utf-8"), +); + +const keepPrefixes = ["error", "common", "chars"]; +const keyMap: Map = new Map(); + +for (const file of files) { + const content = fs.readFileSync(file, "utf-8"); + const keys = keysFromContent(content); + + const fileNoExtension = file.slice(0, file.lastIndexOf(".")); + + for (const key of keys) { + const _value = fetchLocalisation(localeFile, key); + + const newKeySuffix = key.split(".").slice(-1); /*value + .replaceAll(/[^a-zA-Z\s]/g, "") + .toLowerCase() + .split(" ") + .slice(0, 3) + .map((v, i) => + v + ? i > 0 + ? v[0].toUpperCase() + v.slice(1) + : v + : key.split(".").slice(-1), + ) + .join("");*/ + + const newKey = [ + ...fileNoExtension + .replaceAll(/[^a-zA-Z0-9/]/g, "") + .toLowerCase() + .split("/"), + newKeySuffix, + ].join("."); + + const finalKey = keepPrefixes.some((v) => key.startsWith(v)) ? key : newKey; + + keyMap.set(key, finalKey); + } +} + +console.log(keyMap); diff --git a/i18n/scripts/utils.ts b/i18n/scripts/utils.ts new file mode 100644 index 0000000..a104df8 --- /dev/null +++ b/i18n/scripts/utils.ts @@ -0,0 +1,122 @@ +import path from "node:path"; +import fs from "node:fs"; +import prettier from "prettier"; +const prettierConfig = JSON.parse( + fs.readFileSync("./.prettierrc.json", "utf-8"), +); + +const paths = ["./components", "./layouts", "./pages", "./server"]; +const constPaths = ["error.vue", "app.vue"]; +const extensions = [".vue", ".ts"]; + +function recursiveFindFiles(root: string): string[] { + const results = []; + const subpaths = fs.readdirSync(root); + for (const subpath of subpaths) { + const absPath = path.join(root, subpath); + if (extensions.some((v) => absPath.endsWith(v))) { + results.push(absPath); + continue; + } + const stat = fs.statSync(absPath); + if (stat.isDirectory()) { + results.push(...recursiveFindFiles(absPath)); + continue; + } + } + return [...results, ...constPaths]; +} + +/** + * Fetches the paths of all files available to be localised + */ +export function allLocalisableFiles(): string[] { + const files = paths.map((k) => recursiveFindFiles(k)).flat(); + + return files; +} + +const I18N_UTIL_REGEX = /(?<=[^a-zA-Z]t\(\s*?["']).*?(?=["'])/g; +const I18N_KEYPATH_REGEX = /(?<=keypath=["']).*?(?=["'])/g; +/** + * Uses regex to match all i18n keys in content + * @param content The file content to match against + */ +export function keysFromContent(content: string): string[] { + const matches = [ + ...content.matchAll(I18N_UTIL_REGEX), + ...content.matchAll(I18N_KEYPATH_REGEX), + ]; + return matches.map((v) => v[0]); +} + +export type Localisation = { [key: string]: Localisation | string }; + +export function flattenLocalisation(localisation: Localisation) { + const map = new Map(); + flattenLocalisationRecursive(map, [], localisation); + return map; +} + +function flattenLocalisationRecursive( + map: Map, + key: string[], + localisationBranch: Localisation | string, +) { + if (typeof localisationBranch === "string") { + map.set(key.join("."), localisationBranch); + return; + } + for (const [subKey, value] of Object.entries(localisationBranch)) { + const newKey = [...key, subKey]; + flattenLocalisationRecursive(map, newKey, value); + } +} + +export function deleteLocalisation(localisation: Localisation, key: string) { + const parts = key.split("."); + let current: Localisation | string = localisation; + for (const part of parts.slice(0, -1)) { + if (typeof current === "string") + throw new Error(`${key} not found in localisation`); + current = current[part]; + } + if (typeof current === "string") + throw new Error(`${key} not found in localisation`); + + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete current[parts.at(-1)!]; +} + +export function fetchLocalisation( + localisation: Localisation, + key: string, +): string { + const parts = key.split("."); + let current: Localisation | string = localisation; + for (const part of parts.slice(0, -1)) { + if (typeof current === "string") + throw new Error(`${key} not found in localisation`); + current = current[part]; + } + if (typeof current === "string") + throw new Error(`${key} not found in localisation`); + + return current[parts.at(-1)!] as string; +} + +export async function writeJSON(path: string, object: T) { + const flatStr = JSON.stringify(object); + const formatted = await prettier.format(flatStr, { + parser: "json", + ...prettierConfig, + }); + fs.writeFileSync(path, formatted); +} + +/** + * Strips some sort of English language string down to something that can be compared to be basically equivalent + */ +export function stripEquivalence(value: string): string { + return value.replaceAll(/[.,\s]/g, "").toLowerCase(); +} diff --git a/layouts/default.vue b/layouts/default.vue index 42288c0..5b45363 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -1,5 +1,8 @@ - + @@ -12,9 +15,8 @@ diff --git a/pages/admin/library/[id]/import.vue b/pages/admin/library/[id]/import.vue index 485a57c..84b89e4 100644 --- a/pages/admin/library/[id]/import.vue +++ b/pages/admin/library/[id]/import.vue @@ -1,5 +1,5 @@ - + - + - - {{ $t("library.admin.import.version.setupCmd") }} - - {{ $t("library.admin.import.version.setupDesc") }} - - - - - {{ $t("library.admin.import.version.installDir") }} - - updateSetupCommand(v)" - > - - - - - - - - - - {{ guess.filename }} - - - - - - - - - - - - {{ $t("chars.quoted", { text: setupProcessQuery }) }} - - - - - - - - - - - - + + + {{ + $t("library.admin.import.version.setupCmd") + }} + + {{ $t("library.admin.import.version.setupDesc") }} + + + + + versionSettings.setups.splice(launchIdx, 1)" + > + + + + + {{ $t("library.admin.import.version.noSetups") }} + versionSettings.setups.push({} as any)" + >{{ $t("common.add") }} - + @@ -233,143 +153,62 @@ /> - - {{ $t("library.admin.import.version.launchCmd") }} - - {{ $t("library.admin.import.version.launchDesc") }} - - - - {{ $t("library.admin.import.version.installDir") }} - updateLaunchCommand(v)" - > - - - - - - - - - - - {{ guess.filename }} - - - - - - - - - - - - {{ $t("chars.quoted", { text: launchProcessQuery }) }} - - - - - - - - - - - - + + + + {{ + $t("library.admin.import.version.launchCmd") + }} + + {{ $t("library.admin.import.version.launchDesc") }} + + + + + versionSettings.launches.splice(launchIdx, 1)" + > + + + + + {{ $t("library.admin.import.version.noLaunches") }} + versionSettings.launches.push({} as any)" + >{{ $t("common.add") }} + - - {{ $t("library.admin.import.version.platform") }} - - + @@ -398,89 +237,9 @@ /> - - - - - {{ $t("library.admin.import.version.advancedOptions") }} - - - - - - - - - - - - - - {{ $t("library.admin.import.version.umuOverride") }} - - - {{ $t("library.admin.import.version.umuOverrideDesc") }} - - - - - - - - - {{ $t("library.admin.import.version.umuLauncherId") }} - - - - - - - - - {{ $t("library.admin.import.version.noAdv") }} - - - @@ -536,18 +295,15 @@ import { SwitchDescription, SwitchGroup, SwitchLabel, - Disclosure, - DisclosureButton, - DisclosurePanel, - Combobox, - ComboboxButton, - ComboboxInput, - ComboboxOption, - ComboboxOptions, } from "@headlessui/vue"; import { XCircleIcon } from "@heroicons/vue/16/solid"; -import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid"; -import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/vue/24/solid"; +import { + CheckIcon, + ChevronUpDownIcon, + TrashIcon, +} from "@heroicons/vue/20/solid"; +import type { Platform } from "~/prisma/client/enums"; +import type { ImportVersion } from "~/server/api/v1/admin/import/version/index.post"; definePageMeta({ layout: "admin", @@ -561,76 +317,16 @@ const versions = await $dropFetch( `/api/v1/admin/import/version?id=${encodeURIComponent(gameId)}`, ); const currentlySelectedVersion = ref(-1); -const versionSettings = ref<{ - platform: PlatformClient | undefined; - - onlySetup: boolean; - launch: string; - launchArgs: string; - setup: string; - setupArgs: string; - - delta: boolean; - umuId: string; -}>({ - platform: undefined, - launch: "", - launchArgs: "", - setup: "", - setupArgs: "", +const versionSettings = ref({ + id: gameId, + version: "", delta: false, onlySetup: false, - umuId: "", + launches: [], + setups: [], }); -const versionGuesses = - ref>(); -const launchProcessQuery = ref(""); -const setupProcessQuery = ref(""); - -const launchFilteredVersionGuesses = computed(() => - versionGuesses.value?.filter((e) => - e.filename.toLowerCase().includes(launchProcessQuery.value.toLowerCase()), - ), -); -const setupFilteredVersionGuesses = computed(() => - versionGuesses.value?.filter((e) => - e.filename.toLowerCase().includes(setupProcessQuery.value.toLowerCase()), - ), -); - -function updateLaunchCommand(value: string) { - versionSettings.value.launch = value; - autosetPlatform(value); -} - -function updateSetupCommand(value: string) { - versionSettings.value.setup = value; - autosetPlatform(value); -} - -function autosetPlatform(value: string) { - if (!versionGuesses.value) return; - if (versionSettings.value.platform) return; - const guessIndex = versionGuesses.value.findIndex( - (e) => e.filename === value, - ); - if (guessIndex == -1) return; - versionSettings.value.platform = versionGuesses.value[guessIndex].platform; -} - -const umuIdEnabled = ref(false); -const umuId = computed({ - get() { - if (umuIdEnabled.value) return versionSettings.value.umuId; - return undefined; - }, - set(v) { - if (umuIdEnabled.value && v) { - versionSettings.value.umuId = v; - } - }, -}); +const versionGuesses = ref>(); const importLoading = ref(false); const importError = ref(); @@ -639,15 +335,19 @@ async function updateCurrentlySelectedVersion(value: number) { if (currentlySelectedVersion.value == value) return; currentlySelectedVersion.value = value; const version = versions[currentlySelectedVersion.value]; - const results = await $dropFetch( - `/api/v1/admin/import/version/preload?id=${encodeURIComponent( - gameId, - )}&version=${encodeURIComponent(version)}`, - ); - versionGuesses.value = results.map((e) => ({ - ...e, - platform: e.platform as PlatformClient, - })); + try { + const results = await $dropFetch( + `/api/v1/admin/import/version/preload?id=${encodeURIComponent( + gameId, + )}&version=${encodeURIComponent(version)}`, + { + failTitle: "Failed to fetch version information", + }, + ); + versionGuesses.value = results as typeof versionGuesses.value; + } catch { + currentlySelectedVersion.value = -1; + } } async function startImport() { @@ -655,9 +355,9 @@ async function startImport() { const taskId = await $dropFetch("/api/v1/admin/import/version", { method: "POST", body: { + ...versionSettings.value, id: gameId, version: versions[currentlySelectedVersion.value], - ...versionSettings.value, }, }); router.push(`/admin/task/${taskId.taskId}`); diff --git a/pages/admin/library/[id]/index.vue b/pages/admin/library/[id]/index.vue index 240d580..3b9e930 100644 --- a/pages/admin/library/[id]/index.vue +++ b/pages/admin/library/[id]/index.vue @@ -3,11 +3,11 @@ class="pt-8 lg:pt-0 lg:pl-20 fixed inset-0 flex flex-col overflow-auto bg-zinc-900" > - + - + {{ $t("library.admin.openStore") }} = [ @@ -57,6 +58,12 @@ const navigation: Array = [ prefix: "/admin/settings/tokens", icon: CodeBracketIcon, }, + { + label: "Services", + route: "/admin/settings/services", + prefix: "/admin/settings/services", + icon: ServerIcon, + }, ]; // const notifications = useNotifications(); diff --git a/pages/admin/settings/services.vue b/pages/admin/settings/services.vue new file mode 100644 index 0000000..31397bb --- /dev/null +++ b/pages/admin/settings/services.vue @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + {{ serviceMetadata[service.name].title }} + + + + {{ serviceMetadata[service.name].description }} + + + + + + + + + + + diff --git a/pages/admin/settings/tokens.vue b/pages/admin/settings/tokens.vue index c307695..4e9aab8 100644 --- a/pages/admin/settings/tokens.vue +++ b/pages/admin/settings/tokens.vue @@ -206,7 +206,6 @@ async function createToken( }, failTitle: "Failed to create API token.", }); - console.log(result); newToken.value = result.token; tokens.value.push(result); } catch { diff --git a/pages/admin/task/[id]/index.vue b/pages/admin/task/[id]/index.vue index dd6e609..26dc80b 100644 --- a/pages/admin/task/[id]/index.vue +++ b/pages/admin/task/[id]/index.vue @@ -44,8 +44,25 @@ {{ task.name }} + + + + {{ name }} + + + + No actions + + + + + + + + Two-factor authentication + + + Two-factor authentication is enabled on your account. Choose one of the + options below to continue. + + + + + + ← Back to options + + + + + + diff --git a/pages/auth/mfa/index.vue b/pages/auth/mfa/index.vue new file mode 100644 index 0000000..2c7683a --- /dev/null +++ b/pages/auth/mfa/index.vue @@ -0,0 +1,65 @@ + + + + + + + + + + + TOTP + + + + Use a one-time code to sign in to your Drop account. + + + + + + + + + + + + + + + WebAuthn + + + + Use a passkey, like biometrics, a hardware security device, or other + compatible device to sign in to your Drop account. + + + + + + + + + + diff --git a/pages/auth/mfa/totp.vue b/pages/auth/mfa/totp.vue new file mode 100644 index 0000000..449dd7c --- /dev/null +++ b/pages/auth/mfa/totp.vue @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + signin(code)" + /> + + + + + + + + + + {{ error }} + + + + + + + + diff --git a/pages/auth/mfa/webauthn.vue b/pages/auth/mfa/webauthn.vue new file mode 100644 index 0000000..d1c148e --- /dev/null +++ b/pages/auth/mfa/webauthn.vue @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + tryAuthWrapper()"> + Sign in with WebAuthn + + + + + + + + + + {{ error }} + + + + + + + + diff --git a/pages/auth/signin.vue b/pages/auth/signin.vue index 4feba93..98f5520 100644 --- a/pages/auth/signin.vue +++ b/pages/auth/signin.vue @@ -9,10 +9,18 @@ - {{ $t("auth.signin.title") }} + {{ + superlevel + ? "Sign in to access protected action" + : $t("auth.signin.title") + }} - {{ $t("auth.signin.noAccount") }} + {{ + superlevel + ? "We need you to sign in again for security reasons while attempting to access more sensitive actions." + : $t("auth.signin.noAccount") + }} @@ -49,11 +57,16 @@ import DropLogo from "~/components/DropLogo.vue"; const { t } = useI18n(); const enabledAuths = await $dropFetch("/api/v1/auth"); +const route = useRoute(); +const superlevel = route.query.superlevel; + definePageMeta({ layout: false, }); useHead({ - title: t("auth.signin.pageTitle"), + title: superlevel + ? "Sign in to access protected action" + : t("auth.signin.pageTitle"), }); diff --git a/pages/client/code/index.vue b/pages/client/code/index.vue index 266f03c..8ad7a80 100644 --- a/pages/client/code/index.vue +++ b/pages/client/code/index.vue @@ -8,20 +8,7 @@ {{ $t("auth.code.description") }} - keydown(i - 1, v)" - @input="() => input(i - 1)" - @focusin="() => select(i - 1)" - @paste="(v) => paste(i - 1, v)" - /> + complete(code)" /> ([]); - const router = useRouter(); const loading = ref(false); const error = ref(undefined); -function keydown(index: number, event: KeyboardEvent) { - if (event.key === "Backspace" && !code.value[index] && index > 0) { - codeElements.value![index - 1].focus(); - } -} - -function input(index: number) { - if (codeElements.value === null) return; - const v = code.value[index] ?? ""; - if (v.length > 1) code.value[index] = v[0]; - - if (!(index + 1 >= codeElements.value.length) && v) { - codeElements.value[index + 1].focus(); - } - - if (!(index - 1 < 0) && !v) { - codeElements.value[index - 1].focus(); - } - - console.log(index, codeLength - 1); - if (index == codeLength - 1) { - const assembledCode = code.value.join(""); - if (assembledCode.length == codeLength) { - complete(assembledCode); - } - } -} - -function select(index: number) { - if (!codeElements.value) return; - if (index >= codeElements.value.length) return; - codeElements.value[index].select(); -} - -function paste(index: number, event: ClipboardEvent) { - const newCode = event.clipboardData!.getData("text/plain"); - for (let i = 0; i < newCode.length && i < codeLength; i++) { - code.value[i] = newCode[i]; - codeElements.value![i].focus(); - } - event.preventDefault(); -} - async function complete(code: string) { loading.value = true; try { diff --git a/pages/library/collection/[id]/index.vue b/pages/library/collection/[id]/index.vue index 9176d99..3ce0108 100644 --- a/pages/library/collection/[id]/index.vue +++ b/pages/library/collection/[id]/index.vue @@ -52,16 +52,3 @@ useHead({ title: collection.value?.name || t("library.collection.title"), }); - - diff --git a/pages/library/index.vue b/pages/library/index.vue index 8d87bd7..2890565 100644 --- a/pages/library/index.vue +++ b/pages/library/index.vue @@ -113,16 +113,6 @@ useHead({ - - diff --git a/pages/setup.vue b/pages/setup.vue index 2acdc1d..3a64526 100644 --- a/pages/setup.vue +++ b/pages/setup.vue @@ -14,7 +14,7 @@ {{ $t("setup.welcome") }} - + {{ $t("setup.welcomeDescription") }} diff --git a/pages/store/[id]/index.vue b/pages/store/[id]/index.vue index 5fd4fa4..b21e50a 100644 --- a/pages/store/[id]/index.vue +++ b/pages/store/[id]/index.vue @@ -100,7 +100,7 @@ {{ $t("store.commingSoon") @@ -231,30 +231,9 @@ - - - (showPreview = !showPreview)" - > - - {{ - showPreview ? $t("store.readMore") : $t("store.readLess") - }} - - @@ -266,7 +245,6 @@ import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline"; import { StarIcon } from "@heroicons/vue/24/solid"; import { micromark } from "micromark"; -import type { PlatformClient } from "~/composables/types"; import { formatBytes } from "~/server/internal/utils/files"; const route = useRoute(); @@ -276,32 +254,11 @@ const user = useUser(); const { game, rating, size } = await $dropFetch(`/api/v1/games/${gameId}`); -// Preview description (first 30 lines) -const showPreview = ref(true); -const gameDescriptionCharacters = game.mDescription.split(""); - -// First new line after x characters -const descriptionSplitIndex = gameDescriptionCharacters.findIndex( - (v, i, arr) => { - // If we're at the last element, we return true. - // So we don't have to handle a -1 from this findIndex - if (i + 1 == arr.length) return true; - if (i < 500) return false; - if (v != "\n") return false; - return true; - }, -); - -const previewDescription = gameDescriptionCharacters - .slice(0, descriptionSplitIndex + 1) // Slice a character after - .join(""); -const previewHTML = micromark(previewDescription); - const descriptionHTML = micromark(game.mDescription); -const showReadMore = previewHTML != descriptionHTML; const platforms = game.versions - .map((e) => e.platform as PlatformClient) + .map((e) => e.launches.map((v) => v.platform)) + .flat() .flat() .filter((e, i, u) => u.indexOf(e) === i); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9392e98..14f5a64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^16.0.1 version: 16.0.1 '@drop-oss/droplet': - specifier: 3.5.0 - version: 3.5.0 + specifier: 5.3.1 + version: 5.3.1 '@headlessui/vue': specifier: ^1.7.23 version: 1.7.23(vue@3.5.26(typescript@5.8.3)) @@ -38,6 +38,12 @@ importers: '@prisma/client': specifier: ^6.11.1 version: 6.12.0(prisma@6.11.1(typescript@5.8.3))(typescript@5.8.3) + '@simplewebauthn/browser': + specifier: ^13.2.2 + version: 13.2.2 + '@simplewebauthn/server': + specifier: ^13.2.2 + version: 13.2.2 '@tailwindcss/vite': specifier: ^4.0.6 version: 4.1.11(vite@7.2.2(@types/node@22.16.5)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.1)(yaml@2.8.1)) @@ -50,12 +56,12 @@ importers: arktype: specifier: ^2.1.10 version: 2.1.20 - axios: - specifier: ^1.12.0 - version: 1.12.0 bcryptjs: specifier: ^3.0.2 version: 3.0.2 + cbor2: + specifier: ^2.0.1 + version: 2.0.1 cheerio: specifier: ^1.0.0 version: 1.1.2 @@ -74,6 +80,9 @@ importers: jdenticon: specifier: ^3.3.0 version: 3.3.0 + kjua: + specifier: ^0.10.0 + version: 0.10.0 luxon: specifier: ^3.6.1 version: 3.7.1 @@ -89,6 +98,12 @@ importers: nuxt-security: specifier: 2.2.0 version: 2.2.0(magicast@0.5.1)(rollup@4.53.3) + otp-io: + specifier: ^1.2.7 + version: 1.2.7 + parse-cosekey: + specifier: ^1.0.2 + version: 1.0.2 pino: specifier: ^9.7.0 version: 9.7.0 @@ -137,7 +152,7 @@ importers: 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) '@nuxt/eslint': specifier: ^1.3.0 - version: 1.7.1(@typescript-eslint/utils@8.38.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3))(@vue/compiler-sfc@3.5.26)(eslint@9.31.0(jiti@2.6.1))(magicast@0.5.1)(typescript@5.8.3)(vite@7.2.2(@types/node@22.16.5)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.1)(yaml@2.8.1)) + version: 1.7.1(@typescript-eslint/utils@8.50.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3))(@vue/compiler-sfc@3.5.26)(eslint@9.31.0(jiti@2.6.1))(magicast@0.5.1)(typescript@5.8.3)(vite@7.2.2(@types/node@22.16.5)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.1)(yaml@2.8.1)) '@tailwindcss/forms': specifier: ^0.5.9 version: 0.5.10(tailwindcss@4.1.11) @@ -156,6 +171,9 @@ importers: '@types/turndown': specifier: ^5.0.5 version: 5.0.5 + '@typescript-eslint/utils': + specifier: ^8.50.0 + version: 8.50.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3) autoprefixer: specifier: ^10.4.20 version: 10.4.21(postcss@8.5.6) @@ -177,6 +195,9 @@ importers: prettier: specifier: ^3.5.3 version: 3.6.2 + prettier-plugin-sort-json: + specifier: ^4.1.1 + version: 4.1.1(prettier@3.6.2) sass: specifier: ^1.79.4 version: 1.89.2 @@ -389,6 +410,10 @@ packages: resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} engines: {node: '>=0.1.90'} + '@cto.af/wtf8@0.0.2': + resolution: {integrity: sha512-ATm4UQiKrdm5GnU6BvIwUDN+LDEtt23zuzKFpnfDT59ULAd0aMYm/nSFzbSO02garLcXumRC13PzNfa7BsfvSg==} + engines: {node: '>=20'} + '@dabh/diagnostics@2.0.3': resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==} @@ -399,67 +424,67 @@ packages: '@discordapp/twemoji@16.0.1': resolution: {integrity: sha512-figLiBWzjS5cyrAjLaGjM8AAaowO3qvK8rg5bA2dElB4qsaPMvBVlFDMO2d3x+nC1igt7kgWH4dvNmvvUHUF8w==} - '@drop-oss/droplet-darwin-arm64@3.5.0': - resolution: {integrity: sha512-tEznf8ZftvIpgpBpWom43leUBLlvGzZE3pGt1cZcUZ8KPQySD/n5qqhPbP9qTdYgbobHjF/0VLFKlSKI90iMJA==} + '@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@3.5.0': - resolution: {integrity: sha512-FSjTKKUL0+eM1DWxFW969n3kbV6yNFjPU/F1NLwXL9xNoEKyN/A2tJOdSYvlHZNR6IaGL2O1QfBB4L6raADV+A==} + '@drop-oss/droplet-darwin-universal@5.3.1': + resolution: {integrity: sha512-ifvQGjoWtGfPssA9jYRb7hFjDIQSa4SUWYAdBpOWstan6hb36ak9Q7uxrMY+7cWuTayZrYOUd1O4IfRTGEojKg==} engines: {node: '>= 10'} os: [darwin] - '@drop-oss/droplet-darwin-x64@3.5.0': - resolution: {integrity: sha512-Sgy/UyM7NRWdJY2lpNo1sD0iYx1fPaEQZTgGREXZPNPUkG2uVSlqcl8rsdopFI9ZFc7GD/aSGgXNy+jWhOi0DQ==} + '@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@3.5.0': - resolution: {integrity: sha512-+eP8W9Hea6koV3XyotBN/iUrmRu9zb9QIHujdDpxmmkp511sKoWEIjKqV+/sC9cR3J3OGILureaXjb39k35nfg==} + '@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@3.5.0': - resolution: {integrity: sha512-zEHEm9PdXncxlAJkafLn8yykpGOr+AfDsjhzTH7yVxBVGl0U8L31nS2BuxKposLD6gKIuzRpFj4mQ+AtOIn+XA==} + '@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@3.5.0': - resolution: {integrity: sha512-SBo5A02oQ/qBWgVrSoE82Lbi5neS6CwlNKEKahG1dmkId/ZPQ9vMxwo5Cdgq3Oa4Lyo9l3RtRQWYFnw6HdG/rw==} + '@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@3.5.0': - resolution: {integrity: sha512-ddJv4UqzVr3GS7W6T9pcKjsY3qv+B+ahdPKP6cIwfL5EMLrKKfFBE7+pbXWEbE0t4q7Qhmj8GxSliCHA5TgCOg==} + '@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@3.5.0': - resolution: {integrity: sha512-pDc85qzA4UHvQopnA8nRFg20spDi4gL4yCwlYllJfoDUmXThPIHSQnQ/DurLPwqvJTURwkrfJboBQ93Z+Hnr9A==} + '@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@3.5.0': - resolution: {integrity: sha512-ZEYCBhRD5VMrbG6p0TFj/TUUskZxQOf0plFFkSqYxeK2BZkwh2cxEw2y977BPo0pfzV5QxbASzXo6I17cJIztg==} + '@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@3.5.0': - resolution: {integrity: sha512-WloxGl6hJb2mn1N2TFQrrDEmjppDGHLUwegP/6M4FKT63i/SxhIoenCsL2e7qWdhEC7XZZYtl18e6iLt80cl3g==} + '@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@3.5.0': - resolution: {integrity: sha512-FaTwGRl9uWdA1aw/WnbZfyZ7W/b2nEPdBLkdauonQ8OPKqK0k8KgolgyZ87yPFWw0BPyzUyCz4imrmdIIKhFYw==} + '@drop-oss/droplet@5.3.1': + resolution: {integrity: sha512-8tHYSsAk5tFGkJ9FX3S0ikHlj/rnUvedWR9OpP7KC3sRQmH1zmTA8Z9QxeMFSRiWlK57JY+mcU/qQO9LMLqJ3g==} engines: {node: '>= 10'} '@dxup/nuxt@0.2.2': @@ -1027,6 +1052,9 @@ packages: peerDependencies: vue: '>= 3' + '@hexagon/base64@1.1.28': + resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1185,6 +1213,9 @@ packages: '@kwsites/promise-deferred@1.1.1': resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} + '@levischuck/tiny-cbor@0.2.11': + resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} + '@lobomfz/prismark@0.0.3': resolution: {integrity: sha512-g2xfR/F+sRBRUhWYlpUkafqZjqsQBetjfzdWvQndRU4wdoavn3zblM3OQwb7vrsrKB6Wmbs+DtLGaD5XBQ2v8A==} hasBin: true @@ -1830,6 +1861,43 @@ packages: resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} engines: {node: '>= 10.0.0'} + '@peculiar/asn1-android@2.6.0': + resolution: {integrity: sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ==} + + '@peculiar/asn1-cms@2.6.0': + resolution: {integrity: sha512-2uZqP+ggSncESeUF/9Su8rWqGclEfEiz1SyU02WX5fUONFfkjzS2Z/F1Li0ofSmf4JqYXIOdCAZqIXAIBAT1OA==} + + '@peculiar/asn1-csr@2.6.0': + resolution: {integrity: sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ==} + + '@peculiar/asn1-ecc@2.6.0': + resolution: {integrity: sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw==} + + '@peculiar/asn1-pfx@2.6.0': + resolution: {integrity: sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ==} + + '@peculiar/asn1-pkcs8@2.6.0': + resolution: {integrity: sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA==} + + '@peculiar/asn1-pkcs9@2.6.0': + resolution: {integrity: sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw==} + + '@peculiar/asn1-rsa@2.6.0': + resolution: {integrity: sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w==} + + '@peculiar/asn1-schema@2.6.0': + resolution: {integrity: sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==} + + '@peculiar/asn1-x509-attr@2.6.0': + resolution: {integrity: sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA==} + + '@peculiar/asn1-x509@2.6.0': + resolution: {integrity: sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==} + + '@peculiar/x509@1.14.2': + resolution: {integrity: sha512-r2w1Hg6pODDs0zfAKHkSS5HLkOLSeburtcgwvlLLWWCixw+MmW3U6kD5ddyvc2Y2YdbGuVwCF2S2ASoU1cFAag==} + engines: {node: '>=22.0.0'} + '@phc/format@1.0.0': resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} engines: {node: '>=10'} @@ -2223,6 +2291,13 @@ packages: cpu: [x64] os: [win32] + '@simplewebauthn/browser@13.2.2': + resolution: {integrity: sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==} + + '@simplewebauthn/server@13.2.2': + resolution: {integrity: sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA==} + engines: {node: '>=20.0.0'} + '@sindresorhus/is@7.0.2': resolution: {integrity: sha512-d9xRovfKNz1SKieM0qJdO+PQonjnnIfSNWfHYnBSJ9hkjm0ZPw6HlxscDXYstp3z+7V2GOFHc+J0CYrYTjqCJw==} engines: {node: '>=18'} @@ -2438,16 +2513,32 @@ packages: peerDependencies: typescript: '>=4.8.4 <5.9.0' + '@typescript-eslint/project-service@8.50.0': + resolution: {integrity: sha512-Cg/nQcL1BcoTijEWyx4mkVC56r8dj44bFDvBdygifuS20f3OZCHmFbjF34DPSi07kwlFvqfv/xOLnJ5DquxSGQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/scope-manager@8.38.0': resolution: {integrity: sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.50.0': + resolution: {integrity: sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.38.0': resolution: {integrity: sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <5.9.0' + '@typescript-eslint/tsconfig-utils@8.50.0': + resolution: {integrity: sha512-vxd3G/ybKTSlm31MOA96gqvrRGv9RJ7LGtZCn2Vrc5htA0zCDvcMqUkifcjrWNNKXHUU3WCkYOzzVSFBd0wa2w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/type-utils@8.38.0': resolution: {integrity: sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2459,12 +2550,22 @@ packages: resolution: {integrity: sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.50.0': + resolution: {integrity: sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.38.0': resolution: {integrity: sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <5.9.0' + '@typescript-eslint/typescript-estree@8.50.0': + resolution: {integrity: sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/utils@8.38.0': resolution: {integrity: sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2472,10 +2573,21 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' + '@typescript-eslint/utils@8.50.0': + resolution: {integrity: sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/visitor-keys@8.38.0': resolution: {integrity: sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.50.0': + resolution: {integrity: sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@unhead/vue@2.0.19': resolution: {integrity: sha512-7BYjHfOaoZ9+ARJkT10Q2TjnTUqDXmMpfakIAsD/hXiuff1oqWg1xeXT5+MomhNcC15HbiABpbbBmITLSHxdKg==} peerDependencies: @@ -2850,6 +2962,10 @@ packages: arktype@2.1.20: resolution: {integrity: sha512-IZCEEXaJ8g+Ijd59WtSYwtjnqXiwM8sWQ5EjGamcto7+HVN9eK0C4p0zDlCuAwWhpqr6fIBkxPuYDl4/Mcj/+Q==} + asn1js@3.0.7: + resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==} + engines: {node: '>=12.0.0'} + ast-kit@1.4.3: resolution: {integrity: sha512-MdJqjpodkS5J149zN0Po+HPshkTdUyrvF7CKTafUgv69vBSPtncrj+3IiUgqdd7ElIEkbeXCsEouBUwLrw9Ilg==} engines: {node: '>=16.14.0'} @@ -2876,9 +2992,6 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -2890,9 +3003,6 @@ packages: peerDependencies: postcss: ^8.1.0 - axios@1.12.0: - resolution: {integrity: sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==} - b4a@1.6.7: resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==} @@ -2932,8 +3042,12 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.8.29: - resolution: {integrity: sha512-sXdt2elaVnhpDNRDz+1BDx1JQoJRuNk7oVlAlbGiFkLikHCAQiccexF/9e91zVi6RCgqspl04aP+6Cnl9zRLrA==} + base64url@3.0.1: + resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==} + engines: {node: '>=6.0.0'} + + baseline-browser-mapping@2.9.11: + resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} hasBin: true basic-auth@2.0.1: @@ -3058,15 +3172,20 @@ packages: caniuse-api@3.0.0: resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} - caniuse-lite@1.0.30001731: - resolution: {integrity: sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==} - - caniuse-lite@1.0.30001756: - resolution: {integrity: sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==} + caniuse-lite@1.0.30001762: + resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==} canvas-renderer@2.2.1: resolution: {integrity: sha512-RrBgVL5qCEDIXpJ6NrzyRNoTnXxYarqm/cS/W6ERhUJts5UQtt/XPEosGN3rqUkZ4fjBArlnCbsISJ+KCFnIAg==} + cbor2@2.0.1: + resolution: {integrity: sha512-9bE8+tueGxONyxpttNKkAKKcGVtAPeoSJ64AjVTTjEuBOuRaeeP76EN9BbmQqkz1ZeTP0QPvksNBKwvEutIUzQ==} + engines: {node: '>=20'} + + cbor@8.1.0: + resolution: {integrity: sha512-DwGjNW9omn6EwP70aXsn7FQJx5kO12tX0bZkaTjzdVFM6/7nhA4t0EENocKGx6D2Bch9PE2KzCUf5SceBdeijg==} + engines: {node: '>=12.19'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -3158,10 +3277,6 @@ packages: colorspace@1.1.4: resolution: {integrity: sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==} - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - commander@10.0.1: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} @@ -3431,10 +3546,6 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} @@ -3623,10 +3734,6 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -3846,6 +3953,9 @@ packages: exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + extensible-custom-error@0.0.7: + resolution: {integrity: sha512-1tgubPkgC+Qi2nUpulI7hGddHh0fA8hXu3P0LBUq2pamZL52KSJZqMu8Q3CiA6kf7Irn/CU1fJe6y4igHCwu4Q==} + externality@1.0.2: resolution: {integrity: sha512-LyExtJWKxtgVzmgtEHyQtLFpw1KFhQphF9nTG8TpAIVkiI/xQ3FJh75tRFLYl4hkn7BNIIdLJInuDAavX35pMw==} @@ -3960,15 +4070,6 @@ packages: fn.name@1.1.0: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - fontaine@0.6.0: resolution: {integrity: sha512-cfKqzB62GmztJhwJ0YXtzNsmpqKAcFzTqsakJ//5COTzbou90LU7So18U+4D8z+lDXr4uztaAUZBonSoPDcj1w==} @@ -3979,10 +4080,6 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - form-data@4.0.4: - resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} - engines: {node: '>= 6'} - formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -4132,10 +4229,6 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -4387,6 +4480,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@4.15.9: + resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -4459,6 +4555,9 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kjua@0.10.0: + resolution: {integrity: sha512-OEV1EYPyBGfQN6iieNf0hhQ5RoX0UaV4n1PLita5QkaUpPB+LidX2J2afITnO76bQIDkUA+RF3wFfEFRdtg4cA==} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -4961,6 +5060,10 @@ packages: resolution: {integrity: sha512-3VW/8JpPqPvnJvseXowjZcirPisssnBuDikk6JIZ8jQzF7KJQX52iPFX4RYYxLycYH7IbMRSPUOga/esVjy5Yg==} engines: {node: '>=18'} + nofilter@3.1.0: + resolution: {integrity: sha512-l2NNj07e9afPnhAhvgVrCD/oy2Ai1yfLpuo3EpiO1jFTsB4sFz6oIfAfSZyQzVpkZQ9xS8ZS5g1jCBgq4Hwo0g==} + engines: {node: '>=12.19'} + nopt@8.1.0: resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} engines: {node: ^18.17.0 || >=20.5.0} @@ -5082,6 +5185,10 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + otp-io@1.2.7: + resolution: {integrity: sha512-UhIZfOtlMBSWGSN/v1i1eY5qsW07V/ZgaSq8bZXK0nfZEcoEitRKmn1dY8Sed8rafai3Ju8BwuxttuC2DJm6sA==} + engines: {node: '>=14'} + oxc-minify@0.96.0: resolution: {integrity: sha512-dXeeGrfPJJ4rMdw+NrqiCRtbzVX2ogq//R0Xns08zql2HjV3Zi2SBJ65saqfDaJzd2bcHqvGWH+M44EQCHPAcA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5148,6 +5255,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-cosekey@1.0.2: + resolution: {integrity: sha512-j306AQxJMF9FXL4DLlPI4cLkU4v6llfumV2/zJrUV/5rLBP+eGCt7f15lgGZmlJDfXC/0Da2ngovjWKcaUmAng==} + parse-gitignore@2.0.0: resolution: {integrity: sha512-RmVuCHWsfu0QPNW+mraxh/xjQVw/lhUCUru8Zni3Ctq3AoMhpDTq0OVdKS6iesd6Kqb7viCV3isAL43dciOSog==} engines: {node: '>=14'} @@ -5470,6 +5580,12 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier-plugin-sort-json@4.1.1: + resolution: {integrity: sha512-uJ49wCzwJ/foKKV4tIPxqi4jFFvwUzw4oACMRG2dcmDhBKrxBv0L2wSKkAqHCmxKCvj0xcCZS4jO2kSJO/tRJw==} + engines: {node: '>=18.0.0'} + peerDependencies: + prettier: ^3.0.0 + prettier@3.6.2: resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} engines: {node: '>=14'} @@ -5510,9 +5626,6 @@ packages: protocols@2.0.2: resolution: {integrity: sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ==} - proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -5520,6 +5633,13 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.1.5: + resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} + engines: {node: '>=16.0.0'} + qs@6.14.0: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} @@ -5606,6 +5726,9 @@ packages: resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + regexp-ast-analysis@0.7.1: resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} @@ -5909,6 +6032,9 @@ packages: std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + str2ab@1.2.1: + resolution: {integrity: sha512-AzTOr/w122dSjO4KVYAk7nXqruoyBMlh8FRoVNBpqzeqbC+xoPyq6BbPVFnJSFAnCTz6lpZHfBH4D5WX0mh8vg==} + stream-head@3.0.0: resolution: {integrity: sha512-EfcHQpe+HxwAY46J+o+LeQG8gL6FfxBBfNEGzPWzXYEiL2dRS1dtFJ2F38JLcrSKz1tIFA3HkST4SkTPA7+jgw==} engines: {node: '>=14.13.1'} @@ -6119,9 +6245,16 @@ packages: peerDependencies: typescript: '>=4.8.4' + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsyringe@4.10.0: + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} + engines: {node: '>= 6.0.0'} + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -7038,6 +7171,8 @@ snapshots: '@colors/colors@1.6.0': {} + '@cto.af/wtf8@0.0.2': {} + '@dabh/diagnostics@2.0.3': dependencies: colorspace: 1.1.4 @@ -7056,48 +7191,48 @@ snapshots: jsonfile: 5.0.0 universalify: 0.1.2 - '@drop-oss/droplet-darwin-arm64@3.5.0': + '@drop-oss/droplet-darwin-arm64@5.3.1': optional: true - '@drop-oss/droplet-darwin-universal@3.5.0': + '@drop-oss/droplet-darwin-universal@5.3.1': optional: true - '@drop-oss/droplet-darwin-x64@3.5.0': + '@drop-oss/droplet-darwin-x64@5.3.1': optional: true - '@drop-oss/droplet-linux-arm64-gnu@3.5.0': + '@drop-oss/droplet-linux-arm64-gnu@5.3.1': optional: true - '@drop-oss/droplet-linux-arm64-musl@3.5.0': + '@drop-oss/droplet-linux-arm64-musl@5.3.1': optional: true - '@drop-oss/droplet-linux-riscv64-gnu@3.5.0': + '@drop-oss/droplet-linux-riscv64-gnu@5.3.1': optional: true - '@drop-oss/droplet-linux-x64-gnu@3.5.0': + '@drop-oss/droplet-linux-x64-gnu@5.3.1': optional: true - '@drop-oss/droplet-linux-x64-musl@3.5.0': + '@drop-oss/droplet-linux-x64-musl@5.3.1': optional: true - '@drop-oss/droplet-win32-arm64-msvc@3.5.0': + '@drop-oss/droplet-win32-arm64-msvc@5.3.1': optional: true - '@drop-oss/droplet-win32-x64-msvc@3.5.0': + '@drop-oss/droplet-win32-x64-msvc@5.3.1': optional: true - '@drop-oss/droplet@3.5.0': + '@drop-oss/droplet@5.3.1': optionalDependencies: - '@drop-oss/droplet-darwin-arm64': 3.5.0 - '@drop-oss/droplet-darwin-universal': 3.5.0 - '@drop-oss/droplet-darwin-x64': 3.5.0 - '@drop-oss/droplet-linux-arm64-gnu': 3.5.0 - '@drop-oss/droplet-linux-arm64-musl': 3.5.0 - '@drop-oss/droplet-linux-riscv64-gnu': 3.5.0 - '@drop-oss/droplet-linux-x64-gnu': 3.5.0 - '@drop-oss/droplet-linux-x64-musl': 3.5.0 - '@drop-oss/droplet-win32-arm64-msvc': 3.5.0 - '@drop-oss/droplet-win32-x64-msvc': 3.5.0 + '@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: @@ -7469,6 +7604,8 @@ snapshots: dependencies: vue: 3.5.26(typescript@5.8.3) + '@hexagon/base64@1.1.28': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -7650,6 +7787,8 @@ snapshots: '@kwsites/promise-deferred@1.1.1': {} + '@levischuck/tiny-cbor@0.2.11': {} + '@lobomfz/prismark@0.0.3': dependencies: '@prisma/generator-helper': 6.19.0 @@ -7901,7 +8040,7 @@ snapshots: - utf-8-validate - vue - '@nuxt/eslint-config@1.7.1(@typescript-eslint/utils@8.38.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3))(@vue/compiler-sfc@3.5.26)(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3)': + '@nuxt/eslint-config@1.7.1(@typescript-eslint/utils@8.50.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3))(@vue/compiler-sfc@3.5.26)(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3)': dependencies: '@antfu/install-pkg': 1.1.0 '@clack/prompts': 0.11.0 @@ -7915,7 +8054,7 @@ snapshots: eslint-flat-config-utils: 2.1.1 eslint-merge-processors: 2.0.0(eslint@9.31.0(jiti@2.6.1)) eslint-plugin-import-lite: 0.3.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3) - eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.38.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3))(eslint@9.31.0(jiti@2.6.1)) + eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.50.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3))(eslint@9.31.0(jiti@2.6.1)) eslint-plugin-jsdoc: 51.4.1(eslint@9.31.0(jiti@2.6.1)) eslint-plugin-regexp: 2.9.0(eslint@9.31.0(jiti@2.6.1)) eslint-plugin-unicorn: 60.0.0(eslint@9.31.0(jiti@2.6.1)) @@ -7935,17 +8074,17 @@ snapshots: '@nuxt/eslint-plugin@1.7.1(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3)': dependencies: '@typescript-eslint/types': 8.38.0 - '@typescript-eslint/utils': 8.38.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/utils': 8.50.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3) eslint: 9.31.0(jiti@2.6.1) transitivePeerDependencies: - supports-color - typescript - '@nuxt/eslint@1.7.1(@typescript-eslint/utils@8.38.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3))(@vue/compiler-sfc@3.5.26)(eslint@9.31.0(jiti@2.6.1))(magicast@0.5.1)(typescript@5.8.3)(vite@7.2.2(@types/node@22.16.5)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.1)(yaml@2.8.1))': + '@nuxt/eslint@1.7.1(@typescript-eslint/utils@8.50.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3))(@vue/compiler-sfc@3.5.26)(eslint@9.31.0(jiti@2.6.1))(magicast@0.5.1)(typescript@5.8.3)(vite@7.2.2(@types/node@22.16.5)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.1)(yaml@2.8.1))': dependencies: '@eslint/config-inspector': 1.1.0(eslint@9.31.0(jiti@2.6.1)) '@nuxt/devtools-kit': 2.6.2(magicast@0.5.1)(vite@7.2.2(@types/node@22.16.5)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.89.2)(terser@5.43.1)(yaml@2.8.1)) - '@nuxt/eslint-config': 1.7.1(@typescript-eslint/utils@8.38.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3))(@vue/compiler-sfc@3.5.26)(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3) + '@nuxt/eslint-config': 1.7.1(@typescript-eslint/utils@8.50.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3))(@vue/compiler-sfc@3.5.26)(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3) '@nuxt/eslint-plugin': 1.7.1(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3) '@nuxt/kit': 4.0.2(magicast@0.5.1) chokidar: 4.0.3 @@ -8600,6 +8739,102 @@ snapshots: '@parcel/watcher-win32-ia32': 2.5.1 '@parcel/watcher-win32-x64': 2.5.1 + '@peculiar/asn1-android@2.6.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-cms@2.6.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.0 + '@peculiar/asn1-x509-attr': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-csr@2.6.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.6.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pfx@2.6.0': + dependencies: + '@peculiar/asn1-cms': 2.6.0 + '@peculiar/asn1-pkcs8': 2.6.0 + '@peculiar/asn1-rsa': 2.6.0 + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs8@2.6.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs9@2.6.0': + dependencies: + '@peculiar/asn1-cms': 2.6.0 + '@peculiar/asn1-pfx': 2.6.0 + '@peculiar/asn1-pkcs8': 2.6.0 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.0 + '@peculiar/asn1-x509-attr': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.6.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.6.0': + dependencies: + asn1js: 3.0.7 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + '@peculiar/asn1-x509-attr@2.6.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.6.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.7 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + '@peculiar/x509@1.14.2': + dependencies: + '@peculiar/asn1-cms': 2.6.0 + '@peculiar/asn1-csr': 2.6.0 + '@peculiar/asn1-ecc': 2.6.0 + '@peculiar/asn1-pkcs9': 2.6.0 + '@peculiar/asn1-rsa': 2.6.0 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.0 + pvtsutils: 1.3.6 + reflect-metadata: 0.2.2 + tslib: 2.8.1 + tsyringe: 4.10.0 + '@phc/format@1.0.0': {} '@pkgjs/parseargs@0.11.0': @@ -8933,6 +9168,19 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.53.3': optional: true + '@simplewebauthn/browser@13.2.2': {} + + '@simplewebauthn/server@13.2.2': + dependencies: + '@hexagon/base64': 1.1.28 + '@levischuck/tiny-cbor': 0.2.11 + '@peculiar/asn1-android': 2.6.0 + '@peculiar/asn1-ecc': 2.6.0 + '@peculiar/asn1-rsa': 2.6.0 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.0 + '@peculiar/x509': 1.14.2 + '@sindresorhus/is@7.0.2': {} '@sindresorhus/merge-streams@2.3.0': {} @@ -9140,15 +9388,33 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.50.0(typescript@5.8.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.8.3) + '@typescript-eslint/types': 8.50.0 + debug: 4.4.1 + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.38.0': dependencies: '@typescript-eslint/types': 8.38.0 '@typescript-eslint/visitor-keys': 8.38.0 + '@typescript-eslint/scope-manager@8.50.0': + dependencies: + '@typescript-eslint/types': 8.50.0 + '@typescript-eslint/visitor-keys': 8.50.0 + '@typescript-eslint/tsconfig-utils@8.38.0(typescript@5.8.3)': dependencies: typescript: 5.8.3 + '@typescript-eslint/tsconfig-utils@8.50.0(typescript@5.8.3)': + dependencies: + typescript: 5.8.3 + '@typescript-eslint/type-utils@8.38.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3)': dependencies: '@typescript-eslint/types': 8.38.0 @@ -9163,6 +9429,8 @@ snapshots: '@typescript-eslint/types@8.38.0': {} + '@typescript-eslint/types@8.50.0': {} + '@typescript-eslint/typescript-estree@8.38.0(typescript@5.8.3)': dependencies: '@typescript-eslint/project-service': 8.38.0(typescript@5.8.3) @@ -9179,6 +9447,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.50.0(typescript@5.8.3)': + dependencies: + '@typescript-eslint/project-service': 8.50.0(typescript@5.8.3) + '@typescript-eslint/tsconfig-utils': 8.50.0(typescript@5.8.3) + '@typescript-eslint/types': 8.50.0 + '@typescript-eslint/visitor-keys': 8.50.0 + debug: 4.4.1 + minimatch: 9.0.5 + semver: 7.7.3 + tinyglobby: 0.2.15 + ts-api-utils: 2.1.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.38.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3)': dependencies: '@eslint-community/eslint-utils': 4.7.0(eslint@9.31.0(jiti@2.6.1)) @@ -9190,11 +9473,27 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.50.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3)': + dependencies: + '@eslint-community/eslint-utils': 4.7.0(eslint@9.31.0(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.50.0 + '@typescript-eslint/types': 8.50.0 + '@typescript-eslint/typescript-estree': 8.50.0(typescript@5.8.3) + eslint: 9.31.0(jiti@2.6.1) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@8.38.0': dependencies: '@typescript-eslint/types': 8.38.0 eslint-visitor-keys: 4.2.1 + '@typescript-eslint/visitor-keys@8.50.0': + dependencies: + '@typescript-eslint/types': 8.50.0 + eslint-visitor-keys: 4.2.1 + '@unhead/vue@2.0.19(vue@3.5.26(typescript@5.8.3))': dependencies: hookable: 5.5.3 @@ -9663,6 +9962,12 @@ snapshots: '@ark/schema': 0.46.0 '@ark/util': 0.46.0 + asn1js@3.0.7: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + ast-kit@1.4.3: dependencies: '@babel/parser': 7.28.0 @@ -9689,28 +9994,18 @@ snapshots: async@3.2.6: {} - asynckit@0.4.0: {} - atomic-sleep@1.0.0: {} autoprefixer@10.4.21(postcss@8.5.6): dependencies: browserslist: 4.25.1 - caniuse-lite: 1.0.30001731 + caniuse-lite: 1.0.30001762 fraction.js: 4.3.7 normalize-range: 0.1.2 picocolors: 1.1.1 postcss: 8.5.6 postcss-value-parser: 4.2.0 - axios@1.12.0: - dependencies: - follow-redirects: 1.15.11 - form-data: 4.0.4 - proxy-from-env: 1.1.0 - transitivePeerDependencies: - - debug - b4a@1.6.7: {} balanced-match@1.0.2: {} @@ -9742,7 +10037,9 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.8.29: {} + base64url@3.0.1: {} + + baseline-browser-mapping@2.9.11: {} basic-auth@2.0.1: dependencies: @@ -9788,15 +10085,15 @@ snapshots: browserslist@4.25.1: dependencies: - caniuse-lite: 1.0.30001731 + caniuse-lite: 1.0.30001762 electron-to-chromium: 1.5.194 node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.25.1) browserslist@4.28.0: dependencies: - baseline-browser-mapping: 2.8.29 - caniuse-lite: 1.0.30001756 + baseline-browser-mapping: 2.9.11 + caniuse-lite: 1.0.30001762 electron-to-chromium: 1.5.256 node-releases: 2.0.27 update-browserslist-db: 1.1.4(browserslist@4.28.0) @@ -9901,18 +10198,24 @@ snapshots: caniuse-api@3.0.0: dependencies: browserslist: 4.28.0 - caniuse-lite: 1.0.30001756 + caniuse-lite: 1.0.30001762 lodash.memoize: 4.1.2 lodash.uniq: 4.5.0 - caniuse-lite@1.0.30001731: {} - - caniuse-lite@1.0.30001756: {} + caniuse-lite@1.0.30001762: {} canvas-renderer@2.2.1: dependencies: '@types/node': 22.16.5 + cbor2@2.0.1: + dependencies: + '@cto.af/wtf8': 0.0.2 + + cbor@8.1.0: + dependencies: + nofilter: 3.1.0 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -10029,10 +10332,6 @@ snapshots: color: 3.2.1 text-hex: 1.0.0 - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - commander@10.0.1: {} commander@11.1.0: {} @@ -10255,8 +10554,6 @@ snapshots: defu@6.1.4: {} - delayed-stream@1.0.0: {} - denque@2.1.0: {} depd@2.0.0: {} @@ -10425,13 +10722,6 @@ snapshots: dependencies: es-errors: 1.3.0 - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -10573,7 +10863,7 @@ snapshots: optionalDependencies: typescript: 5.8.3 - eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.38.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3))(eslint@9.31.0(jiti@2.6.1)): + eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.50.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3))(eslint@9.31.0(jiti@2.6.1)): dependencies: '@typescript-eslint/types': 8.38.0 comment-parser: 1.4.1 @@ -10586,7 +10876,7 @@ snapshots: stable-hash-x: 0.2.0 unrs-resolver: 1.11.1 optionalDependencies: - '@typescript-eslint/utils': 8.38.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/utils': 8.50.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3) transitivePeerDependencies: - supports-color @@ -10771,6 +11061,8 @@ snapshots: exsolve@1.0.8: {} + extensible-custom-error@0.0.7: {} + externality@1.0.2: dependencies: enhanced-resolve: 5.18.2 @@ -10881,8 +11173,6 @@ snapshots: fn.name@1.1.0: {} - follow-redirects@1.15.11: {} - fontaine@0.6.0: dependencies: '@capsizecss/metrics': 3.5.0 @@ -10913,14 +11203,6 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - form-data@4.0.4: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 - formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 @@ -11101,10 +11383,6 @@ snapshots: has-symbols@1.1.0: {} - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -11368,6 +11646,8 @@ snapshots: jiti@2.6.1: {} + jose@4.15.9: {} + joycon@3.1.1: {} js-base64@3.7.7: {} @@ -11430,6 +11710,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kjua@0.10.0: {} + kleur@3.0.3: {} kleur@4.1.5: {} @@ -12126,6 +12408,8 @@ snapshots: dependencies: '@babel/parser': 7.28.0 + nofilter@3.1.0: {} + nopt@8.1.0: dependencies: abbrev: 3.0.1 @@ -12379,6 +12663,8 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + otp-io@1.2.7: {} + oxc-minify@0.96.0: optionalDependencies: '@oxc-minify/binding-android-arm64': 0.96.0 @@ -12497,6 +12783,13 @@ snapshots: dependencies: callsites: 3.1.0 + parse-cosekey@1.0.2: + dependencies: + cbor: 8.1.0 + extensible-custom-error: 0.0.7 + jose: 4.15.9 + str2ab: 1.2.1 + parse-gitignore@2.0.0: {} parse-imports-exports@0.2.4: @@ -12845,6 +13138,10 @@ snapshots: prelude-ls@1.2.1: {} + prettier-plugin-sort-json@4.1.1(prettier@3.6.2): + dependencies: + prettier: 3.6.2 + prettier@3.6.2: {} pretty-bytes@6.1.1: {} @@ -12871,8 +13168,6 @@ snapshots: protocols@2.0.2: {} - proxy-from-env@1.1.0: {} - pump@3.0.3: dependencies: end-of-stream: 1.4.5 @@ -12880,6 +13175,12 @@ snapshots: punycode@2.3.1: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.1.5: {} + qs@6.14.0: dependencies: side-channel: 1.1.0 @@ -12979,6 +13280,8 @@ snapshots: dependencies: '@eslint-community/regexpp': 4.12.1 + reflect-metadata@0.2.2: {} + regexp-ast-analysis@0.7.1: dependencies: '@eslint-community/regexpp': 4.12.1 @@ -13321,6 +13624,10 @@ snapshots: std-env@3.9.0: {} + str2ab@1.2.1: + dependencies: + base64url: 3.0.1 + stream-head@3.0.0: dependencies: through2: 4.0.2 @@ -13559,8 +13866,14 @@ snapshots: dependencies: typescript: 5.8.3 + tslib@1.14.1: {} + tslib@2.8.1: {} + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a658e84..69884fd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,9 +1,13 @@ onlyBuiltDependencies: + - "@parcel/watcher" - "@prisma/client" - "@prisma/engines" - "@tailwindcss/oxide" + - argon2 - esbuild - prisma + - sharp + - unrs-resolver overrides: droplet: link:../../.local/share/pnpm/global/5/node_modules/@drop-oss/droplet diff --git a/prisma/migrations/20251210231153_move_to_version_id/migration.sql b/prisma/migrations/20251210231153_move_to_version_id/migration.sql new file mode 100644 index 0000000..db19fcc --- /dev/null +++ b/prisma/migrations/20251210231153_move_to_version_id/migration.sql @@ -0,0 +1,28 @@ +/* + Warnings: + + - The primary key for the `GameVersion` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `versionName` on the `GameVersion` table. All the data in the column will be lost. + - Made the column `libraryId` on table `Game` required. This step will fail if there are existing NULL values in that column. + - The required column `versionId` was added to the `GameVersion` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required. + - Added the required column `versionPath` to the `GameVersion` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropIndex +DROP INDEX "GameTag_name_idx"; + +-- AlterTable +ALTER TABLE "Game" ALTER COLUMN "libraryId" SET NOT NULL; + +DELETE FROM "GameVersion"; + +-- AlterTable +ALTER TABLE "GameVersion" DROP CONSTRAINT "GameVersion_pkey", +DROP COLUMN "versionName", +ADD COLUMN "displayName" TEXT, +ADD COLUMN "versionId" TEXT NOT NULL, +ADD COLUMN "versionPath" TEXT NOT NULL, +ADD CONSTRAINT "GameVersion_pkey" PRIMARY KEY ("gameId", "versionId"); + +-- CreateIndex +CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32)); diff --git a/prisma/migrations/20251218081603_add_depots/migration.sql b/prisma/migrations/20251218081603_add_depots/migration.sql new file mode 100644 index 0000000..ebd5ef5 --- /dev/null +++ b/prisma/migrations/20251218081603_add_depots/migration.sql @@ -0,0 +1,14 @@ +-- DropIndex +DROP INDEX "GameTag_name_idx"; + +-- CreateTable +CREATE TABLE "Depot" ( + "id" TEXT NOT NULL, + "endpoint" TEXT NOT NULL, + "key" TEXT NOT NULL, + + CONSTRAINT "Depot_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32)); diff --git a/prisma/migrations/20251220094201_add_launch_configurations_and_emulation_targets/migration.sql b/prisma/migrations/20251220094201_add_launch_configurations_and_emulation_targets/migration.sql new file mode 100644 index 0000000..a2a3099 --- /dev/null +++ b/prisma/migrations/20251220094201_add_launch_configurations_and_emulation_targets/migration.sql @@ -0,0 +1,60 @@ +/* + Warnings: + + - You are about to drop the column `launchArgs` on the `GameVersion` table. All the data in the column will be lost. + - You are about to drop the column `launchCommand` on the `GameVersion` table. All the data in the column will be lost. + - You are about to drop the column `platform` on the `GameVersion` table. All the data in the column will be lost. + - You are about to drop the column `setupArgs` on the `GameVersion` table. All the data in the column will be lost. + - You are about to drop the column `setupCommand` on the `GameVersion` table. All the data in the column will be lost. + - You are about to drop the column `umuIdOverride` on the `GameVersion` table. All the data in the column will be lost. + - Added the required column `setupId` to the `GameVersion` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropIndex +DROP INDEX "GameTag_name_idx"; + +-- AlterTable +ALTER TABLE "GameVersion" DROP COLUMN "launchArgs", +DROP COLUMN "launchCommand", +DROP COLUMN "platform", +DROP COLUMN "setupArgs", +DROP COLUMN "setupCommand", +DROP COLUMN "umuIdOverride", +ADD COLUMN "setupId" TEXT NOT NULL; + +-- CreateTable +CREATE TABLE "SetupConfiguration" ( + "setupId" TEXT NOT NULL, + "command" TEXT NOT NULL, + "args" TEXT[], + "platform" "Platform" NOT NULL, + + CONSTRAINT "SetupConfiguration_pkey" PRIMARY KEY ("setupId") +); + +-- CreateTable +CREATE TABLE "LaunchConfiguration" ( + "launchId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "command" TEXT NOT NULL, + "args" TEXT[], + "platform" "Platform" NOT NULL, + "executorId" TEXT, + "umuIdOverride" TEXT, + "gameId" TEXT NOT NULL, + "versionId" TEXT NOT NULL, + + CONSTRAINT "LaunchConfiguration_pkey" PRIMARY KEY ("launchId") +); + +-- CreateIndex +CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32)); + +-- AddForeignKey +ALTER TABLE "GameVersion" ADD CONSTRAINT "GameVersion_setupId_fkey" FOREIGN KEY ("setupId") REFERENCES "SetupConfiguration"("setupId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LaunchConfiguration" ADD CONSTRAINT "LaunchConfiguration_executorId_fkey" FOREIGN KEY ("executorId") REFERENCES "LaunchConfiguration"("launchId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LaunchConfiguration" ADD CONSTRAINT "LaunchConfiguration_gameId_versionId_fkey" FOREIGN KEY ("gameId", "versionId") REFERENCES "GameVersion"("gameId", "versionId") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20251220095108_make_setup_optional/migration.sql b/prisma/migrations/20251220095108_make_setup_optional/migration.sql new file mode 100644 index 0000000..aabff04 --- /dev/null +++ b/prisma/migrations/20251220095108_make_setup_optional/migration.sql @@ -0,0 +1,14 @@ +-- DropForeignKey +ALTER TABLE "GameVersion" DROP CONSTRAINT "GameVersion_setupId_fkey"; + +-- DropIndex +DROP INDEX "GameTag_name_idx"; + +-- AlterTable +ALTER TABLE "GameVersion" ALTER COLUMN "setupId" DROP NOT NULL; + +-- CreateIndex +CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32)); + +-- AddForeignKey +ALTER TABLE "GameVersion" ADD CONSTRAINT "GameVersion_setupId_fkey" FOREIGN KEY ("setupId") REFERENCES "SetupConfiguration"("setupId") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20251220103245_multi_setups/migration.sql b/prisma/migrations/20251220103245_multi_setups/migration.sql new file mode 100644 index 0000000..e78fd41 --- /dev/null +++ b/prisma/migrations/20251220103245_multi_setups/migration.sql @@ -0,0 +1,26 @@ +/* + Warnings: + + - You are about to drop the column `setupId` on the `GameVersion` table. All the data in the column will be lost. + - Added the required column `gameId` to the `SetupConfiguration` table without a default value. This is not possible if the table is not empty. + - Added the required column `versionId` to the `SetupConfiguration` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "GameVersion" DROP CONSTRAINT "GameVersion_setupId_fkey"; + +-- DropIndex +DROP INDEX "GameTag_name_idx"; + +-- AlterTable +ALTER TABLE "GameVersion" DROP COLUMN "setupId"; + +-- AlterTable +ALTER TABLE "SetupConfiguration" ADD COLUMN "gameId" TEXT NOT NULL, +ADD COLUMN "versionId" TEXT NOT NULL; + +-- CreateIndex +CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32)); + +-- AddForeignKey +ALTER TABLE "SetupConfiguration" ADD CONSTRAINT "SetupConfiguration_gameId_versionId_fkey" FOREIGN KEY ("gameId", "versionId") REFERENCES "GameVersion"("gameId", "versionId") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20251220230511_add_cascading_deletions/migration.sql b/prisma/migrations/20251220230511_add_cascading_deletions/migration.sql new file mode 100644 index 0000000..7faf4e9 --- /dev/null +++ b/prisma/migrations/20251220230511_add_cascading_deletions/migration.sql @@ -0,0 +1,23 @@ +-- DropForeignKey +ALTER TABLE "LaunchConfiguration" DROP CONSTRAINT "LaunchConfiguration_executorId_fkey"; + +-- DropForeignKey +ALTER TABLE "LaunchConfiguration" DROP CONSTRAINT "LaunchConfiguration_gameId_versionId_fkey"; + +-- DropForeignKey +ALTER TABLE "SetupConfiguration" DROP CONSTRAINT "SetupConfiguration_gameId_versionId_fkey"; + +-- DropIndex +DROP INDEX "GameTag_name_idx"; + +-- CreateIndex +CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32)); + +-- AddForeignKey +ALTER TABLE "SetupConfiguration" ADD CONSTRAINT "SetupConfiguration_gameId_versionId_fkey" FOREIGN KEY ("gameId", "versionId") REFERENCES "GameVersion"("gameId", "versionId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LaunchConfiguration" ADD CONSTRAINT "LaunchConfiguration_executorId_fkey" FOREIGN KEY ("executorId") REFERENCES "LaunchConfiguration"("launchId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LaunchConfiguration" ADD CONSTRAINT "LaunchConfiguration_gameId_versionId_fkey" FOREIGN KEY ("gameId", "versionId") REFERENCES "GameVersion"("gameId", "versionId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20251230030847_add_task_actions/migration.sql b/prisma/migrations/20251230030847_add_task_actions/migration.sql new file mode 100644 index 0000000..bbf2af3 --- /dev/null +++ b/prisma/migrations/20251230030847_add_task_actions/migration.sql @@ -0,0 +1,8 @@ +-- DropIndex +DROP INDEX "GameTag_name_idx"; + +-- AlterTable +ALTER TABLE "Task" ADD COLUMN "actions" TEXT[]; + +-- CreateIndex +CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32)); diff --git a/prisma/migrations/20251230040838_add_mfa/migration.sql b/prisma/migrations/20251230040838_add_mfa/migration.sql new file mode 100644 index 0000000..9acb737 --- /dev/null +++ b/prisma/migrations/20251230040838_add_mfa/migration.sql @@ -0,0 +1,22 @@ +-- CreateEnum +CREATE TYPE "MFAMec" AS ENUM ('WebAuthn', 'TOTP'); + +-- DropIndex +DROP INDEX "GameTag_name_idx"; + +-- CreateTable +CREATE TABLE "LinkedMFAMec" ( + "userId" TEXT NOT NULL, + "mec" "MFAMec" NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "version" INTEGER NOT NULL DEFAULT 1, + "credentials" JSONB NOT NULL, + + CONSTRAINT "LinkedMFAMec_pkey" PRIMARY KEY ("userId","mec") +); + +-- CreateIndex +CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32)); + +-- AddForeignKey +ALTER TABLE "LinkedMFAMec" ADD CONSTRAINT "LinkedMFAMec_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260104040733_make_session_userids_optional/migration.sql b/prisma/migrations/20260104040733_make_session_userids_optional/migration.sql new file mode 100644 index 0000000..33de907 --- /dev/null +++ b/prisma/migrations/20260104040733_make_session_userids_optional/migration.sql @@ -0,0 +1,8 @@ +-- DropIndex +DROP INDEX "GameTag_name_idx"; + +-- AlterTable +ALTER TABLE "Session" ALTER COLUMN "userId" DROP NOT NULL; + +-- CreateIndex +CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32)); diff --git a/prisma/migrations/20260104074505_add_trgm_index_for_game_m_name/migration.sql b/prisma/migrations/20260104074505_add_trgm_index_for_game_m_name/migration.sql new file mode 100644 index 0000000..84d702f --- /dev/null +++ b/prisma/migrations/20260104074505_add_trgm_index_for_game_m_name/migration.sql @@ -0,0 +1,8 @@ +-- DropIndex +DROP INDEX "GameTag_name_idx"; + +-- CreateIndex +CREATE INDEX "Game_mName_idx" ON "Game" USING GIST ("mName" gist_trgm_ops(siglen=32)); + +-- CreateIndex +CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32)); diff --git a/prisma/migrations/20260111022718_/migration.sql b/prisma/migrations/20260111022718_/migration.sql new file mode 100644 index 0000000..d4c8dbe --- /dev/null +++ b/prisma/migrations/20260111022718_/migration.sql @@ -0,0 +1,11 @@ +-- DropIndex +DROP INDEX "Game_mName_idx"; + +-- DropIndex +DROP INDEX "GameTag_name_idx"; + +-- CreateIndex +CREATE INDEX "Game_mName_idx" ON "Game" USING GIST ("mName" gist_trgm_ops(siglen=32)); + +-- CreateIndex +CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32)); diff --git a/prisma/migrations/20260111054324_remove_args_from_commands/migration.sql b/prisma/migrations/20260111054324_remove_args_from_commands/migration.sql new file mode 100644 index 0000000..0e29b0f --- /dev/null +++ b/prisma/migrations/20260111054324_remove_args_from_commands/migration.sql @@ -0,0 +1,24 @@ +/* + Warnings: + + - You are about to drop the column `args` on the `LaunchConfiguration` table. All the data in the column will be lost. + - You are about to drop the column `args` on the `SetupConfiguration` table. All the data in the column will be lost. + +*/ +-- DropIndex +DROP INDEX "Game_mName_idx"; + +-- DropIndex +DROP INDEX "GameTag_name_idx"; + +-- AlterTable +ALTER TABLE "LaunchConfiguration" DROP COLUMN "args"; + +-- AlterTable +ALTER TABLE "SetupConfiguration" DROP COLUMN "args"; + +-- CreateIndex +CREATE INDEX "Game_mName_idx" ON "Game" USING GIST ("mName" gist_trgm_ops(siglen=32)); + +-- CreateIndex +CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32)); diff --git a/prisma/models/app.prisma b/prisma/models/app.prisma index 338af50..dffd165 100644 --- a/prisma/models/app.prisma +++ b/prisma/models/app.prisma @@ -30,3 +30,9 @@ model Library { games Game[] } + +model Depot { + id String @id @default(uuid()) + endpoint String + key String @default(uuid()) +} diff --git a/prisma/models/auth.prisma b/prisma/models/auth.prisma index 8749cf6..e8661e3 100644 --- a/prisma/models/auth.prisma +++ b/prisma/models/auth.prisma @@ -11,7 +11,25 @@ model LinkedAuthMec { version Int @default(1) credentials Json - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + + @@id([userId, mec]) +} + +enum MFAMec { + WebAuthn + TOTP +} + +model LinkedMFAMec { + userId String + mec MFAMec + enabled Boolean @default(true) + + version Int @default(1) + credentials Json + + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) @@id([userId, mec]) } @@ -63,7 +81,7 @@ model Session { token String @id expiresAt DateTime - userId String + userId String? user User? @relation(fields: [userId], references: [id], onDelete: Cascade) data Json // misc extra data diff --git a/prisma/models/content.prisma b/prisma/models/content.prisma index 1d8a002..fbd4244 100644 --- a/prisma/models/content.prisma +++ b/prisma/models/content.prisma @@ -17,7 +17,8 @@ model Game { // Any field prefixed with m is filled in from metadata // Acts as a cache so we can search and filter it - mName String // Name of game + mName String // Name of game + mShortDescription String // Short description mDescription String // Supports markdown mReleased DateTime // When the game was released @@ -36,8 +37,8 @@ model Game { // These fields will not be optional in the next version // Any game without a library ID will be assigned one at startup, based on the defaults - libraryId String? - library Library? @relation(fields: [libraryId], references: [id], onDelete: Cascade, onUpdate: Cascade) + libraryId String + library Library @relation(fields: [libraryId], references: [id], onDelete: Cascade, onUpdate: Cascade) libraryPath String collections CollectionEntry[] @@ -51,18 +52,18 @@ model Game { @@unique([metadataSource, metadataId], name: "metadataKey") @@unique([libraryId, libraryPath], name: "libraryKey") + @@index([mName(ops: raw("gist_trgm_ops(siglen=32)"))], type: Gist) } model GameTag { id String @id @default(uuid()) name String @unique - games Game[] + games Game[] @@index([name(ops: raw("gist_trgm_ops(siglen=32)"))], type: Gist) } - model GameRating { id String @id @default(uuid()) @@ -83,28 +84,60 @@ model GameRating { // A particular set of files that relate to the version model GameVersion { - gameId String - game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) - versionName String // Sub directory for the game files + gameId String + game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) + versionId String @default(uuid()) + + displayName String? + versionPath String created DateTime @default(now()) - platform Platform + launches LaunchConfiguration[] + setups SetupConfiguration[] - launchCommand String @default("") // Command to run to start. Platform-specific. Windows games on Linux will wrap this command in Proton/Wine - launchArgs String[] - setupCommand String @default("") // Command to setup game (dependencies and such) - setupArgs String[] - onlySetup Boolean @default(false) - - umuIdOverride String? + onlySetup Boolean @default(false) dropletManifest Json // Results from droplet versionIndex Int delta Boolean @default(false) - @@id([gameId, versionName]) + @@id([gameId, versionId]) +} + +model SetupConfiguration { + setupId String @id @default(uuid()) + + command String + + platform Platform + + gameId String + versionId String + gameVersion GameVersion @relation(fields: [gameId, versionId], references: [gameId, versionId], onDelete: Cascade, onUpdate: Cascade) +} + +model LaunchConfiguration { + launchId String @id @default(uuid()) + + name String + + command String + + platform Platform + + // For emulation targets + executorId String? + executor LaunchConfiguration? @relation(fields: [executorId], references: [launchId], name: "executor", onDelete: Cascade, onUpdate: Cascade) + + umuIdOverride String? + + gameId String + versionId String + gameVersion GameVersion @relation(fields: [gameId, versionId], references: [gameId, versionId], onDelete: Cascade, onUpdate: Cascade) + + executions LaunchConfiguration[] @relation("executor") } // A save slot for a game diff --git a/prisma/models/task.prisma b/prisma/models/task.prisma index 9edac6f..2a891ef 100644 --- a/prisma/models/task.prisma +++ b/prisma/models/task.prisma @@ -11,6 +11,8 @@ model Task { progress Float log String[] + actions String[] + acls String[] @@id([id, started]) diff --git a/prisma/models/user.prisma b/prisma/models/user.prisma index e3a1938..cf1fd26 100644 --- a/prisma/models/user.prisma +++ b/prisma/models/user.prisma @@ -8,7 +8,9 @@ model User { displayName String profilePictureObjectId String // Object - authMecs LinkedAuthMec[] + authMecs LinkedAuthMec[] + mfas LinkedMFAMec[] + clients Client[] notifications Notification[] collections Collection[] diff --git a/rules/no-prisma-delete.mts b/rules/no-prisma-delete.mts index aa8546f..d50b8c6 100644 --- a/rules/no-prisma-delete.mts +++ b/rules/no-prisma-delete.mts @@ -1,14 +1,16 @@ import type { TSESLint } from "@typescript-eslint/utils"; +const blacklistedFunctions = ["delete", "update"]; + export default { meta: { type: "problem", docs: { - description: "Don't use Prisma error-prone .delete function", + description: "Don't use Prisma error-prone .delete or .update function", }, messages: { noPrismaDelete: - "Prisma .delete(...) function is used. Use .deleteMany(..) and check count instead.", + "Prisma .delete(...) or .update(...) function is used. Use .deleteMany(..) or .updateMany(...) and check count instead.", }, schema: [], }, @@ -17,7 +19,7 @@ export default { CallExpression: function (node) { // @ts-expect-error It ain't typing properly const funcId = node.callee.property; - if (!funcId || funcId.name !== "delete") return; + if (!funcId || !blacklistedFunctions.includes(funcId.name)) return; // @ts-expect-error It ain't typing properly const tableExpr = node.callee.object; if (!tableExpr) return; diff --git a/server/api/v1/admin/company/[id]/banner.post.ts b/server/api/v1/admin/company/[id]/banner.post.ts index 0c8744a..02920e6 100644 --- a/server/api/v1/admin/company/[id]/banner.post.ts +++ b/server/api/v1/admin/company/[id]/banner.post.ts @@ -32,20 +32,20 @@ export default defineEventHandler(async (h3) => { statusMessage: "Upload at least one file.", }); - try { - await objectHandler.deleteAsSystem(company.mBannerObjectId); - await prisma.company.update({ - where: { - id: companyId, - }, - data: { - mBannerObjectId: id, - }, - }); - await pull(); - } catch { + await objectHandler.deleteAsSystem(company.mBannerObjectId); + const { count } = await prisma.company.updateMany({ + where: { + id: companyId, + }, + data: { + mBannerObjectId: id, + }, + }); + if (count == 0) { await dump(); + throw createError({ statusCode: 404, message: "Company not found" }); } + await pull(); return { id: id }; }); diff --git a/server/api/v1/admin/company/[id]/game.delete.ts b/server/api/v1/admin/company/[id]/game.delete.ts index 0a9fe21..b6c120e 100644 --- a/server/api/v1/admin/company/[id]/game.delete.ts +++ b/server/api/v1/admin/company/[id]/game.delete.ts @@ -15,6 +15,15 @@ export default defineEventHandler(async (h3) => { const body = await readDropValidatedBody(h3, GameDelete); + const gameId = await prisma.game.findUnique({ + where: { id: body.id }, + select: { id: true }, + }); + if (!gameId) + throw createError({ statusCode: 404, message: "Game not found" }); + + // SAFETY: above check + // eslint-disable-next-line drop/no-prisma-delete await prisma.game.update({ where: { id: body.id, diff --git a/server/api/v1/admin/company/[id]/game.patch.ts b/server/api/v1/admin/company/[id]/game.patch.ts index 9e0260f..e25ddd9 100644 --- a/server/api/v1/admin/company/[id]/game.patch.ts +++ b/server/api/v1/admin/company/[id]/game.patch.ts @@ -20,6 +20,11 @@ export default defineEventHandler(async (h3) => { const action = body.action === "developed" ? "developers" : "publishers"; const actionType = body.enabled ? "connect" : "disconnect"; + const game = await prisma.game.findUnique({ where: { id: body.id } }); + if (!game) throw createError({ statusCode: 404, message: "Game not found" }); + + // Safe because we query the game above + // eslint-disable-next-line drop/no-prisma-delete await prisma.game.update({ where: { id: body.id, diff --git a/server/api/v1/admin/company/[id]/game.post.ts b/server/api/v1/admin/company/[id]/game.post.ts index f873118..0f56d83 100644 --- a/server/api/v1/admin/company/[id]/game.post.ts +++ b/server/api/v1/admin/company/[id]/game.post.ts @@ -43,6 +43,15 @@ export default defineEventHandler(async (h3) => { } : undefined; + const gameId = await prisma.game.findUnique({ + where: { id: body.id }, + select: { id: true }, + }); + if (!gameId) + throw createError({ statusCode: 404, message: "Game not found" }); + + // SAFETY: Above check makes this update okay + // eslint-disable-next-line drop/no-prisma-delete const game = await prisma.game.update({ where: { id: body.id, diff --git a/server/api/v1/admin/company/[id]/icon.post.ts b/server/api/v1/admin/company/[id]/icon.post.ts index 0a4cefd..d2ac951 100644 --- a/server/api/v1/admin/company/[id]/icon.post.ts +++ b/server/api/v1/admin/company/[id]/icon.post.ts @@ -32,20 +32,21 @@ export default defineEventHandler(async (h3) => { statusMessage: "Upload at least one file.", }); - try { - await objectHandler.deleteAsSystem(company.mLogoObjectId); - await prisma.company.update({ - where: { - id: companyId, - }, - data: { - mLogoObjectId: id, - }, - }); - await pull(); - } catch { + await objectHandler.deleteAsSystem(company.mLogoObjectId); + const { count } = await prisma.company.updateMany({ + where: { + id: companyId, + }, + data: { + mLogoObjectId: id, + }, + }); + if (count == 0) { await dump(); + throw createError({ statusCode: 404, message: "Company not found" }); } + await pull(); + return { id: id }; }); diff --git a/server/api/v1/admin/company/[id]/index.patch.ts b/server/api/v1/admin/company/[id]/index.patch.ts index 74511e2..5c3ba1d 100644 --- a/server/api/v1/admin/company/[id]/index.patch.ts +++ b/server/api/v1/admin/company/[id]/index.patch.ts @@ -11,13 +11,17 @@ export default defineEventHandler(async (h3) => { const restOfTheBody = { ...body }; delete restOfTheBody["id"]; - const newObj = await prisma.company.update({ - where: { - id: id, - }, - data: restOfTheBody, - // I would put a select here, but it would be based on the body, and muck up the types - }); + const newObj = ( + await prisma.company.updateManyAndReturn({ + where: { + id: id, + }, + data: restOfTheBody, + // I would put a select here, but it would be based on the body, and muck up the types + }) + ).at(0); + if (!newObj) + throw createError({ statusCode: 404, message: "Company not found" }); return newObj; }); diff --git a/server/api/v1/admin/depot/index.post.ts b/server/api/v1/admin/depot/index.post.ts new file mode 100644 index 0000000..73d5583 --- /dev/null +++ b/server/api/v1/admin/depot/index.post.ts @@ -0,0 +1,21 @@ +import { type } from "arktype"; +import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +const CreateDepot = type({ + endpoint: "string", +}).configure(throwingArktype); + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["depot:new"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const body = await readDropValidatedBody(h3, CreateDepot); + + const depot = await prisma.depot.create({ + data: { endpoint: body.endpoint }, + }); + + return depot; +}); diff --git a/server/api/v1/admin/depot/manifest.get.ts b/server/api/v1/admin/depot/manifest.get.ts new file mode 100644 index 0000000..1f0b7b7 --- /dev/null +++ b/server/api/v1/admin/depot/manifest.get.ts @@ -0,0 +1,59 @@ +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({ + game: "string", + 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: { + gameId_versionId: { + gameId: query.game, + 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, + }; +}); diff --git a/server/api/v1/admin/depot/versions.get.ts b/server/api/v1/admin/depot/versions.get.ts new file mode 100644 index 0000000..0e0aafa --- /dev/null +++ b/server/api/v1/admin/depot/versions.get.ts @@ -0,0 +1,19 @@ +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, + }, + }, + }, + }); + + return games; +}); diff --git a/server/api/v1/admin/game/[id]/index.get.ts b/server/api/v1/admin/game/[id]/index.get.ts index 54f1af8..90f8499 100644 --- a/server/api/v1/admin/game/[id]/index.get.ts +++ b/server/api/v1/admin/game/[id]/index.get.ts @@ -1,9 +1,67 @@ -import type { GameVersion } from "~/prisma/client/client"; +import type { GameVersion, Prisma } from "~/prisma/client/client"; import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; import libraryManager from "~/server/internal/library"; -export default defineEventHandler(async (h3) => { +async function getGameVersionSize< + T extends Omit, +>(gameId: string, version: T) { + const size = await libraryManager.getGameVersionSize( + gameId, + version.versionId, + ); + return { ...version, size }; +} + +export type AdminFetchGameType = Prisma.GameGetPayload<{ + include: { + versions: { + include: { + setups: true; + launches: { + include: { + executor: { + include: { + gameVersion: { + select: { + versionId: true; + displayName: true; + versionPath: true; + game: { + select: { + id: true; + mName: true; + mIconObjectId: true; + }; + }; + }; + }; + }; + }; + executions: { + select: { + launchId: true; + }; + }; + }; + }; + }; + omit: { + dropletManifest: true; + }; + }; + tags: true; + }; +}>; + +// Types in the route ensure we actually return the value as defined above +export default defineEventHandler< + { body: never }, + Promise<{ + game: AdminFetchGameType; + unimportedVersions: string[] | undefined; + }> +>(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, ["game:read"]); if (!allowed) throw createError({ statusCode: 403 }); @@ -15,12 +73,42 @@ export default defineEventHandler(async (h3) => { }, include: { versions: { - orderBy: { - versionIndex: "asc", + include: { + setups: true, + launches: { + include: { + executor: { + include: { + gameVersion: { + select: { + versionId: true, + displayName: true, + versionPath: true, + game: { + select: { + id: true, + mName: true, + mIconObjectId: true, + }, + }, + }, + }, + }, + }, + executions: { + select: { + launchId: true, + }, + }, + }, + }, }, omit: { dropletManifest: true, }, + orderBy: { + versionIndex: "asc", + }, }, tags: true, }, @@ -29,16 +117,11 @@ export default defineEventHandler(async (h3) => { if (!game || !game.libraryId) throw createError({ statusCode: 404, statusMessage: "Game ID not found" }); - const getGameVersionSize = async (version: GameVersion) => { - const size = await libraryManager.getGameVersionSize( - gameId, - version.versionName, - ); - return { ...version, size }; - }; const gameWithVersionSize = { ...game, - versions: await Promise.all(game.versions.map(getGameVersionSize)), + versions: await Promise.all( + game.versions.map((v) => getGameVersionSize(gameId, v)), + ), }; const unimportedVersions = await libraryManager.fetchUnimportedGameVersions( diff --git a/server/api/v1/admin/game/[id]/index.patch.ts b/server/api/v1/admin/game/[id]/index.patch.ts index 410adee..0b51adf 100644 --- a/server/api/v1/admin/game/[id]/index.patch.ts +++ b/server/api/v1/admin/game/[id]/index.patch.ts @@ -11,13 +11,18 @@ export default defineEventHandler(async (h3) => { const restOfTheBody = { ...body }; delete restOfTheBody["id"]; - const newObj = await prisma.game.update({ - where: { - id: id, - }, - data: restOfTheBody, - // I would put a select here, but it would be based on the body, and muck up the types - }); + const newObj = ( + await prisma.game.updateManyAndReturn({ + where: { + id: id, + }, + data: restOfTheBody, + // I would put a select here, but it would be based on the body, and muck up the types + }) + ).at(0); + + if (!newObj) + throw createError({ statusCode: 404, message: "Game not found" }); return newObj; }); diff --git a/server/api/v1/admin/game/[id]/metadata.post.ts b/server/api/v1/admin/game/[id]/metadata.post.ts index cd1dcbc..3d46b2e 100644 --- a/server/api/v1/admin/game/[id]/metadata.post.ts +++ b/server/api/v1/admin/game/[id]/metadata.post.ts @@ -52,12 +52,17 @@ export default defineEventHandler(async (h3) => { } } - const newObject = await prisma.game.update({ - where: { - id: gameId, - }, - data: updateModel, - }); + const newObject = ( + await prisma.game.updateManyAndReturn({ + where: { + id: gameId, + }, + data: updateModel, + }) + ).at(0); + + if (!newObject) + throw createError({ statusCode: 404, message: "Game not found" }); return newObject; }); diff --git a/server/api/v1/admin/game/[id]/tags.patch.ts b/server/api/v1/admin/game/[id]/tags.patch.ts index d7192ad..83f1432 100644 --- a/server/api/v1/admin/game/[id]/tags.patch.ts +++ b/server/api/v1/admin/game/[id]/tags.patch.ts @@ -14,6 +14,14 @@ export default defineEventHandler(async (h3) => { const body = await readDropValidatedBody(h3, PatchTags); const id = getRouterParam(h3, "id")!; + const game = await prisma.game.findUnique({ + where: { id }, + select: { id: true }, + }); + if (!game) throw createError({ statusCode: 404, message: "Game not found" }); + + // SAFETY: Okay to disable due to check above + // eslint-disable-next-line drop/no-prisma-delete await prisma.game.update({ where: { id, diff --git a/server/api/v1/admin/game/version/index.delete.ts b/server/api/v1/admin/game/[id]/versions/index.delete.ts similarity index 83% rename from server/api/v1/admin/game/version/index.delete.ts rename to server/api/v1/admin/game/[id]/versions/index.delete.ts index 6037858..00d54dc 100644 --- a/server/api/v1/admin/game/version/index.delete.ts +++ b/server/api/v1/admin/game/[id]/versions/index.delete.ts @@ -4,8 +4,7 @@ import aclManager from "~/server/internal/acls"; import libraryManager from "~/server/internal/library"; const DeleteVersion = type({ - id: "string", - versionName: "string", + version: "string", }).configure(throwingArktype); export default defineEventHandler<{ body: typeof DeleteVersion }>( @@ -17,8 +16,8 @@ export default defineEventHandler<{ body: typeof DeleteVersion }>( const body = await readDropValidatedBody(h3, DeleteVersion); - const gameId = body.id.toString(); - const version = body.versionName.toString(); + const gameId = getRouterParam(h3, "id")!; + const version = body.version.toString(); await libraryManager.deleteGameVersion(gameId, version); return {}; diff --git a/server/api/v1/admin/game/[id]/versions/index.get.ts b/server/api/v1/admin/game/[id]/versions/index.get.ts new file mode 100644 index 0000000..23a0098 --- /dev/null +++ b/server/api/v1/admin/game/[id]/versions/index.get.ts @@ -0,0 +1,35 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["game:read"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const id = getRouterParam(h3, "id")!; + + const game = await prisma.game.findUnique({ + where: { + id, + }, + select: { + versions: { + select: { + versionId: true, + displayName: true, + versionPath: true, + launches: { + select: { + launchId: true, + command: true, + name: true, + platform: true, + }, + }, + }, + }, + }, + }); + if (!game) throw createError({ statusCode: 404, message: "Game not found" }); + + return game.versions; +}); diff --git a/server/api/v1/admin/game/[id]/versions/index.patch.ts b/server/api/v1/admin/game/[id]/versions/index.patch.ts new file mode 100644 index 0000000..be3adab --- /dev/null +++ b/server/api/v1/admin/game/[id]/versions/index.patch.ts @@ -0,0 +1,67 @@ +import { type } from "arktype"; +import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +const UpdateVersionOrder = type({ + versions: "string[]", +}).configure(throwingArktype); + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["game:version:update"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const body = await readDropValidatedBody(h3, UpdateVersionOrder); + const gameId = getRouterParam(h3, "id")!; + // We expect an array of the version names for this game + const unsortedVersions = await prisma.gameVersion.findMany({ + where: { + versionId: { in: body.versions }, + }, + select: { + versionId: true, + versionIndex: true, + delta: true, + launches: { select: { platform: true } }, + }, + }); + + const versions = body.versions + .map((e) => unsortedVersions.find((v) => v.versionId === e)) + .filter((e) => e !== undefined); + + if (versions.length !== unsortedVersions.length) + throw createError({ + statusCode: 500, + statusMessage: "Sorting versions yielded less results, somehow.", + }); + + // Validate the new order + const has: { [key: string]: boolean } = {}; + for (const version of versions) { + for (const versionPlatform of version.launches.map((v) => v.platform)) { + if (version.delta && !has[versionPlatform]) + throw createError({ + statusCode: 400, + statusMessage: `"${version.versionId}" requires a base version to apply the delta to for platform ${versionPlatform}.`, + }); + has[versionPlatform] = true; + } + } + + await prisma.$transaction( + versions.map((version, versionIndex) => + prisma.gameVersion.updateMany({ + where: { + gameId: gameId, + versionId: version.versionId, + }, + data: { + versionIndex: versionIndex, + }, + }), + ), + ); + + return versions.map((v) => v.versionId); +}); diff --git a/server/api/v1/admin/game/image/index.delete.ts b/server/api/v1/admin/game/image/index.delete.ts index d89ae76..546d58f 100644 --- a/server/api/v1/admin/game/image/index.delete.ts +++ b/server/api/v1/admin/game/image/index.delete.ts @@ -48,21 +48,23 @@ export default defineEventHandler<{ game.mCoverObjectId = game.mImageLibraryObjectIds[0]; } - const result = await prisma.game.update({ - where: { - id: gameId, - }, - data: { - mBannerObjectId: game.mBannerObjectId, - mImageLibraryObjectIds: game.mImageLibraryObjectIds, - mCoverObjectId: game.mCoverObjectId, - }, - select: { - mBannerObjectId: true, - mImageLibraryObjectIds: true, - mCoverObjectId: true, - }, - }); + const result = ( + await prisma.game.updateManyAndReturn({ + where: { + id: gameId, + }, + data: { + mBannerObjectId: game.mBannerObjectId, + mImageLibraryObjectIds: game.mImageLibraryObjectIds, + mCoverObjectId: game.mCoverObjectId, + }, + select: { + mBannerObjectId: true, + mImageLibraryObjectIds: true, + mCoverObjectId: true, + }, + }) + ).at(0); return result; }); diff --git a/server/api/v1/admin/game/image/index.post.ts b/server/api/v1/admin/game/image/index.post.ts index e230e9f..4f17613 100644 --- a/server/api/v1/admin/game/image/index.post.ts +++ b/server/api/v1/admin/game/image/index.post.ts @@ -42,16 +42,18 @@ export default defineEventHandler(async (h3) => { throw createError({ statusCode: 400, statusMessage: "Invalid game ID" }); } - const result = await prisma.game.update({ - where: { - id: gameId, - }, - data: { - mImageLibraryObjectIds: { - push: ids, + const result = ( + await prisma.game.updateManyAndReturn({ + where: { + id: gameId, }, - }, - }); + data: { + mImageLibraryObjectIds: { + push: ids, + }, + }, + }) + ).at(0); await pull(); return result; diff --git a/server/api/v1/admin/game/version/index.patch.ts b/server/api/v1/admin/game/version/index.patch.ts deleted file mode 100644 index 54e0547..0000000 --- a/server/api/v1/admin/game/version/index.patch.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { type } from "arktype"; -import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; -import aclManager from "~/server/internal/acls"; -import prisma from "~/server/internal/db/database"; - -const UpdateVersionOrder = type({ - id: "string", - versions: "string[]", -}).configure(throwingArktype); - -export default defineEventHandler<{ body: typeof UpdateVersionOrder }>( - async (h3) => { - const allowed = await aclManager.allowSystemACL(h3, [ - "game:version:update", - ]); - if (!allowed) throw createError({ statusCode: 403 }); - - const body = await readDropValidatedBody(h3, UpdateVersionOrder); - const gameId = body.id; - // We expect an array of the version names for this game - const unsortedVersions = await prisma.gameVersion.findMany({ - where: { - versionName: { in: body.versions }, - }, - select: { - versionName: true, - versionIndex: true, - delta: true, - platform: true, - }, - }); - - const versions = body.versions - .map((e) => unsortedVersions.find((v) => v.versionName === e)) - .filter((e) => e !== undefined); - - if (versions.length !== unsortedVersions.length) - throw createError({ - statusCode: 500, - statusMessage: "Sorting versions yielded less results, somehow.", - }); - - // Validate the new order - const has: { [key: string]: boolean } = {}; - for (const version of versions) { - if (version.delta && !has[version.platform]) - throw createError({ - statusCode: 400, - statusMessage: `"${version.versionName}" requires a base version to apply the delta to.`, - }); - has[version.platform] = true; - } - - await prisma.$transaction( - versions.map((version, versionIndex) => - prisma.gameVersion.update({ - where: { - gameId_versionName: { - gameId: gameId, - versionName: version.versionName, - }, - }, - data: { - versionIndex: versionIndex, - }, - }), - ), - ); - - return versions; - }, -); diff --git a/server/api/v1/admin/import/version/index.post.ts b/server/api/v1/admin/import/version/index.post.ts index ab9ad1c..cac4766 100644 --- a/server/api/v1/admin/import/version/index.post.ts +++ b/server/api/v1/admin/import/version/index.post.ts @@ -1,84 +1,77 @@ import { type } from "arktype"; +import { Platform } from "~/prisma/client/enums"; import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; import libraryManager from "~/server/internal/library"; -import { parsePlatform } from "~/server/internal/utils/parseplatform"; -const ImportVersion = type({ +export const ImportVersion = type({ id: "string", version: "string", + displayName: "string?", + + launches: type({ + platform: type.valueOf(Platform), + name: "string", + launch: "string", + umuId: "string?", + executorId: "string?", + }).array(), + + setups: type({ + platform: type.valueOf(Platform), + launch: "string", + }).array(), - platform: "string", - launch: "string = ''", - launchArgs: "string = ''", - setup: "string = ''", - setupArgs: "string = ''", onlySetup: "boolean = false", delta: "boolean = false", - umuId: "string = ''", }).configure(throwingArktype); export default defineEventHandler(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, ["import:version:new"]); if (!allowed) throw createError({ statusCode: 403 }); - const { - id, - version, - platform, - launch, - launchArgs, - setup, - setupArgs, - onlySetup, - delta, - umuId, - } = await readDropValidatedBody(h3, ImportVersion); + const body = await readDropValidatedBody(h3, ImportVersion); - const platformParsed = parsePlatform(platform); - if (!platformParsed) - throw createError({ statusCode: 400, statusMessage: "Invalid platform." }); - - if (delta) { - const validOverlayVersions = await prisma.gameVersion.count({ - where: { gameId: id, platform: platformParsed, delta: false }, - }); - if (validOverlayVersions == 0) - throw createError({ - statusCode: 400, - statusMessage: - "Update mode requires a pre-existing version for this platform.", + if (body.delta) { + for (const platformObject of [...body.launches, ...body.setups].filter( + (v, i, a) => a.findIndex((k) => k.platform === v.platform) == i, + )) { + const validOverlayVersions = await prisma.gameVersion.count({ + where: { + gameId: body.id, + delta: false, + launches: { some: { platform: platformObject.platform } }, + }, }); + if (validOverlayVersions == 0) + throw createError({ + statusCode: 400, + statusMessage: "Update mode requires a pre-existing version.", + }); + } } - if (onlySetup) { - if (!setup) + if (body.onlySetup) { + if (body.setups.length == 0) throw createError({ statusCode: 400, statusMessage: 'Setup required in "setup mode".', }); } else { - if (!delta && !launch) + if (body.launches.length == 0) throw createError({ statusCode: 400, - statusMessage: "Launch executable is required for non-update versions", + statusMessage: "Launch executable is required.", }); } // startup & delta require more complex checking logic - const taskId = await libraryManager.importVersion(id, version, { - platform, - onlySetup, - - launch, - launchArgs, - setup, - setupArgs, - - umuId, - delta, - }); + const taskId = await libraryManager.importVersion( + body.id, + body.version, + body, + ); if (!taskId) throw createError({ statusCode: 400, diff --git a/server/api/v1/admin/import/version/preload.get.ts b/server/api/v1/admin/import/version/preload.get.ts index d83b936..5e0476c 100644 --- a/server/api/v1/admin/import/version/preload.get.ts +++ b/server/api/v1/admin/import/version/preload.get.ts @@ -14,15 +14,22 @@ export default defineEventHandler(async (h3) => { statusMessage: "Missing id or version in request params", }); - const preload = await libraryManager.fetchUnimportedVersionInformation( - gameId, - versionName, - ); - if (!preload) - throw createError({ - statusCode: 400, - statusMessage: "Invalid game or version id/name", - }); + try { + const preload = await libraryManager.fetchUnimportedVersionInformation( + gameId, + versionName, + ); + if (!preload) + throw createError({ + statusCode: 400, + statusMessage: "Invalid game or version id/name", + }); - return preload; + return preload; + } catch (e) { + throw createError({ + statusCode: 500, + message: `Failed to fetch preload information for ${gameId}: ${e}`, + }); + } }); diff --git a/server/api/v1/admin/library/sources/index.patch.ts b/server/api/v1/admin/library/sources/index.patch.ts index 7cec327..325b25a 100644 --- a/server/api/v1/admin/library/sources/index.patch.ts +++ b/server/api/v1/admin/library/sources/index.patch.ts @@ -31,15 +31,15 @@ export default defineEventHandler<{ body: typeof UpdateLibrarySource.infer }>( const constructor = libraryConstructors[source.backend]; - try { - const newLibrary = constructor(body.options, source.id); + const newLibrary = constructor(body.options, source.id); - // Test we can actually use it - if ((await newLibrary.listGames()) === undefined) { - throw "Library failed to fetch games."; - } + // Test we can actually use it + if ((await newLibrary.listGames()) === undefined) { + throw "Library failed to fetch games."; + } - const updatedSource = await prisma.library.update({ + const updatedSource = ( + await prisma.library.updateManyAndReturn({ where: { id: source.id, }, @@ -47,22 +47,22 @@ export default defineEventHandler<{ body: typeof UpdateLibrarySource.infer }>( name: body.name, options: body.options, }, - }); - - libraryManager.removeLibrary(source.id); - libraryManager.addLibrary(newLibrary); - - const workingSource: WorkingLibrarySource = { - ...updatedSource, - working: true, - }; - - return workingSource; - } catch (e) { + }) + ).at(0); + if (!updatedSource) throw createError({ - statusCode: 400, - statusMessage: `Failed to create source: ${e}`, + statusCode: 404, + message: "Library source not found", }); - } + + libraryManager.removeLibrary(source.id); + libraryManager.addLibrary(newLibrary); + + const workingSource: WorkingLibrarySource = { + ...updatedSource, + working: true, + }; + + return workingSource; }, ); diff --git a/server/api/v1/admin/search/game.get.ts b/server/api/v1/admin/search/game.get.ts new file mode 100644 index 0000000..2d10a9f --- /dev/null +++ b/server/api/v1/admin/search/game.get.ts @@ -0,0 +1,39 @@ +import { ArkErrors, type } from "arktype"; +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; +import type { GameMetadataSearchResult } from "~/server/internal/metadata/types"; + +const Query = type({ + q: "string", +}); + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["game:read"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const query = Query(getQuery(h3)); + if (query instanceof ArkErrors) + throw createError({ statusCode: 400, message: query.summary }); + + const results: { + id: string; + mName: string; + mIconObjectId: string; + mShortDescription: string; + mReleased: string; + }[] = + await prisma.$queryRaw`SELECT id, "mName", "mIconObjectId", "mShortDescription", "mReleased" FROM "Game" WHERE SIMILARITY("mName", ${query.q}) > 0.2 ORDER BY SIMILARITY("mName", ${query.q}) DESC;`; + + const resultsMapped = results.map( + (v) => + ({ + id: v.id, + name: v.mName, + icon: v.mIconObjectId, + description: v.mShortDescription, + year: new Date(v.mReleased).getFullYear(), + }) satisfies GameMetadataSearchResult, + ); + + return resultsMapped; +}); diff --git a/server/api/v1/admin/services/index.get.ts b/server/api/v1/admin/services/index.get.ts new file mode 100644 index 0000000..4232f22 --- /dev/null +++ b/server/api/v1/admin/services/index.get.ts @@ -0,0 +1,10 @@ +import aclManager from "~/server/internal/acls"; +import serviceManager from "~/server/internal/services"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["maintenance:read"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const healthcheck = serviceManager.healthchecks(); + return healthcheck; +}); diff --git a/server/api/v1/auth/mfa/index.get.ts b/server/api/v1/auth/mfa/index.get.ts new file mode 100644 index 0000000..1c059f0 --- /dev/null +++ b/server/api/v1/auth/mfa/index.get.ts @@ -0,0 +1,22 @@ +import sessionHandler from "~/server/internal/session"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const session = await sessionHandler.getSession(h3); + if (!session || !session.authenticated || session.authenticated.level == 0) + throw createError({ + statusCode: 403, + message: "Sign in before completing MFA", + }); + + const linkedMFAMec = await prisma.linkedMFAMec.findMany({ + where: { + userId: session.authenticated.userId, + }, + select: { + mec: true, + }, + }); + + return linkedMFAMec.map((v) => v.mec); +}); diff --git a/server/api/v1/auth/mfa/totp.post.ts b/server/api/v1/auth/mfa/totp.post.ts new file mode 100644 index 0000000..faac527 --- /dev/null +++ b/server/api/v1/auth/mfa/totp.post.ts @@ -0,0 +1,48 @@ +import sessionHandler from "~/server/internal/session"; +import { type } from "arktype"; +import prisma from "~/server/internal/db/database"; +import { MFAMec } from "~/prisma/client/client"; +import type { TOTPv1Credentials } from "~/server/internal/auth/totp"; +import { dropDecodeArrayBase64 } from "~/server/internal/auth/totp"; +import { SecretKey, totp } from "otp-io"; +import { hmac } from "otp-io/crypto-web"; +import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; + +const TOTPBody = type({ + code: "string", +}).configure(throwingArktype); + +export default defineEventHandler(async (h3) => { + const session = await sessionHandler.getSession(h3); + if (!session || !session.authenticated || session.authenticated.level == 0) + throw createError({ + statusCode: 403, + message: "Sign in before completing MFA", + }); + + const body = await readDropValidatedBody(h3, TOTPBody); + + const linkedMFAMec = await prisma.linkedMFAMec.findUnique({ + where: { + userId_mec: { + userId: session.authenticated.userId, + mec: MFAMec.TOTP, + }, + }, + }); + if (!linkedMFAMec) + throw createError({ statusCode: 400, message: "TOTP not enabled" }); + + const secret = (linkedMFAMec.credentials as unknown as TOTPv1Credentials) + .secret; + const secretKeyBuffer = dropDecodeArrayBase64(secret); + const secretKey = new SecretKey(secretKeyBuffer); + + const code = await totp(hmac, { secret: secretKey }); + if (code !== body.code) + throw createError({ statusCode: 403, message: "Invalid TOTP code." }); + + await sessionHandler.mfa(h3, 10); + + return {}; +}); diff --git a/server/api/v1/auth/mfa/webauthn/finish.post.ts b/server/api/v1/auth/mfa/webauthn/finish.post.ts new file mode 100644 index 0000000..4c636c3 --- /dev/null +++ b/server/api/v1/auth/mfa/webauthn/finish.post.ts @@ -0,0 +1,108 @@ +import { verifyAuthenticationResponse } from "@simplewebauthn/server"; +import { MFAMec } from "~/prisma/client/enums"; +import { dropDecodeArrayBase64 } from "~/server/internal/auth/totp"; +import type { WebAuthNv1Credentials } from "~/server/internal/auth/webauthn"; +import { getRpId } from "~/server/internal/auth/webauthn"; +import { systemConfig } from "~/server/internal/config/sys-conf"; +import prisma from "~/server/internal/db/database"; +import sessionHandler from "~/server/internal/session"; + +export default defineEventHandler(async (h3) => { + const session = await sessionHandler.getSession(h3); + if (!session || !session.authenticated || session.authenticated.level == 0) + throw createError({ + statusCode: 403, + message: "Sign in before completing MFA", + }); + + const body = await readBody(h3); + const credentialId = body?.id; + if (!credentialId || typeof credentialId !== "string") + throw createError({ + statusCode: 400, + message: "Missing credential id in body.", + }); + + const optionsRaw = await sessionHandler.getSessionDataKey( + h3, + "webauthn/options", + ); + if (!optionsRaw) + throw createError({ + statusCode: 400, + message: "WebAuthn setup not started for this session.", + }); + const options = JSON.parse(optionsRaw); + await sessionHandler.deleteSessionDataKey(h3, "webauthn/challenge"); + + const mfaMec = await prisma.linkedMFAMec.findUnique({ + where: { + userId_mec: { + userId: session.authenticated.userId, + mec: MFAMec.WebAuthn, + }, + }, + }); + if (!mfaMec) + throw createError({ statusCode: 400, message: "WebAuthn not enabled" }); + + const rpID = await getRpId(); + const passkeys = (mfaMec.credentials as unknown as WebAuthNv1Credentials) + .passkeys; + const passkeyIndex = passkeys.findIndex((v) => v.id === body.id); + if (passkeyIndex == -1) + throw createError({ statusCode: 400, message: "Invalid credential ID." }); + const passkey = passkeys[passkeyIndex]; + + const externalUrl = await systemConfig.getExternalUrl(); + const url = new URL(externalUrl); + + let verification; + try { + verification = await verifyAuthenticationResponse({ + response: body, + expectedChallenge: options.challenge, + expectedOrigin: url.origin, + expectedRPID: rpID, + credential: { + id: passkey.id, + publicKey: Buffer.from(dropDecodeArrayBase64(passkey.publicKey)), + counter: passkey.counter, + transports: passkey.transports ?? [], + }, + }); + } catch (error) { + throw createError({ + statusCode: 400, + message: (error as string)?.toString(), + }); + } + + const { verified } = verification; + if (!verified) + throw createError({ statusCode: 403, message: "Invalid passkey." }); + + const { authenticationInfo } = verification; + const { newCounter } = authenticationInfo; + + passkeys[passkeyIndex].counter = newCounter; + (mfaMec.credentials as unknown as WebAuthNv1Credentials).passkeys = passkeys; + + // Safe because we query it at the start of the route + // eslint-disable-next-line drop/no-prisma-delete + await prisma.linkedMFAMec.update({ + where: { + userId_mec: { + userId: session.authenticated.userId, + mec: MFAMec.WebAuthn, + }, + }, + data: { + credentials: mfaMec.credentials!, + }, + }); + + await sessionHandler.mfa(h3, 10); + + return {}; +}); diff --git a/server/api/v1/auth/mfa/webauthn/start.post.ts b/server/api/v1/auth/mfa/webauthn/start.post.ts new file mode 100644 index 0000000..508de16 --- /dev/null +++ b/server/api/v1/auth/mfa/webauthn/start.post.ts @@ -0,0 +1,49 @@ +import { generateAuthenticationOptions } from "@simplewebauthn/server"; +import { MFAMec } from "~/prisma/client/enums"; +import type { WebAuthNv1Credentials } from "~/server/internal/auth/webauthn"; +import { getRpId } from "~/server/internal/auth/webauthn"; +import prisma from "~/server/internal/db/database"; +import sessionHandler from "~/server/internal/session"; + +export default defineEventHandler(async (h3) => { + const session = await sessionHandler.getSession(h3); + if (!session || !session.authenticated || session.authenticated.level == 0) + throw createError({ + statusCode: 403, + message: "Sign in before completing MFA", + }); + + const mec = await prisma.linkedMFAMec.findUnique({ + where: { + userId_mec: { + userId: session.authenticated.userId, + mec: MFAMec.WebAuthn, + }, + }, + }); + if (!mec) + throw createError({ + statusCode: 400, + message: "WebAuthn not enabled on account.", + }); + + const rpID = await getRpId(); + const passkeys = (mec.credentials as unknown as WebAuthNv1Credentials) + .passkeys; + + const options = await generateAuthenticationOptions({ + rpID, + allowCredentials: passkeys.map((v) => ({ + id: v.id, + transports: v.transports ?? [], + })), + }); + + await sessionHandler.setSessionDataKey( + h3, + "webauthn/options", + JSON.stringify(options), + ); + + return options; +}); diff --git a/server/api/v1/auth/passkey/finish.post.ts b/server/api/v1/auth/passkey/finish.post.ts new file mode 100644 index 0000000..cefa025 --- /dev/null +++ b/server/api/v1/auth/passkey/finish.post.ts @@ -0,0 +1,105 @@ +import { verifyAuthenticationResponse } from "@simplewebauthn/server"; +import { MFAMec } from "~/prisma/client/enums"; +import { dropDecodeArrayBase64 } from "~/server/internal/auth/totp"; +import type { WebAuthNv1Credentials } from "~/server/internal/auth/webauthn"; +import { getRpId } from "~/server/internal/auth/webauthn"; +import { systemConfig } from "~/server/internal/config/sys-conf"; +import prisma from "~/server/internal/db/database"; +import sessionHandler from "~/server/internal/session"; + +export default defineEventHandler(async (h3) => { + const body = await readBody(h3); + const credentialId = body?.id; + if (!credentialId || typeof credentialId !== "string") + throw createError({ + statusCode: 400, + message: "Missing credential id in body.", + }); + + const optionsRaw = await sessionHandler.getSessionDataKey( + h3, + "webauthn/options", + ); + if (!optionsRaw) + throw createError({ + statusCode: 400, + message: "WebAuthn setup not started for this session.", + }); + const options = JSON.parse(optionsRaw); + await sessionHandler.deleteSessionDataKey(h3, "webauthn/challenge"); + + // See WebAuthNv1Credentials for schema + const mfaMec = await prisma.linkedMFAMec.findFirst({ + where: { + credentials: { + path: ["passkeys"], + array_contains: [ + { + id: credentialId, + }, + ], + }, + }, + }); + if (!mfaMec) + throw createError({ statusCode: 404, message: "Passkey not found" }); + + const passkeys = (mfaMec.credentials as unknown as WebAuthNv1Credentials) + .passkeys; + const passkeyIndex = passkeys.findIndex((v) => v.id === credentialId); + const passkey = passkeys[passkeyIndex]; // Exists guarantee by database + + const rpID = await getRpId(); + const externalUrl = await systemConfig.getExternalUrl(); + const url = new URL(externalUrl); + + let verification; + try { + verification = await verifyAuthenticationResponse({ + response: body, + expectedChallenge: options.challenge, + expectedOrigin: url.origin, + expectedRPID: rpID, + credential: { + id: passkey.id, + publicKey: Buffer.from(dropDecodeArrayBase64(passkey.publicKey)), + counter: passkey.counter, + transports: passkey.transports ?? [], + }, + }); + } catch (error) { + throw createError({ + statusCode: 400, + message: (error as string)?.toString(), + }); + } + + const { verified } = verification; + if (!verified) + throw createError({ statusCode: 403, message: "Invalid passkey." }); + + const { authenticationInfo } = verification; + const { newCounter } = authenticationInfo; + + passkeys[passkeyIndex].counter = newCounter; + (mfaMec.credentials as unknown as WebAuthNv1Credentials).passkeys = passkeys; + + // Safe because we query it before + // eslint-disable-next-line drop/no-prisma-delete + await prisma.linkedMFAMec.update({ + where: { + userId_mec: { + userId: mfaMec.userId, + mec: MFAMec.WebAuthn, + }, + }, + data: { + credentials: mfaMec.credentials!, + }, + }); + + await sessionHandler.signin(h3, mfaMec.userId, true); + await sessionHandler.mfa(h3, 10); + + return {}; +}); diff --git a/server/api/v1/auth/passkey/start.post.ts b/server/api/v1/auth/passkey/start.post.ts new file mode 100644 index 0000000..8c69b45 --- /dev/null +++ b/server/api/v1/auth/passkey/start.post.ts @@ -0,0 +1,26 @@ +import { generateAuthenticationOptions } from "@simplewebauthn/server"; +import { getRpId } from "~/server/internal/auth/webauthn"; +import sessionHandler from "~/server/internal/session"; + +export default defineEventHandler(async (h3) => { + const rpID = await getRpId(); + + const options = await generateAuthenticationOptions({ + rpID, + allowCredentials: [], + }); + + if ( + !(await sessionHandler.setSessionDataKey( + h3, + "webauthn/options", + JSON.stringify(options), + )) + ) + throw createError({ + statusCode: 500, + message: "Failed to set session data key", + }); + + return options; +}); diff --git a/server/api/v1/auth/signin/simple.post.ts b/server/api/v1/auth/signin/simple.post.ts index 3e4ca03..083fdcc 100644 --- a/server/api/v1/auth/signin/simple.post.ts +++ b/server/api/v1/auth/signin/simple.post.ts @@ -84,8 +84,17 @@ export default defineEventHandler<{ }); // TODO: send user to forgot password screen or something to force them to change their password to new system - await sessionHandler.signin(h3, authMek.userId, body.rememberMe); - return { result: true, userId: authMek.userId }; + const result = await sessionHandler.signin( + h3, + authMek.userId, + body.rememberMe, + ); + if (result === "fail") + throw createError({ + statusCode: 500, + message: "Failed to create session", + }); + return { userId: authMek.userId, result }; } // V2: argon2 @@ -102,6 +111,12 @@ export default defineEventHandler<{ statusMessage: t("errors.auth.invalidUserOrPass"), }); - await sessionHandler.signin(h3, authMek.userId, body.rememberMe); - return { result: true, userId: authMek.userId }; + const result = await sessionHandler.signin( + h3, + authMek.userId, + body.rememberMe, + ); + if (result == "fail") + throw createError({ statusCode: 500, message: "Failed to create session" }); + return { userId: authMek.userId, result }; }); diff --git a/server/api/v1/client/auth/callback/index.post.ts b/server/api/v1/client/auth/callback/index.post.ts index bc63be7..d7a1530 100644 --- a/server/api/v1/client/auth/callback/index.post.ts +++ b/server/api/v1/client/auth/callback/index.post.ts @@ -2,8 +2,9 @@ import clientHandler from "~/server/internal/clients/handler"; import sessionHandler from "~/server/internal/session"; export default defineEventHandler(async (h3) => { - const user = await sessionHandler.getSession(h3); - if (!user) throw createError({ statusCode: 403 }); + const session = await sessionHandler.getSession(h3); + if (!session || !session.authenticated) + throw createError({ statusCode: 403 }); const body = await readBody(h3); const clientId = await body.id; @@ -15,7 +16,7 @@ export default defineEventHandler(async (h3) => { statusMessage: "Invalid or expired client ID.", }); - if (client.userId != user.userId) + if (client.userId != session.authenticated.userId) throw createError({ statusCode: 403, statusMessage: "Not allowed to authorize this client.", diff --git a/server/api/v1/client/auth/code/index.get.ts b/server/api/v1/client/auth/code/index.get.ts index 74bc6e8..858481f 100644 --- a/server/api/v1/client/auth/code/index.get.ts +++ b/server/api/v1/client/auth/code/index.get.ts @@ -3,7 +3,7 @@ import sessionHandler from "~/server/internal/session"; export default defineEventHandler(async (h3) => { const user = await sessionHandler.getSession(h3); - if (!user) throw createError({ statusCode: 403 }); + if (!user || !user.authenticated) throw createError({ statusCode: 403 }); const query = getQuery(h3); const code = query.code?.toString()?.toUpperCase(); diff --git a/server/api/v1/client/auth/code/index.post.ts b/server/api/v1/client/auth/code/index.post.ts index 593b773..803f317 100644 --- a/server/api/v1/client/auth/code/index.post.ts +++ b/server/api/v1/client/auth/code/index.post.ts @@ -3,7 +3,7 @@ import sessionHandler from "~/server/internal/session"; export default defineEventHandler(async (h3) => { const user = await sessionHandler.getSession(h3); - if (!user) throw createError({ statusCode: 403 }); + if (!user || !user.authenticated) throw createError({ statusCode: 403 }); const body = await readBody(h3); const clientId = await body.id; @@ -15,7 +15,7 @@ export default defineEventHandler(async (h3) => { statusMessage: "Invalid or expired client ID.", }); - if (client.userId != user.userId) + if (client.userId != user.authenticated.userId) throw createError({ statusCode: 403, statusMessage: "Not allowed to authorize this client.", diff --git a/server/api/v1/client/auth/index.get.ts b/server/api/v1/client/auth/index.get.ts index 92cf16c..1ab44ca 100644 --- a/server/api/v1/client/auth/index.get.ts +++ b/server/api/v1/client/auth/index.get.ts @@ -2,8 +2,9 @@ import clientHandler from "~/server/internal/clients/handler"; import sessionHandler from "~/server/internal/session"; export default defineEventHandler(async (h3) => { - const user = await sessionHandler.getSession(h3); - if (!user) throw createError({ statusCode: 403 }); + const session = await sessionHandler.getSession(h3); + if (!session || !session.authenticated) + throw createError({ statusCode: 403 }); const query = getQuery(h3); const providedClientId = query.id?.toString(); @@ -20,13 +21,16 @@ export default defineEventHandler(async (h3) => { statusMessage: "Request not found.", }); - if (client.userId && user.userId !== client.userId) + if (client.userId && session.authenticated.userId !== client.userId) throw createError({ statusCode: 400, statusMessage: "Client already claimed.", }); - await clientHandler.attachUserId(providedClientId, user.userId); + await clientHandler.attachUserId( + providedClientId, + session.authenticated.userId, + ); return client.data; }); diff --git a/server/api/v1/client/auth/initiate.post.ts b/server/api/v1/client/auth/initiate.post.ts index a5a8615..dddc71e 100644 --- a/server/api/v1/client/auth/initiate.post.ts +++ b/server/api/v1/client/auth/initiate.post.ts @@ -31,19 +31,20 @@ export default defineEventHandler(async (h3) => { statusMessage: "Invalid or unsupported platform", }); - const capabilityIterable = Object.entries(capabilities) as Array< - [InternalClientCapability, object] - >; - if ( - capabilityIterable.length > 0 && - capabilityIterable - .map(([capability]) => validCapabilities.find((v) => capability == v)) - .filter((e) => e).length == 0 - ) - throw createError({ - statusCode: 400, - message: "Invalid capabilities.", - }); + const capabilityIterableRaw = Object.entries(capabilities); + const capabilityIterable = capabilityIterableRaw.map( + ([capability, value]) => { + const actualCapability = validCapabilities.find( + (v) => capability.toLowerCase() == v.toLowerCase(), + ); + if (!actualCapability) + throw createError({ + statusCode: 400, + message: "Invalid capabilities.", + }); + return [actualCapability, value]; + }, + ) as Array<[InternalClientCapability, object]>; if ( capabilityIterable.length > 0 && @@ -63,7 +64,7 @@ export default defineEventHandler(async (h3) => { const result = await clientHandler.initiate({ name: body.name, platform, - capabilities, + capabilities: Object.fromEntries(capabilityIterable), mode: body.mode, }); diff --git a/server/api/v1/client/capability/index.post.ts b/server/api/v1/client/capability/index.post.ts index a33f690..b9dd0d5 100644 --- a/server/api/v1/client/capability/index.post.ts +++ b/server/api/v1/client/capability/index.post.ts @@ -1,4 +1,3 @@ -import type { InternalClientCapability } from "~/server/internal/clients/capabilities"; import capabilityManager, { validCapabilities, } from "~/server/internal/clients/capabilities"; @@ -23,9 +22,11 @@ export default defineClientEventHandler( statusMessage: "configuration must be an object", }); - const capability = rawCapability as InternalClientCapability; + const capability = validCapabilities.find( + (v) => v.toLowerCase() === rawCapability.toLowerCase(), + ); - if (!validCapabilities.includes(capability)) + if (!capability) throw createError({ statusCode: 400, statusMessage: "Invalid capability.", diff --git a/server/api/v1/client/chunk.get.ts b/server/api/v1/client/chunk.get.ts deleted file mode 100644 index 030b796..0000000 --- a/server/api/v1/client/chunk.get.ts +++ /dev/null @@ -1,83 +0,0 @@ -import cacheHandler from "~/server/internal/cache"; -import { defineClientEventHandler } from "~/server/internal/clients/event-handler"; -import prisma from "~/server/internal/db/database"; -import libraryManager from "~/server/internal/library"; - -const chunkSize = 1024 * 1024 * 64; - -const gameLookupCache = cacheHandler.createCache<{ - libraryId: string | null; - libraryPath: string; -}>("downloadGameLookupCache"); - -export default defineClientEventHandler(async (h3) => { - const query = getQuery(h3); - const gameId = query.id?.toString(); - const versionName = query.version?.toString(); - const filename = query.name?.toString(); - const chunkIndex = parseInt(query.chunk?.toString() ?? "?"); - - if (!gameId || !versionName || !filename || Number.isNaN(chunkIndex)) - throw createError({ - statusCode: 400, - statusMessage: "Invalid chunk arguments", - }); - - let game = await gameLookupCache.getItem(gameId); - if (!game) { - game = await prisma.game.findUnique({ - where: { - id: gameId, - }, - select: { - libraryId: true, - libraryPath: true, - }, - }); - if (!game || !game.libraryId) - throw createError({ statusCode: 400, statusMessage: "Invalid game ID" }); - - await gameLookupCache.setItem(gameId, game); - } - - if (!game.libraryId) - throw createError({ - statusCode: 500, - statusMessage: "Somehow, we got here.", - }); - - const peek = await libraryManager.peekFile( - game.libraryId, - game.libraryPath, - versionName, - filename, - ); - if (!peek) - throw createError({ status: 400, statusMessage: "Failed to peek file" }); - - const start = chunkIndex * chunkSize; - const end = Math.min((chunkIndex + 1) * chunkSize, peek.size); - const currentChunkSize = end - start; - setHeader(h3, "Content-Length", currentChunkSize); - - if (start >= end) - throw createError({ - statusCode: 400, - statusMessage: "Invalid chunk index", - }); - - const gameReadStream = await libraryManager.readFile( - game.libraryId, - game.libraryPath, - versionName, - filename, - { start, end }, - ); - if (!gameReadStream) - throw createError({ - statusCode: 400, - statusMessage: "Failed to create stream", - }); - - return sendStream(h3, gameReadStream); -}); diff --git a/server/api/v1/client/game/version.get.ts b/server/api/v1/client/game/[id]/version/[versionid]/index.get.ts similarity index 66% rename from server/api/v1/client/game/version.get.ts rename to server/api/v1/client/game/[id]/version/[versionid]/index.get.ts index 2ed7422..fd9a62c 100644 --- a/server/api/v1/client/game/version.get.ts +++ b/server/api/v1/client/game/[id]/version/[versionid]/index.get.ts @@ -3,22 +3,29 @@ import prisma from "~/server/internal/db/database"; import libraryManager from "~/server/internal/library"; export default defineClientEventHandler(async (h3) => { - const query = getQuery(h3); - const id = query.id?.toString(); - const version = query.version?.toString(); + const id = getRouterParam(h3, "id"); + const version = getRouterParam(h3, "versionid"); if (!id || !version) throw createError({ statusCode: 400, - statusMessage: "Missing id or version in query", + statusMessage: "Missing id or version in route params", }); const gameVersion = await prisma.gameVersion.findUnique({ where: { - gameId_versionName: { + gameId_versionId: { gameId: id, - versionName: version, + versionId: version, }, }, + include: { + launches: { + include: { + executor: true, + }, + }, + setups: true, + }, }); if (!gameVersion) diff --git a/server/api/v1/client/game/manifest.get.ts b/server/api/v1/client/game/manifest.get.ts index 80535e5..77a278e 100644 --- a/server/api/v1/client/game/manifest.get.ts +++ b/server/api/v1/client/game/manifest.get.ts @@ -1,5 +1,5 @@ import { defineClientEventHandler } from "~/server/internal/clients/event-handler"; -import manifestGenerator from "~/server/internal/downloads/manifest"; +import prisma from "~/server/internal/db/database"; export default defineClientEventHandler(async (h3) => { const query = getQuery(h3); @@ -11,11 +11,14 @@ export default defineClientEventHandler(async (h3) => { statusMessage: "Missing id or version in query", }); - const manifest = await manifestGenerator.generateManifest(id, version); + const manifest = await prisma.gameVersion.findUnique({ + where: { gameId_versionId: { gameId: id, versionId: version } }, + select: { dropletManifest: true }, + }); if (!manifest) throw createError({ statusCode: 400, statusMessage: "Invalid game or version, or no versions added.", }); - return manifest; + return manifest.dropletManifest; }); diff --git a/server/api/v1/client/game/versions.get.ts b/server/api/v1/client/game/versions.get.ts index cd60544..6219a6e 100644 --- a/server/api/v1/client/game/versions.get.ts +++ b/server/api/v1/client/game/versions.get.ts @@ -20,6 +20,10 @@ export default defineClientEventHandler(async (h3) => { omit: { dropletManifest: true, }, + include: { + launches: true, + setups: true, + }, }); return versions; diff --git a/server/api/v1/client/user/webtoken.post.ts b/server/api/v1/client/user/webtoken.post.ts index eda744f..abae355 100644 --- a/server/api/v1/client/user/webtoken.post.ts +++ b/server/api/v1/client/user/webtoken.post.ts @@ -14,6 +14,7 @@ export default defineClientEventHandler( "store:read", "collections:read", "object:read", + "settings:read", ]; const token = await prisma.aPIToken.create({ diff --git a/server/api/v1/depot/STUB.md b/server/api/v1/depot/STUB.md new file mode 100644 index 0000000..c461524 --- /dev/null +++ b/server/api/v1/depot/STUB.md @@ -0,0 +1,3 @@ +# Don't add anything here + +This route is overriden by the reverse proxy, and forwarded to the Rust depot. diff --git a/server/api/v1/games/[id]/index.get.ts b/server/api/v1/games/[id]/index.get.ts index ec0b3f4..85b6950 100644 --- a/server/api/v1/games/[id]/index.get.ts +++ b/server/api/v1/games/[id]/index.get.ts @@ -16,7 +16,12 @@ export default defineEventHandler(async (h3) => { const game = await prisma.game.findUnique({ where: { id: gameId }, include: { - versions: true, + versions: { + include: { + launches: true, + setups: true, + }, + }, publishers: { select: { id: true, diff --git a/server/api/v1/notifications/[id]/read.post.ts b/server/api/v1/notifications/[id]/read.post.ts index 4ffe007..cc286e7 100644 --- a/server/api/v1/notifications/[id]/read.post.ts +++ b/server/api/v1/notifications/[id]/read.post.ts @@ -20,15 +20,17 @@ export default defineEventHandler(async (h3) => { userIds.push("system"); } - const notification = await prisma.notification.update({ - where: { - id: notificationId, - userId: { in: userIds }, - }, - data: { - read: true, - }, - }); + const notification = ( + await prisma.notification.updateManyAndReturn({ + where: { + id: notificationId, + userId: { in: userIds }, + }, + data: { + read: true, + }, + }) + ).at(0); if (!notification) throw createError({ diff --git a/server/api/v1/store/index.get.ts b/server/api/v1/store/index.get.ts index 26649f3..5d98321 100644 --- a/server/api/v1/store/index.get.ts +++ b/server/api/v1/store/index.get.ts @@ -49,11 +49,15 @@ export default defineEventHandler(async (h3) => { ? { versions: { some: { - platform: { - in: options.platform - .split(",") - .map(parsePlatform) - .filter((e) => e !== undefined), + launches: { + some: { + platform: { + in: options.platform + .split(",") + .map(parsePlatform) + .filter((e) => e !== undefined), + }, + }, }, }, }, diff --git a/server/api/v1/user/auth/index.get.ts b/server/api/v1/user/auth/index.get.ts new file mode 100644 index 0000000..e763d48 --- /dev/null +++ b/server/api/v1/user/auth/index.get.ts @@ -0,0 +1,19 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; +import { AuthMec } from "~/prisma/client/enums"; + +export default defineEventHandler(async (h3) => { + const userId = await aclManager.getUserIdACL(h3, []); // No ACLs only allows session authentication + if (!userId) throw createError({ statusCode: 403 }); + + const authMecs = await prisma.linkedAuthMec.findMany({ + where: { + userId, + }, + omit: { + credentials: true, + }, + }); + const authMecMap = Object.fromEntries(authMecs.map((v) => [v.mec, v])); + return { mecs: authMecMap, available: Object.keys(AuthMec) }; +}); diff --git a/server/api/v1/user/mfa/index.get.ts b/server/api/v1/user/mfa/index.get.ts new file mode 100644 index 0000000..94cde60 --- /dev/null +++ b/server/api/v1/user/mfa/index.get.ts @@ -0,0 +1,38 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; +import { MFAMec } from "~/prisma/client/enums"; +import type { WebAuthNv1Credentials } from "~/server/internal/auth/webauthn"; + +export default defineEventHandler(async (h3) => { + const userId = await aclManager.getUserIdACL(h3, []); // No ACLs only allows session authentication + if (!userId) throw createError({ statusCode: 403 }); + + const mfaMecs = await prisma.linkedMFAMec.findMany({ + where: { + userId, + }, + }); + // Sanitise and convert to map + const mfaMecMap = Object.fromEntries( + mfaMecs.map((v) => { + switch (v.mec) { + case MFAMec.TOTP: + v.credentials = {}; + break; + case MFAMec.WebAuthn: { + const newCredentials = ( + v.credentials as unknown as WebAuthNv1Credentials + ).passkeys.map((v) => ({ + name: v.name, + id: v.id, + created: v.created, + })); + v.credentials = newCredentials; + break; + } + } + return [v.mec, v]; + }), + ); + return { mecs: mfaMecMap, available: Object.keys(MFAMec) }; +}); diff --git a/server/api/v1/user/mfa/totp/finish.post.ts b/server/api/v1/user/mfa/totp/finish.post.ts new file mode 100644 index 0000000..911a7ca --- /dev/null +++ b/server/api/v1/user/mfa/totp/finish.post.ts @@ -0,0 +1,61 @@ +import aclManager from "~/server/internal/acls"; +import { totp, SecretKey } from "otp-io"; +import { hmac } from "otp-io/crypto"; +import prisma from "~/server/internal/db/database"; +import { MFAMec } from "~/prisma/client/client"; +import type { TOTPv1Credentials } from "~/server/internal/auth/totp"; +import { dropDecodeArrayBase64 } from "~/server/internal/auth/totp"; +import { createError } from "h3"; +import { type } from "arktype"; +import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; + +const TOTPEnableBody = type({ + code: "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, TOTPEnableBody); + + const existing = await prisma.linkedMFAMec.findUnique({ + where: { + userId_mec: { + userId, + mec: MFAMec.TOTP, + }, + enabled: false, + }, + }); + if (!existing) + throw createError({ statusCode: 400, message: "TOTP not started" }); + + const secret = (existing.credentials as unknown as TOTPv1Credentials).secret; + const secretKeyBuffer = dropDecodeArrayBase64(secret); + const secretKey = new SecretKey(secretKeyBuffer); + + const code = await totp(hmac, { secret: secretKey }); + if (body.code !== code) + throw createError({ statusCode: 400, message: "Invalid TOTP code." }); + + // Safe because we're updating something we just queried + // eslint-disable-next-line drop/no-prisma-delete + await prisma.linkedMFAMec.update({ + where: { + userId_mec: { + userId, + mec: MFAMec.TOTP, + }, + }, + data: { + enabled: true, + }, + }); + + return; +}); diff --git a/server/api/v1/user/mfa/totp/start.post.ts b/server/api/v1/user/mfa/totp/start.post.ts new file mode 100644 index 0000000..1e38e9f --- /dev/null +++ b/server/api/v1/user/mfa/totp/start.post.ts @@ -0,0 +1,63 @@ +import aclManager from "~/server/internal/acls"; +import { generateKey, getKeyUri } from "otp-io"; +import { randomBytes } from "otp-io/crypto"; +import prisma from "~/server/internal/db/database"; +import { MFAMec } from "~/prisma/client/client"; +import type { TOTPv1Credentials } from "~/server/internal/auth/totp"; +import { dropEncodeArrayBase64 } from "~/server/internal/auth/totp"; +import { b32e } from "~/server/internal/auth/base32"; + +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 existing = await prisma.linkedMFAMec.findUnique({ + where: { + userId_mec: { + userId, + mec: MFAMec.TOTP, + }, + }, + }); + + if (existing) { + if (!existing.enabled) { + // Safe because we're updating something we just queried + // eslint-disable-next-line drop/no-prisma-delete + await prisma.linkedMFAMec.delete({ + where: { userId_mec: { userId: existing.userId, mec: existing.mec } }, + }); + } else { + throw createError({ + statusCode: 400, + message: "Cannot set up TOTP authentication if already exists.", + }); + } + } + + const secret = generateKey(randomBytes, /* bytes: */ 20); // 5-20 good for Google Authenticator + const url = getKeyUri({ + type: "totp", + secret, + name: userId, + issuer: "Drop", + }); + + await prisma.linkedMFAMec.create({ + data: { + userId, + mec: MFAMec.TOTP, + version: 1, + credentials: { + secret: dropEncodeArrayBase64(secret.bytes), + } satisfies TOTPv1Credentials, + enabled: false, + }, + }); + + return { url, secret: b32e(secret.bytes) }; +}); diff --git a/server/api/v1/user/mfa/webauthn/finish.post.ts b/server/api/v1/user/mfa/webauthn/finish.post.ts new file mode 100644 index 0000000..490cf5b --- /dev/null +++ b/server/api/v1/user/mfa/webauthn/finish.post.ts @@ -0,0 +1,110 @@ +import aclManager from "~/server/internal/acls"; +import { dropEncodeArrayBase64 } from "~/server/internal/auth/totp"; +import type { WebAuthNv1Credentials } from "~/server/internal/auth/webauthn"; +import { getRpId } from "~/server/internal/auth/webauthn"; +import prisma from "~/server/internal/db/database"; +import { MFAMec } from "~/prisma/client/enums"; +import sessionHandler from "~/server/internal/session"; +import type { PublicKeyCredentialCreationOptionsJSON } from "@simplewebauthn/server"; +import { verifyRegistrationResponse } from "@simplewebauthn/server"; +import { systemConfig } from "~/server/internal/config/sys-conf"; + +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 readBody(h3); + + const optionsRaw = await sessionHandler.getSessionDataKey( + h3, + "webauthn/options", + ); + if (!optionsRaw) + throw createError({ + statusCode: 400, + message: "WebAuthn not started for this session.", + }); + const options: PublicKeyCredentialCreationOptionsJSON = + JSON.parse(optionsRaw); + await sessionHandler.deleteSessionDataKey(h3, "webauthn/options"); + + const rpID = await getRpId(); + const externalUrl = await systemConfig.getExternalUrl(); + const url = new URL(externalUrl); + + let verification; + try { + verification = await verifyRegistrationResponse({ + response: body, + expectedChallenge: options.challenge, + expectedOrigin: url.origin, + expectedRPID: rpID, + }); + } catch (error) { + console.error(error); + throw createError({ + statusCode: 400, + message: (error as string)?.toString(), + }); + } + + const webauthnMec = + (await prisma.linkedMFAMec.findUnique({ + where: { userId_mec: { userId, mec: MFAMec.WebAuthn } }, + })) ?? + (await prisma.linkedMFAMec.create({ + data: { + userId, + mec: MFAMec.WebAuthn, + credentials: { passkeys: [] } satisfies WebAuthNv1Credentials, + version: 1, + }, + })); + + const { verified, registrationInfo } = verification; + if (!verified) + throw createError({ + statusCode: 400, + message: "Failed to verify passkey.", + }); + const { credential, credentialDeviceType, credentialBackedUp } = + registrationInfo!; + + const name = await sessionHandler.getSessionDataKey( + h3, + "webauthn/passkeyname", + ); + + (webauthnMec.credentials as unknown as WebAuthNv1Credentials).passkeys.push({ + name: name ?? "My New Passkey", + created: Date.now(), + userId, + webAuthnUserId: options.user.id, + id: credential.id, + publicKey: dropEncodeArrayBase64(credential.publicKey), + counter: credential.counter, + transports: credential.transports, + deviceType: credentialDeviceType, + backedUp: credentialBackedUp, + }); + + // Safe because we're updating something we just queried + // eslint-disable-next-line drop/no-prisma-delete + await prisma.linkedMFAMec.update({ + where: { + userId_mec: { + userId: webauthnMec.userId, + mec: webauthnMec.mec, + }, + }, + data: { + credentials: webauthnMec.credentials!, + }, + }); + + return; +}); diff --git a/server/api/v1/user/mfa/webauthn/start.post.ts b/server/api/v1/user/mfa/webauthn/start.post.ts new file mode 100644 index 0000000..65cae4b --- /dev/null +++ b/server/api/v1/user/mfa/webauthn/start.post.ts @@ -0,0 +1,56 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; +import sessionHandler from "~/server/internal/session"; +import { generateRegistrationOptions } from "@simplewebauthn/server"; +import { getRpId } from "~/server/internal/auth/webauthn"; +import { type } from "arktype"; +import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; + +const CreatePasskey = type({ + name: "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, CreatePasskey); + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { displayName: true, username: true }, + }); + if (!user) + throw createError({ + statusCode: 500, + message: "Session refers to non-existed user.", + }); + + const rpID = await getRpId(); + + const registrationOptions = await generateRegistrationOptions({ + rpID, + rpName: "Drop", + userName: user.username, + attestationType: "none", + authenticatorSelection: { + requireResidentKey: true, + residentKey: "required", + userVerification: "preferred", + }, + }); + + await sessionHandler.setSessionDataKey( + h3, + "webauthn/options", + JSON.stringify(registrationOptions), + ); + + await sessionHandler.setSessionDataKey(h3, "webauthn/passkeyname", body.name); + + return registrationOptions; +}); diff --git a/server/api/v1/user/superlevel.ts b/server/api/v1/user/superlevel.ts new file mode 100644 index 0000000..802d742 --- /dev/null +++ b/server/api/v1/user/superlevel.ts @@ -0,0 +1,6 @@ +import aclManager from "~/server/internal/acls"; + +export default defineEventHandler(async (h3) => { + const userId = await aclManager.allowUserSuperlevel(h3); + return userId !== undefined; +}); diff --git a/server/api/v2/client/chunk.post.ts b/server/api/v2/client/chunk.post.ts deleted file mode 100644 index 648961b..0000000 --- a/server/api/v2/client/chunk.post.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { type } from "arktype"; -import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; -import contextManager from "~/server/internal/downloads/coordinator"; -import libraryManager from "~/server/internal/library"; -import { logger } from "~/server/internal/logging"; - -const GetChunk = type({ - context: "string", - files: type({ - filename: "string", - chunkIndex: "number", - }) - .array() - .atLeastLength(1) - .atMostLength(256), -}).configure(throwingArktype); - -export default defineEventHandler(async (h3) => { - const body = await readDropValidatedBody(h3, GetChunk); - - const context = await contextManager.fetchContext(body.context); - if (!context) - throw createError({ - statusCode: 400, - statusMessage: "Invalid download context.", - }); - - const streamFiles = []; - - for (const file of body.files) { - const manifestFile = context.manifest[file.filename]; - if (!manifestFile) - throw createError({ - statusCode: 400, - statusMessage: `Unknown file: ${file.filename}`, - }); - - const start = manifestFile.lengths - .slice(0, file.chunkIndex) - .reduce((a, b) => a + b, 0); - const end = start + manifestFile.lengths[file.chunkIndex]; - - streamFiles.push({ filename: file.filename, start, end }); - } - - setHeader( - h3, - "Content-Lengths", - streamFiles.map((e) => e.end - e.start).join(","), - ); // Non-standard header, but we're cool like that 😎 - - const streams = await Promise.all( - streamFiles.map(async (file) => { - const gameReadStream = await libraryManager.readFile( - context.libraryId, - context.libraryPath, - context.versionName, - file.filename, - { start: file.start, end: file.end }, - ); - if (!gameReadStream) - throw createError({ - statusCode: 500, - statusMessage: "Failed to create read stream", - }); - return { ...file, stream: gameReadStream }; - }), - ); - - for (const file of streams) { - let length = 0; - await file.stream.pipeTo( - new WritableStream({ - write(chunk) { - h3.node.res.write(chunk); - length += chunk.length; - }, - }), - ); - - if (length != file.end - file.start) { - logger.warn( - `failed to read enough from ${file.filename}. read ${length}, required: ${file.end - file.start}`, - ); - throw createError({ - statusCode: 500, - statusMessage: "Failed to read enough from stream.", - }); - } - } - - await h3.node.res.end(); - - return; -}); diff --git a/server/api/v2/client/context.post.ts b/server/api/v2/client/context.post.ts deleted file mode 100644 index e54356a..0000000 --- a/server/api/v2/client/context.post.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { type } from "arktype"; -import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; -import { defineClientEventHandler } from "~/server/internal/clients/event-handler"; -import contextManager from "~/server/internal/downloads/coordinator"; - -const CreateContext = type({ - game: "string", - version: "string", -}).configure(throwingArktype); - -export default defineClientEventHandler(async (h3) => { - const body = await readDropValidatedBody(h3, CreateContext); - - const context = await contextManager.createContext(body.game, body.version); - if (!context) - throw createError({ - statusCode: 400, - statusMessage: "Invalid game or version", - }); - - return { context }; -}); diff --git a/server/internal/acls/descriptions.ts b/server/internal/acls/descriptions.ts index af1a007..58b8166 100644 --- a/server/internal/acls/descriptions.ts +++ b/server/internal/acls/descriptions.ts @@ -103,4 +103,7 @@ export const systemACLDescriptions: ObjectFromList = { "Read tasks and maintenance information, like updates available and cleanup.", "settings:update": "Update system settings.", + + "depot:new": "Create a new download depot", + "depot:delete": "Remove a download depot", }; diff --git a/server/internal/acls/index.ts b/server/internal/acls/index.ts index d6997c5..189d2b5 100644 --- a/server/internal/acls/index.ts +++ b/server/internal/acls/index.ts @@ -43,6 +43,9 @@ export type UserACL = Array<(typeof userACLs)[number]>; export const systemACLs = [ "setup", + "depot:new", + "depot:delete", + "auth:read", "auth:simple:invitation:read", "auth:simple:invitation:new", @@ -123,8 +126,12 @@ class ACLManager { if (!request) throw new Error("Native web requests not available - weird deployment?"); // Sessions automatically have all ACLs - const user = await sessionHandler.getSession(request); - if (user) return user.userId; + const session = await sessionHandler.getSession(request); + if (session && session.authenticated) { + if (session.authenticated.level >= session.authenticated.requiredLevel) + return session.authenticated.userId; + return undefined; + } const authorizationToken = this.getAuthorizationToken(request); if (!authorizationToken) return undefined; @@ -158,6 +165,19 @@ class ACLManager { return undefined; } + async allowUserSuperlevel(request: MinimumRequestObject | undefined) { + if (!request) + throw new Error("Native web requests not available - weird deployment?"); + const session = await sessionHandler.getSession(request); + if (!session || !session.authenticated) return undefined; + if (session.authenticated.level < session.authenticated.requiredLevel) + return undefined; + if (session.authenticated.superleveledExpiry === undefined) + return undefined; + if (session.authenticated.superleveledExpiry < Date.now()) return undefined; + return session.authenticated.userId; + } + async allowSystemACL( request: MinimumRequestObject | undefined, acls: SystemACL, @@ -165,14 +185,19 @@ class ACLManager { if (!request) throw new Error("Native web requests not available - weird deployment?"); const userSession = await sessionHandler.getSession(request); - if (userSession) { + if (userSession && userSession.authenticated) { const user = await prisma.user.findUnique({ - where: { id: userSession.userId }, + where: { id: userSession.authenticated.userId }, }); if (user) { if (!user) return false; - if (user.admin) return true; - return false; + if (!user.admin) return false; + if ( + userSession.authenticated.level < + userSession.authenticated.requiredLevel + ) + return false; + return true; } } @@ -221,7 +246,7 @@ class ACLManager { request: MinimumRequestObject, ): Promise { const userSession = await sessionHandler.getSession(request); - if (!userSession) { + if (!userSession || !userSession.authenticated) { const authorizationToken = this.getAuthorizationToken(request); if (!authorizationToken) return undefined; const token = await prisma.aPIToken.findUnique({ @@ -232,7 +257,7 @@ class ACLManager { } const user = await prisma.user.findUnique({ - where: { id: userSession.userId }, + where: { id: userSession.authenticated.userId }, select: { admin: true, }, diff --git a/server/internal/auth/base32/index.d.ts b/server/internal/auth/base32/index.d.ts new file mode 100644 index 0000000..30a4310 --- /dev/null +++ b/server/internal/auth/base32/index.d.ts @@ -0,0 +1,2 @@ +export function b32e(array: Uint8Array): string; +export function b32d(str: string): Uint8Array; diff --git a/server/internal/auth/base32/index.js b/server/internal/auth/base32/index.js new file mode 100644 index 0000000..e354c7b --- /dev/null +++ b/server/internal/auth/base32/index.js @@ -0,0 +1,69 @@ +// base32 elements +//RFC4648: why include 2? Z and 2 looks similar than 8 and O +const b32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; +console.assert(b32.length === 32, b32.length); +const b32r = new Map(Array.from(b32, (ch, i) => [ch, i])).set("=", 0); +//[constants derived from character table size] +//cbit = 5 (as 32 == 2 ** 5), ubit = 8 (as byte) +//ccount = 8 (= cbit / gcd(cbit, ubit)), ucount = 5 (= ubit / gcd(cbit, ubit)) +//cmask = 0x1f (= 2 ** cbit - 1), umask = 0xff (= 2 ** ubit - 1) +//const b32pad = [0, 6, 4, 3, 1]; +const b32pad = Array.from(Array(5), (_, i) => ((8 - (i * 8) / 5) | 0) % 8); + +function b32e5(u1, u2 = 0, u3 = 0, u4 = 0, u5 = 0) { + const u40 = u1 * 2 ** 32 + u2 * 2 ** 24 + u3 * 2 ** 16 + u4 * 2 ** 8 + u5; + return [ + b32[(u40 / 2 ** 35) & 0x1f], + b32[(u40 / 2 ** 30) & 0x1f], + b32[(u40 / 2 ** 25) & 0x1f], + b32[(u40 / 2 ** 20) & 0x1f], + b32[(u40 / 2 ** 15) & 0x1f], + b32[(u40 / 2 ** 10) & 0x1f], + b32[(u40 / 2 ** 5) & 0x1f], + b32[u40 & 0x1f], + ]; +} +function b32d8(b1, b2, b3, b4, b5, b6, b7, b8) { + const u40 = + b32r.get(b1) * 2 ** 35 + + b32r.get(b2) * 2 ** 30 + + b32r.get(b3) * 2 ** 25 + + b32r.get(b4) * 2 ** 20 + + b32r.get(b5) * 2 ** 15 + + b32r.get(b6) * 2 ** 10 + + b32r.get(b7) * 2 ** 5 + + b32r.get(b8); + return [ + (u40 / 2 ** 32) & 0xff, + (u40 / 2 ** 24) & 0xff, + (u40 / 2 ** 16) & 0xff, + (u40 / 2 ** 8) & 0xff, + u40 & 0xff, + ]; +} + +// base32 encode/decode: Uint8Array <=> string +export function b32e(u8a) { + console.assert(u8a instanceof Uint8Array, u8a.constructor); + const len = u8a.length, + rem = len % 5; + const u5s = Array.from(Array((len - rem) / 5), (_, i) => + u8a.subarray(i * 5, i * 5 + 5), + ); + const pad = b32pad[rem]; + const br = rem === 0 ? [] : b32e5(...u8a.subarray(-rem)).slice(0, 8 - pad); + return [] + .concat(...u5s.map((u5) => b32e5(...u5)), br, ["=".repeat(pad)]) + .join(""); +} +export function b32d(bs) { + const len = bs.length; + if (len === 0) return new Uint8Array([]); + console.assert(len % 8 === 0, len); + const pad = len - bs.indexOf("="), + rem = b32pad.indexOf(pad); + console.assert(rem >= 0, pad); + console.assert(/^[A-Z2-7+/]*$/.test(bs.slice(0, len - pad)), bs); + const u8s = [].concat(...bs.match(/.{8}/g).map((b8) => b32d8(...b8))); + return new Uint8Array(rem > 0 ? u8s.slice(0, rem - 5) : u8s); +} diff --git a/server/internal/auth/index.ts b/server/internal/auth/index.ts index ee9cadc..fa8a0fa 100644 --- a/server/internal/auth/index.ts +++ b/server/internal/auth/index.ts @@ -34,7 +34,7 @@ class AuthManager { (this.authProviders as any)[key] = object; logger.info(`enabled auth: ${key}`); } catch (e) { - logger.warn(e); + logger.warn((e as string).toString()); } } diff --git a/server/internal/auth/oidc/index.ts b/server/internal/auth/oidc/index.ts index 2d55ad4..727c676 100644 --- a/server/internal/auth/oidc/index.ts +++ b/server/internal/auth/oidc/index.ts @@ -45,6 +45,7 @@ export class OIDCManager { private clientSecret: string; private externalUrl: string; + private userGroup?: string = process.env.OIDC_USER_GROUP; private adminGroup?: string = process.env.OIDC_ADMIN_GROUP; private usernameClaim: keyof OIDCUserInfo = (process.env.OIDC_USERNAME_CLAIM as keyof OIDCUserInfo) ?? @@ -204,11 +205,11 @@ export class OIDCManager { }, }); - const user = await this.fetchOrCreateUser(userinfo); + const userOrError = await this.fetchOrCreateUser(userinfo); - if (typeof user === "string") return user; + if (typeof userOrError === "string") return userOrError; - return { user, options: session.options }; + return { user: userOrError, options: session.options }; } catch (e) { logger.error(e); return `Request to identity provider failed: ${e}`; @@ -236,6 +237,19 @@ export class OIDCManager { if (!username) return "Invalid username claim in OIDC response: " + this.usernameClaim; + const isAdmin = + userinfo.groups !== undefined && + this.adminGroup !== undefined && + userinfo.groups.includes(this.adminGroup); + + const isUser = this.userGroup + ? userinfo.groups !== undefined && + userinfo.groups.includes(this.userGroup) + : true; + + if (!(isAdmin || isUser)) + return "Not authorized to access this application."; + /* const takenUsername = await prisma.user.count({ where: { @@ -274,11 +288,6 @@ export class OIDCManager { ); } - const isAdmin = - userinfo.groups !== undefined && - this.adminGroup !== undefined && - userinfo.groups.includes(this.adminGroup); - const created = await prisma.linkedAuthMec.create({ data: { mec: AuthMec.OpenID, diff --git a/server/internal/auth/totp.ts b/server/internal/auth/totp.ts new file mode 100644 index 0000000..5701a4f --- /dev/null +++ b/server/internal/auth/totp.ts @@ -0,0 +1,22 @@ +export function dropEncodeArrayBase64(secret: Uint8Array): string { + return encode(secret); +} +export function dropDecodeArrayBase64(secret: string): Uint8Array { + return decode(secret); +} + +const { fromCharCode } = String; +const encode = (uint8array: Uint8Array) => { + const output = []; + for (let i = 0, { length } = uint8array; i < length; i++) + output.push(fromCharCode(uint8array[i])); + return btoa(output.join("")); +}; + +const asCharCode = (c: string) => c.charCodeAt(0); + +const decode = (chars: string) => Uint8Array.from(atob(chars), asCharCode); + +export interface TOTPv1Credentials { + secret: string; +} diff --git a/server/internal/auth/webauthn.ts b/server/internal/auth/webauthn.ts new file mode 100644 index 0000000..1d5f7b7 --- /dev/null +++ b/server/internal/auth/webauthn.ts @@ -0,0 +1,128 @@ +import { ArkErrors, type } from "arktype"; +import { systemConfig } from "../config/sys-conf"; +import { dropDecodeArrayBase64 } from "./totp"; +import { decode } from "cbor2"; +import { createHash } from "node:crypto"; +import cosekey from "parse-cosekey"; +import type { AuthenticatorTransportFuture } from "@simplewebauthn/server"; + +export async function getRpId() { + const externalUrl = + process.env.WEBAUTHN_DOMAIN ?? (await systemConfig.getExternalUrl()); + const externalUrlParsed = new URL(externalUrl); + + return externalUrlParsed.hostname; +} + +export interface Passkey { + name: string; + created: number; + userId: string; + webAuthnUserId: string; + id: string; + publicKey: string; + counter: number; + transports: Array | undefined; + deviceType: string; + backedUp: boolean; +} + +export interface WebAuthNv1Credentials { + passkeys: Array; +} + +const ClientData = type({ + type: "'webauthn.create'", + challenge: "string", + origin: "string", +}); + +const AuthData = type({ + fmt: "string", + authData: "TypedArray.Uint8", +}); + +export async function parseAndValidatePasskeyCreation( + clientDataString: string, + attestationObjectString: string, + challenge: string, +) { + const clientData = dropDecodeArrayBase64(clientDataString); + const attestationObject = dropDecodeArrayBase64(attestationObjectString); + + const utf8Decoder = new TextDecoder("utf-8"); + const decodedClientData = utf8Decoder.decode(clientData); + const clientDataObj = ClientData(JSON.parse(decodedClientData)); + if (clientDataObj instanceof ArkErrors) + throw createError({ + statusCode: 400, + message: `Invalid client data JSON object: ${clientDataObj.summary}`, + }); + + const convertedChallenge = Buffer.from( + dropDecodeArrayBase64(clientDataObj.challenge), + ).toString("utf8"); + + if (convertedChallenge !== challenge) + throw createError({ + statusCode: 400, + message: "Challenge does not match.", + }); + + const tmp = decode(attestationObject); + const decodedAttestationObject = AuthData(tmp); + if (decodedAttestationObject instanceof ArkErrors) + throw createError({ + statusCode: 400, + message: `Invalid attestation object: ${decodedAttestationObject.summary}`, + }); + + const userRpIdHash = decodedAttestationObject.authData.slice(0, 32); + const rpId = await getRpId(); + const rpIdHash = createHash("sha256").update(rpId).digest(); + + if (!rpIdHash.equals(userRpIdHash)) + throw createError({ + statusCode: 400, + message: "Incorrect relying party ID", + }); + + const attestedCredentialData = decodedAttestationObject.authData.slice(37); + if (attestedCredentialData.length < 18) + throw createError({ + statusCode: 400, + message: + "Attested credential data is missing AAGUID and/or credentialIdLength", + }); + // const aaguid = attestedCredentialData.slice(0, 16); + const credentialIdLengthBuffer = attestedCredentialData.slice(16, 18); + const credentialIdLength = Buffer.from(credentialIdLengthBuffer).readUintBE( + 0, + 2, + ); + if (attestedCredentialData.length < 18 + credentialIdLength) + throw createError({ + statusCode: 400, + message: "Missing credential data of length: " + credentialIdLength, + }); + const credentialId = attestedCredentialData.slice( + 18, + 18 + credentialIdLength, + ); + const credentialPublicKey: Map = decode( + attestedCredentialData.slice(18 + credentialIdLength), + ); + if (!(credentialPublicKey instanceof Map)) + throw createError({ + statusCode: 400, + message: "Could not decode public key from attestion credential data", + }); + + const credentialIdStr = Buffer.from(credentialId).toString("hex"); + const jwk = cosekey.KeyParser.cose2jwk(credentialPublicKey); + + return { + credentialIdStr, + jwk, + }; +} diff --git a/server/internal/clients/ca-store.ts b/server/internal/clients/ca-store.ts index a424731..9faa349 100644 --- a/server/internal/clients/ca-store.ts +++ b/server/internal/clients/ca-store.ts @@ -72,18 +72,14 @@ export const dbCertificateStore = () => { }; }, async blacklistCertificate(name: string) { - try { - await prisma.certificate.update({ - where: { - id: name, - }, - data: { - blacklisted: true, - }, - }); - } finally { - /* empty */ - } + await prisma.certificate.updateMany({ + where: { + id: name, + }, + data: { + blacklisted: true, + }, + }); }, async checkBlacklistCertificate(name: string): Promise { const result = await prisma.certificate.findUnique({ diff --git a/server/internal/clients/capabilities.ts b/server/internal/clients/capabilities.ts index 868a9f6..4bebb81 100644 --- a/server/internal/clients/capabilities.ts +++ b/server/internal/clients/capabilities.ts @@ -102,8 +102,6 @@ class CapabilityManager { () => Promise | void > = { [InternalClientCapability.PeerAPI]: async function () { - // const configuration =rawCapability as CapabilityConfiguration[InternalClientCapability.PeerAPI]; - const currentClient = await prisma.client.findUnique({ where: { id: clientId }, select: { @@ -111,26 +109,10 @@ class CapabilityManager { }, }); if (!currentClient) throw new Error("Invalid client ID"); - /* - if (currentClient.capabilities.includes(ClientCapabilities.PeerAPI)) { - await prisma.clientPeerAPIConfiguration.update({ - where: { clientId }, - data: { - endpoints: configuration.endpoints, - }, - }); + if (currentClient.capabilities.includes(ClientCapabilities.PeerAPI)) return; - } - await prisma.clientPeerAPIConfiguration.create({ - data: { - clientId: clientId, - endpoints: configuration.endpoints, - }, - }); - */ - - await prisma.client.update({ + await prisma.client.updateMany({ where: { id: clientId }, data: { capabilities: { @@ -153,7 +135,7 @@ class CapabilityManager { if (currentClient.capabilities.includes(ClientCapabilities.CloudSaves)) return; - await prisma.client.update({ + await prisma.client.updateMany({ where: { id: clientId }, data: { capabilities: { @@ -175,7 +157,7 @@ class CapabilityManager { ) return; - await prisma.client.update({ + await prisma.client.updateMany({ where: { id: clientId }, data: { capabilities: { diff --git a/server/internal/clients/event-handler.ts b/server/internal/clients/event-handler.ts index dfbdf0d..badbb2d 100644 --- a/server/internal/clients/event-handler.ts +++ b/server/internal/clients/event-handler.ts @@ -124,7 +124,8 @@ export function defineClientEventHandler(handler: EventHandlerFunction) { fetchUser, }; - await prisma.client.update({ + // Ignore response because we don't care if this fails + await prisma.client.updateMany({ where: { id: clientId }, data: { lastConnected: new Date() }, }); diff --git a/server/internal/downloads/coordinator.ts b/server/internal/downloads/coordinator.ts deleted file mode 100644 index bc2dfe5..0000000 --- a/server/internal/downloads/coordinator.ts +++ /dev/null @@ -1,68 +0,0 @@ -import prisma from "../db/database"; -import type { DropManifest } from "./manifest"; - -const TIMEOUT = 1000 * 60 * 60 * 1; // 1 hour - -class DownloadContextManager { - private contexts: Map< - string, - { - timeout: Date; - manifest: DropManifest; - versionName: string; - libraryId: string; - libraryPath: string; - } - > = new Map(); - - async createContext(game: string, versionName: string) { - const version = await prisma.gameVersion.findUnique({ - where: { - gameId_versionName: { - gameId: game, - versionName, - }, - }, - include: { - game: { - select: { - libraryId: true, - libraryPath: true, - }, - }, - }, - }); - if (!version) return undefined; - - const contextId = crypto.randomUUID(); - this.contexts.set(contextId, { - timeout: new Date(), - manifest: JSON.parse(version.dropletManifest as string) as DropManifest, - versionName, - libraryId: version.game.libraryId!, - libraryPath: version.game.libraryPath, - }); - - return contextId; - } - - async fetchContext(contextId: string) { - const context = this.contexts.get(contextId); - if (!context) return undefined; - context.timeout = new Date(); - this.contexts.set(contextId, context); - return context; - } - - async cleanup() { - for (const key of this.contexts.keys()) { - const context = this.contexts.get(key)!; - if (context.timeout.getTime() < Date.now() - TIMEOUT) { - this.contexts.delete(key); - } - } - } -} - -export const contextManager = new DownloadContextManager(); -export default contextManager; diff --git a/server/internal/downloads/manifest.ts b/server/internal/downloads/manifest.ts deleted file mode 100644 index 2da9b57..0000000 --- a/server/internal/downloads/manifest.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { GameVersionModel } from "~/prisma/client/models"; -import prisma from "../db/database"; -import { sum } from "~/utils/array"; - -export type DropChunk = { - permissions: number; - ids: string[]; - checksums: string[]; - lengths: number[]; -}; - -export type DropManifest = { - [key: string]: DropChunk; -}; - -export type DropManifestMetadata = { - manifest: DropManifest; - versionName: string; -}; - -export type DropGeneratedManifest = DropManifest & { - [key: string]: { versionName: string }; -}; - -class ManifestGenerator { - private static generateManifestFromMetadata( - rootManifest: DropManifestMetadata, - ...overlays: DropManifestMetadata[] - ): DropGeneratedManifest { - if (overlays.length == 0) { - return Object.fromEntries( - Object.entries(rootManifest.manifest).map(([key, value]) => { - return [ - key, - Object.assign({}, value, { versionName: rootManifest.versionName }), - ]; - }), - ); - } - - // Recurse in verse order through versions, skipping files that already exist. - const versions = [...overlays.reverse(), rootManifest]; - const manifest: DropGeneratedManifest = {}; - for (const version of versions) { - for (const [filename, chunk] of Object.entries(version.manifest)) { - if (manifest[filename]) continue; - manifest[filename] = Object.assign({}, chunk, { - versionName: version.versionName, - }); - } - } - - return manifest; - } - - // Local function because eventual caching - async generateManifest(gameId: string, versionName: string) { - const versions: GameVersionModel[] = []; - - const baseVersion = await prisma.gameVersion.findUnique({ - where: { - gameId_versionName: { - gameId: gameId, - versionName: versionName, - }, - }, - }); - if (!baseVersion) return undefined; - versions.push(baseVersion); - - // Collect other versions if this is a delta - if (baseVersion.delta) { - // Start at the same index minus one, and keep grabbing them - // until we run out or we hit something that isn't a delta - // eslint-disable-next-line no-constant-condition - for (let i = baseVersion.versionIndex - 1; true; i--) { - const currentVersion = await prisma.gameVersion.findFirst({ - where: { - gameId: gameId, - versionIndex: i, - platform: baseVersion.platform, - }, - }); - if (!currentVersion) return undefined; - versions.push(currentVersion); - if (!currentVersion.delta) break; - } - } - const leastToMost = versions.reverse(); - const metadata: DropManifestMetadata[] = leastToMost.map((e) => { - return { - manifest: JSON.parse( - e.dropletManifest?.toString() ?? "{}", - ) as DropManifest, - versionName: e.versionName, - }; - }); - - const manifest = ManifestGenerator.generateManifestFromMetadata( - metadata[0], - ...metadata.slice(1), - ); - - return manifest; - } - - calculateManifestSize(manifest: DropManifest) { - return sum( - Object.values(manifest) - .map((chunk) => chunk.lengths) - .flat(), - ); - } -} - -export const manifestGenerator = new ManifestGenerator(); -export default manifestGenerator; diff --git a/server/internal/gamesize/index.ts b/server/internal/gamesize/index.ts index 9e20976..dac70b9 100644 --- a/server/internal/gamesize/index.ts +++ b/server/internal/gamesize/index.ts @@ -1,8 +1,8 @@ import cacheHandler from "../cache"; import prisma from "../db/database"; -import manifestGenerator from "../downloads/manifest"; import { sum } from "../../../utils/array"; import type { Game, GameVersion } from "~/prisma/client/client"; +import { castManifest } from "../library/manifest"; export type GameSize = { gameName: string; @@ -46,20 +46,16 @@ class GameSizeManager { where: { gameId }, }); const sizes = await Promise.all( - versions.map((version) => - manifestGenerator.calculateManifestSize( - JSON.parse(version.dropletManifest as string), - ), - ), + versions.map((version) => castManifest(version.dropletManifest).size), ); return sum(sizes); } async getGameVersionSize( gameId: string, - versionName?: string, + versionId?: string, ): Promise { - if (!versionName) { + if (!versionId) { const version = await prisma.gameVersion.findFirst({ where: { gameId }, orderBy: { @@ -69,18 +65,14 @@ class GameSizeManager { if (!version) { return null; } - versionName = version.versionName; + versionId = version.versionId; } - const manifest = await manifestGenerator.generateManifest( - gameId, - versionName, - ); - if (!manifest) { - return null; - } + const { dropletManifest } = (await prisma.gameVersion.findUnique({ + where: { gameId_versionId: { versionId, gameId } }, + }))!; - return manifestGenerator.calculateManifestSize(manifest); + return castManifest(dropletManifest).size; } private async isLatestVersion( @@ -88,7 +80,7 @@ class GameSizeManager { version: GameVersion, ): Promise { return gameVersions.length > 0 - ? gameVersions[0].versionName === version.versionName + ? gameVersions[0].versionId === version.versionId : false; } @@ -162,16 +154,16 @@ class GameSizeManager { async cacheGameVersion( game: Game & { versions: GameVersion[] }, - versionName?: string, + versionId?: string, ) { const cacheVersion = async (version: GameVersion) => { - const size = await this.getGameVersionSize(game.id, version.versionName); - if (!version.versionName || !size) { + const size = await this.getGameVersionSize(game.id, version.versionId); + if (!version.versionId || !size) { return; } const versionsSizes = { - [version.versionName]: { + [version.versionId]: { size, gameName: game.mName, gameId: game.id, @@ -186,9 +178,9 @@ class GameSizeManager { }); }; - if (versionName) { + if (versionId) { const version = await prisma.gameVersion.findFirst({ - where: { gameId: game.id, versionName }, + where: { gameId: game.id, versionId }, }); if (!version) { return; diff --git a/server/internal/library/index.ts b/server/internal/library/index.ts index 4c5b54c..48c7f19 100644 --- a/server/internal/library/index.ts +++ b/server/internal/library/index.ts @@ -9,7 +9,6 @@ import path from "path"; import prisma from "../db/database"; import { fuzzy } from "fast-fuzzy"; import taskHandler from "../tasks"; -import { parsePlatform } from "../utils/parseplatform"; import notificationSystem from "../notifications"; import { GameNotFoundError, type LibraryProvider } from "./provider"; import { logger } from "../logging"; @@ -17,6 +16,8 @@ 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"; export function createGameImportTaskId(libraryId: string, libraryPath: string) { return createHash("md5") @@ -24,7 +25,10 @@ export function createGameImportTaskId(libraryId: string, libraryPath: string) { .digest("hex"); } -export function createVersionImportTaskId(gameId: string, versionName: string) { +export function createVersionImportTaskKey( + gameId: string, + versionName: string, +) { return createHash("md5") .update(`import:${gameId}:${versionName}`) .digest("hex"); @@ -41,6 +45,10 @@ class LibraryManager { this.libraries.delete(id); } + getLibrary(libraryId: string): LibraryProvider | undefined { + return this.libraries.get(libraryId); + } + async fetchLibraries(): Promise { const libraries = await prisma.library.findMany({}); @@ -79,7 +87,7 @@ class LibraryManager { const providerUnimportedGames = providerGames.filter( (libraryPath) => !instanceGames[id]?.[libraryPath] && - !taskHandler.hasTask(createGameImportTaskId(id, libraryPath)), + !taskHandler.hasTaskKey(createGameImportTaskId(id, libraryPath)), ); unimportedGames[id] = providerUnimportedGames; } @@ -107,12 +115,12 @@ class LibraryManager { try { const versions = await provider.listVersions( libraryPath, - game.versions.map((v) => v.versionName), + game.versions.map((v) => v.versionPath), ); const unimportedVersions = versions.filter( (e) => - game.versions.findIndex((v) => v.versionName == e) == -1 && - !taskHandler.hasTask(createVersionImportTaskId(game.id, e)), + game.versions.findIndex((v) => v.versionPath == e) == -1 && + !taskHandler.hasTaskKey(createVersionImportTaskKey(game.id, e)), ); return unimportedVersions; } catch (e) { @@ -127,12 +135,8 @@ class LibraryManager { async fetchGamesWithStatus() { const games = await prisma.game.findMany({ include: { - versions: { - select: { - versionName: true, - }, - }, library: true, + versions: true, }, orderBy: { mName: "asc", @@ -209,7 +213,7 @@ class LibraryManager { if (checkExt != ext) continue; const fuzzyValue = fuzzy(basename, game.mName); options.push({ - filename, + filename: filename.replaceAll(" ", "\\ "), platform, match: fuzzyValue, }); @@ -243,24 +247,10 @@ class LibraryManager { async importVersion( gameId: string, - versionName: string, - metadata: { - platform: string; - onlySetup: boolean; - - setup: string; - setupArgs: string; - launch: string; - launchArgs: string; - delta: boolean; - - umuId: string; - }, + versionPath: string, + metadata: typeof ImportVersion.infer, ) { - const taskId = createVersionImportTaskId(gameId, versionName); - - const platform = parsePlatform(metadata.platform); - if (!platform) return undefined; + const taskKey = createVersionImportTaskKey(gameId, versionPath); const game = await prisma.game.findUnique({ where: { id: gameId }, @@ -271,17 +261,17 @@ class LibraryManager { const library = this.libraries.get(game.libraryId); if (!library) return undefined; - taskHandler.create({ - id: taskId, + return await taskHandler.create({ + key: taskKey, taskGroup: "import:game", - name: `Importing version ${versionName} for ${game.mName}`, + name: `Importing version ${versionPath} for ${game.mName}`, acls: ["system:import:version:read"], async run({ progress, logger }) { // First, create the manifest via droplet. // This takes up 90% of our progress, so we wrap it in a *0.9 const manifest = await library.generateDropletManifest( game.libraryPath, - versionName, + versionPath, (err, value) => { if (err) throw err; progress(value * 0.9); @@ -299,59 +289,64 @@ class LibraryManager { }); // Then, create the database object - if (metadata.onlySetup) { - await prisma.gameVersion.create({ - data: { - gameId: gameId, - versionName: versionName, - dropletManifest: manifest, - versionIndex: currentIndex, - delta: metadata.delta, - umuIdOverride: metadata.umuId, - platform: platform, - - onlySetup: true, - setupCommand: metadata.setup, - setupArgs: metadata.setupArgs.split(" "), + await prisma.gameVersion.create({ + data: { + game: { + connect: { + id: gameId, + }, }, - }); - } else { - await prisma.gameVersion.create({ - data: { - gameId: gameId, - versionName: versionName, - dropletManifest: manifest, - versionIndex: currentIndex, - delta: metadata.delta, - umuIdOverride: metadata.umuId, - platform: platform, - onlySetup: false, - setupCommand: metadata.setup, - setupArgs: metadata.setupArgs.split(" "), - launchCommand: metadata.launch, - launchArgs: metadata.launchArgs.split(" "), + displayName: metadata.displayName ?? null, + + versionPath, + dropletManifest: manifest, + versionIndex: currentIndex, + delta: metadata.delta, + + onlySetup: metadata.onlySetup, + setups: { + createMany: { + data: metadata.setups.map((v) => ({ + command: v.launch, + platform: v.platform, + })), + }, }, - }); - } + launches: { + createMany: !metadata.onlySetup + ? { + data: metadata.launches.map((v) => ({ + name: v.name, + command: v.launch, + platform: v.platform, + ...(v.executorId + ? { executorId: v.executorId } + : undefined), + })), + } + : { data: [] }, + }, + }, + }); logger.info("Successfully created version!"); notificationSystem.systemPush({ - nonce: `version-create-${gameId}-${versionName}`, - title: `'${game.mName}' ('${versionName}') finished importing.`, - description: `Drop finished importing version ${versionName} for ${game.mName}.`, + nonce: `version-create-${gameId}-${versionPath}`, + title: `'${game.mName}' ('${versionPath}') finished importing.`, + description: `Drop finished importing version ${versionPath} for ${game.mName}.`, actions: [`View|/admin/library/${gameId}`], acls: ["system:import:version:read"], }); await libraryManager.cacheCombinedGameSize(gameId); - await libraryManager.cacheGameVersionSize(gameId, versionName); + await libraryManager.cacheGameVersionSize(gameId, versionPath); + + await TORRENTIAL_SERVICE.utils().invalidate(gameId, versionPath); progress(100); }, }); - - return taskId; } async peekFile( @@ -381,7 +376,7 @@ class LibraryManager { await prisma.gameVersion.deleteMany({ where: { gameId: gameId, - versionName: version, + versionId: version, }, }); diff --git a/server/internal/library/manifest.ts b/server/internal/library/manifest.ts new file mode 100644 index 0000000..c6f5d06 --- /dev/null +++ b/server/internal/library/manifest.ts @@ -0,0 +1,27 @@ +import type { JsonValue } from "@prisma/client/runtime/library"; + +export type Manifest = V2Manifest; + +export type V2Manifest = { + version: "2"; + size: number; + key: number[]; + chunks: { [key: string]: V2ChunkData[] }; +}; + +export type V2ChunkData = { + files: Array; + checksum: string; + iv: number[]; +}; + +export type V2FileEntry = { + filename: string; + start: number; + length: number; + permissions: number; +}; + +export function castManifest(manifest: JsonValue): Manifest { + return JSON.parse(manifest as string) as Manifest; +} diff --git a/server/internal/library/providers/filesystem.ts b/server/internal/library/providers/filesystem.ts index 17e5e97..c433b9f 100644 --- a/server/internal/library/providers/filesystem.ts +++ b/server/internal/library/providers/filesystem.ts @@ -7,15 +7,18 @@ import { import { LibraryBackend } from "~/prisma/client/enums"; import fs from "fs"; import path from "path"; -import droplet, { DropletHandler } from "@drop-oss/droplet"; +import droplet, { + hasBackendForPath, + listFiles, + peekFile, + readFile, +} from "@drop-oss/droplet"; import { fsStats } from "~/server/internal/utils/files"; export const FilesystemProviderConfig = type({ baseDir: "string", }); -export const DROPLET_HANDLER = new DropletHandler(); - export class FilesystemProvider implements LibraryProvider { @@ -64,7 +67,7 @@ export class FilesystemProvider const validVersionDirs = versionDirs.filter((e) => { if (ignoredVersions && ignoredVersions.includes(e)) return false; const fullDir = path.join(this.config.baseDir, game, e); - return DROPLET_HANDLER.hasBackendForPath(fullDir); + return hasBackendForPath(fullDir); }); return validVersionDirs; } @@ -72,7 +75,7 @@ export class FilesystemProvider async versionReaddir(game: string, version: string): Promise { const versionDir = path.join(this.config.baseDir, game, version); if (!fs.existsSync(versionDir)) throw new VersionNotFoundError(); - return DROPLET_HANDLER.listFiles(versionDir); + return await listFiles(versionDir); } async generateDropletManifest( @@ -83,25 +86,14 @@ export class FilesystemProvider ): Promise { const versionDir = path.join(this.config.baseDir, game, version); if (!fs.existsSync(versionDir)) throw new VersionNotFoundError(); - const manifest = await new Promise((r, j) => - droplet.generateManifest( - DROPLET_HANDLER, - versionDir, - progress, - log, - (err, result) => { - if (err) return j(err); - r(result); - }, - ), - ); + const manifest = await droplet.generateManifest(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 = DROPLET_HANDLER.peekFile(filepath, filename); + const stat = await peekFile(filepath, filename); return { size: Number(stat) }; } @@ -113,7 +105,7 @@ export class FilesystemProvider ) { const filepath = path.join(this.config.baseDir, game, version); if (!fs.existsSync(filepath)) return undefined; - const stream = DROPLET_HANDLER.readFile( + const stream = await readFile( filepath, filename, options?.start ? BigInt(options.start) : undefined, diff --git a/server/internal/library/providers/flat.ts b/server/internal/library/providers/flat.ts index cb1f131..39d9901 100644 --- a/server/internal/library/providers/flat.ts +++ b/server/internal/library/providers/flat.ts @@ -4,8 +4,12 @@ import { VersionNotFoundError } from "../provider"; import { LibraryBackend } from "~/prisma/client/enums"; import fs from "fs"; import path from "path"; -import droplet from "@drop-oss/droplet"; -import { DROPLET_HANDLER } from "./filesystem"; +import droplet, { + hasBackendForPath, + listFiles, + peekFile, + readFile, +} from "@drop-oss/droplet"; import { fsStats } from "~/server/internal/utils/files"; export const FlatFilesystemProviderConfig = type({ @@ -48,7 +52,7 @@ export class FlatFilesystemProvider const versionDirs = fs.readdirSync(this.config.baseDir); const validVersionDirs = versionDirs.filter((e) => { const fullDir = path.join(this.config.baseDir, e); - return DROPLET_HANDLER.hasBackendForPath(fullDir); + return hasBackendForPath(fullDir); }); return validVersionDirs; } @@ -65,7 +69,7 @@ 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 DROPLET_HANDLER.listFiles(versionDir); + return await listFiles(versionDir); } async generateDropletManifest( @@ -76,24 +80,13 @@ export class FlatFilesystemProvider ) { const versionDir = path.join(this.config.baseDir, game); if (!fs.existsSync(versionDir)) throw new VersionNotFoundError(); - const manifest = await new Promise((r, j) => - droplet.generateManifest( - DROPLET_HANDLER, - versionDir, - progress, - log, - (err, result) => { - if (err) return j(err); - r(result); - }, - ), - ); + const manifest = await droplet.generateManifest(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 = DROPLET_HANDLER.peekFile(filepath, filename); + const stat = await peekFile(filepath, filename); return { size: Number(stat) }; } async readFile( @@ -104,7 +97,7 @@ export class FlatFilesystemProvider ) { const filepath = path.join(this.config.baseDir, game); if (!fs.existsSync(filepath)) return undefined; - const stream = DROPLET_HANDLER.readFile( + const stream = await readFile( filepath, filename, options?.start ? BigInt(options.start) : undefined, diff --git a/server/internal/metadata/giantbomb.ts b/server/internal/metadata/giantbomb.ts index 5a78985..acc488a 100644 --- a/server/internal/metadata/giantbomb.ts +++ b/server/internal/metadata/giantbomb.ts @@ -10,10 +10,10 @@ import type { CompanyMetadata, GameMetadataRating, } from "./types"; -import axios, { type AxiosRequestConfig } from "axios"; import TurndownService from "turndown"; import { DateTime } from "luxon"; import type { TaskRunContext } from "../tasks"; +import type { NitroFetchOptions, NitroFetchRequest } from "nitropack"; interface GiantBombResponseType { error: "OK" | string; @@ -120,7 +120,7 @@ export class GiantBombProvider implements MetadataProvider { resource: string, url: string, query: { [key: string]: string }, - options?: AxiosRequestConfig, + options?: NitroFetchOptions, ) { const queryString = new URLSearchParams({ ...query, @@ -130,13 +130,7 @@ export class GiantBombProvider implements MetadataProvider { const finalURL = `https://www.giantbomb.com/api/${resource}/${url}?${queryString}`; - const overlay: AxiosRequestConfig = { - url: finalURL, - baseURL: "", - }; - const response = await axios.request>( - Object.assign({}, options, overlay), - ); + const response = await $fetch>(finalURL, options); return response; } @@ -152,7 +146,7 @@ export class GiantBombProvider implements MetadataProvider { query: query, resources: ["game"].join(","), }); - const mapped = results.data.results.map((result) => { + const mapped = results.results.map((result) => { const date = (result.original_release_date ? DateTime.fromISO(result.original_release_date).year @@ -172,13 +166,13 @@ export class GiantBombProvider implements MetadataProvider { return mapped; } async fetchGame( - { id, publisher, developer, createObject }: _FetchGameMetadataParams, + { id, company, createObject }: _FetchGameMetadataParams, context?: TaskRunContext, ): Promise { context?.logger.info("Using GiantBomb provider"); const result = await this.request("game", id, {}); - const gameData = result.data.results; + const gameData = result.results; const longDescription = gameData.description ? this.turndown.turndown(gameData.description) @@ -189,7 +183,7 @@ export class GiantBombProvider implements MetadataProvider { for (const pub of gameData.publishers) { context?.logger.info(`Importing publisher "${pub.name}"`); - const res = await publisher(pub.name); + const res = await company(pub.name); if (res === undefined) { context?.logger.warn(`Failed to import publisher "${pub.name}"`); continue; @@ -206,7 +200,7 @@ export class GiantBombProvider implements MetadataProvider { for (const dev of gameData.developers) { context?.logger.info(`Importing developer "${dev.name}"`); - const res = await developer(dev.name); + const res = await company(dev.name); if (res === undefined) { context?.logger.warn(`Failed to import developer "${dev.name}"`); continue; @@ -244,8 +238,8 @@ export class GiantBombProvider implements MetadataProvider { metadataSource: MetadataSource.GiantBomb, metadataId: reviewId, mReviewCount: 1, - mReviewRating: review.data.results.score / 5, - mReviewHref: review.data.results.site_detail_url, + mReviewRating: review.results.score / 5, + mReviewHref: review.results.site_detail_url, }); } } @@ -289,8 +283,7 @@ export class GiantBombProvider implements MetadataProvider { // Find the right entry const company = - results.data.results.find((e) => e.name == query) ?? - results.data.results.at(0); + results.results.find((e) => e.name == query) ?? results.results.at(0); if (!company) return undefined; const longDescription = company.description diff --git a/server/internal/metadata/igdb.ts b/server/internal/metadata/igdb.ts index c135117..98424ab 100644 --- a/server/internal/metadata/igdb.ts +++ b/server/internal/metadata/igdb.ts @@ -9,12 +9,11 @@ import type { _FetchCompanyMetadataParams, CompanyMetadata, } from "./types"; -import type { AxiosRequestConfig } from "axios"; -import axios from "axios"; import { DateTime } from "luxon"; import * as jdenticon from "jdenticon"; import type { TaskRunContext } from "../tasks"; import { logger } from "~/server/internal/logging"; +import type { NitroFetchOptions, NitroFetchRequest } from "nitropack"; type IGDBID = number; @@ -171,20 +170,16 @@ export class IGDBProvider implements MetadataProvider { grant_type: "client_credentials", }); - const response = await axios.request({ - url: `https://id.twitch.tv/oauth2/token?${params.toString()}`, - baseURL: "", - method: "POST", - }); + const response = await $fetch( + `https://id.twitch.tv/oauth2/token?${params.toString()}`, + { + method: "POST", + }, + ); - if (response.status !== 200) - throw new Error( - `Error in IGDB \nStatus Code: ${response.status}\n${response.data}`, - ); - - this.accessToken = response.data.access_token; + this.accessToken = response.access_token; this.accessTokenExpiry = DateTime.now().plus({ - seconds: response.data.expires_in, + seconds: response.expires_in, }); logger.info("IGDB done authorizing with twitch"); @@ -202,7 +197,7 @@ export class IGDBProvider implements MetadataProvider { private async request( resource: string, body: string, - options?: AxiosRequestConfig, + options?: NitroFetchOptions, ) { await this.refreshCredentials(); @@ -214,11 +209,10 @@ export class IGDBProvider implements MetadataProvider { const finalURL = `https://api.igdb.com/v4/${resource}`; - const overlay: AxiosRequestConfig = { - url: finalURL, + const overlay: NitroFetchOptions = { baseURL: "", method: "POST", - data: body, + body, headers: { Accept: "application/json", "Client-ID": this.clientId, @@ -226,24 +220,13 @@ export class IGDBProvider implements MetadataProvider { "content-type": "text/plain", }, }; - const response = await axios.request( + const response = await $fetch( + finalURL, Object.assign({}, options, overlay), ); - if (response.status !== 200) { - let cause = ""; - - response.data.forEach((item) => { - if ("cause" in item) cause = item.cause; - }); - - throw new Error( - `Error in igdb \nStatus Code: ${response.status} \nCause: ${cause}`, - ); - } - // should not have an error object if the status code is 200 - return response.data; + return response; } private async _getMediaInternal( @@ -356,7 +339,7 @@ export class IGDBProvider implements MetadataProvider { return results; } async fetchGame( - { id, publisher, developer, createObject }: _FetchGameMetadataParams, + { id, company, createObject }: _FetchGameMetadataParams, context?: TaskRunContext, ): Promise { const body = `where id = ${id}; fields *;`; @@ -416,34 +399,28 @@ export class IGDBProvider implements MetadataProvider { { name: string } & IGDBItem >("companies", `where id = ${foundInvolved.company}; fields name;`); - for (const company of findCompanyResponse) { + for (const companyData of findCompanyResponse) { context?.logger.info( `Found involved company "${company.name}" as: ${foundInvolved.developer ? "developer, " : ""}${foundInvolved.publisher ? "publisher" : ""}`, ); + const res = await company(companyData.name); + if (res === undefined) { + context?.logger.warn( + `Failed to import company "${companyData.name}"`, + ); + continue; + } + // if company was a dev or publisher // CANNOT use else since a company can be both if (foundInvolved.developer) { - const res = await developer(company.name); - if (res === undefined) { - context?.logger.warn( - `Failed to import developer "${company.name}"`, - ); - continue; - } - context?.logger.info(`Imported developer "${company.name}"`); + context?.logger.info(`Imported developer "${companyData.name}"`); developers.push(res); } if (foundInvolved.publisher) { - const res = await publisher(company.name); - if (res === undefined) { - context?.logger.warn( - `Failed to import publisher "${company.name}"`, - ); - continue; - } - context?.logger.info(`Imported publisher "${company.name}"`); + context?.logger.info(`Imported publisher "${companyData.name}"`); publishers.push(res); } } diff --git a/server/internal/metadata/index.ts b/server/internal/metadata/index.ts index 9c7c69e..34412d0 100644 --- a/server/internal/metadata/index.ts +++ b/server/internal/metadata/index.ts @@ -191,10 +191,10 @@ export class MetadataHandler { const gameId = randomUUID(); - const taskId = createGameImportTaskId(libraryId, libraryPath); - await taskHandler.create({ + const key = createGameImportTaskId(libraryId, libraryPath); + return await taskHandler.create({ name: `Import game "${result.name}" (${libraryPath})`, - id: taskId, + key, taskGroup: "import:game", acls: ["system:import:game:read"], async run(context) { @@ -213,6 +213,11 @@ export class MetadataHandler { }), ); + const companyLookupCache: { + [key: string]: Awaited< + ReturnType + >; + } = {}; let metadata: GameMetadata | undefined = undefined; try { metadata = await provider.fetchGame( @@ -220,8 +225,13 @@ export class MetadataHandler { id: result.id, name: result.name, // wrap in anonymous functions to keep references to this - publisher: (name: string) => metadataHandler.fetchCompany(name), - developer: (name: string) => metadataHandler.fetchCompany(name), + company: async (name: string) => { + if (companyLookupCache[name]) return companyLookupCache[name]; + + const companyData = await metadataHandler.fetchCompany(name); + companyLookupCache[name] = companyData; + return companyData; + }, createObject, }, wrapTaskContext(context, { @@ -281,10 +291,10 @@ export class MetadataHandler { logger.info(`Finished game import.`); progress(100); + + context.addAction(`View Game:/admin/library/${gameId}`); }, }); - - return taskId; } // Careful with this function, it has no typechecking diff --git a/server/internal/metadata/pcgamingwiki.ts b/server/internal/metadata/pcgamingwiki.ts index ab965bc..c16f45f 100644 --- a/server/internal/metadata/pcgamingwiki.ts +++ b/server/internal/metadata/pcgamingwiki.ts @@ -9,14 +9,13 @@ import type { CompanyMetadata, GameMetadataRating, } from "./types"; -import type { AxiosRequestConfig } from "axios"; -import axios from "axios"; import * as jdenticon from "jdenticon"; import { DateTime } from "luxon"; import * as cheerio from "cheerio"; import { type } from "arktype"; import type { TaskRunContext } from "../tasks"; import { logger } from "~/server/internal/logging"; +import type { NitroFetchOptions, NitroFetchRequest } from "nitropack"; interface PCGamingWikiParseRawPage { parse: { @@ -104,35 +103,24 @@ export class PCGamingWikiProvider implements MetadataProvider { private async request( query: URLSearchParams, - options?: AxiosRequestConfig, + options?: NitroFetchOptions, ) { const finalURL = `https://www.pcgamingwiki.com/w/api.php?${query.toString()}`; - const overlay: AxiosRequestConfig = { - url: finalURL, - baseURL: "", - }; - const response = await axios.request( - Object.assign({}, options, overlay), - ); - - if (response.status !== 200) - throw new Error( - `Error in pcgamingwiki \nStatus Code: ${response.status}\n${response.data}`, - ); + const response = await $fetch(finalURL, options); return response; } private async cargoQuery( query: URLSearchParams, - options?: AxiosRequestConfig, + options?: NitroFetchOptions, ) { const response = await this.request>( query, options, ); - if (response.data.error !== undefined) + if (response.error !== undefined) throw new Error(`Error in pcgamingwiki cargo query`); return response; } @@ -150,7 +138,7 @@ export class PCGamingWikiProvider implements MetadataProvider { pageid: pageID, }); const res = await this.request(searchParams); - const $ = cheerio.load(res.data.parse.text["*"]); + const $ = cheerio.load(res.parse.text["*"]); // get intro based on 'introduction' class const introductionEle = $(".introduction").first(); // remove citations from intro @@ -281,7 +269,7 @@ export class PCGamingWikiProvider implements MetadataProvider { await this.cargoQuery(searchParams); const results: GameMetadataSearchResult[] = []; - for (const result of response.data.cargoquery) { + for (const result of response.cargoquery) { const game = result.title; const pageContent = await this.getPageContent(game.PageID); @@ -372,7 +360,7 @@ export class PCGamingWikiProvider implements MetadataProvider { } async fetchGame( - { id, name, publisher, developer, createObject }: _FetchGameMetadataParams, + { id, name, company, createObject }: _FetchGameMetadataParams, context?: TaskRunContext, ): Promise { context?.logger.info("Using PCGamingWiki provider"); @@ -391,10 +379,10 @@ export class PCGamingWikiProvider implements MetadataProvider { this.cargoQuery(searchParams), this.getPageContent(id), ]); - if (res.data.cargoquery.length < 1) + if (res.cargoquery.length < 1) throw new Error("Error in pcgamingwiki, no game"); - const game = res.data.cargoquery[0].title; + const game = res.cargoquery[0].title; const publishers: CompanyModel[] = []; if (game.Publishers !== null) { @@ -403,7 +391,7 @@ export class PCGamingWikiProvider implements MetadataProvider { for (const pub of pubListClean) { context?.logger.info(`Importing publisher "${pub}"...`); - const res = await publisher(pub); + const res = await company(pub); if (res === undefined) { context?.logger.warn(`Failed to import publisher "${pub}"`); continue; @@ -422,7 +410,7 @@ export class PCGamingWikiProvider implements MetadataProvider { const devListClean = this.parseWikiStringArray(game.Developers); for (const dev of devListClean) { context?.logger.info(`Importing developer "${dev}"...`); - const res = await developer(dev); + const res = await company(dev); if (res === undefined) { context?.logger.warn(`Failed to import developer "${dev}"`); continue; @@ -487,8 +475,8 @@ export class PCGamingWikiProvider implements MetadataProvider { // TODO: replace with company logo const icon = createObject(jdenticon.toPng(query, 512)); - for (let i = 0; i < res.data.cargoquery.length; i++) { - const company = res.data.cargoquery[i].title; + for (let i = 0; i < res.cargoquery.length; i++) { + const company = res.cargoquery[i].title; const fixedCompanyName = this.parseWikiStringArray(company.PageName)[0] ?? company.PageName; diff --git a/server/internal/metadata/steam.ts b/server/internal/metadata/steam.ts index 7caa8db..f89f90f 100644 --- a/server/internal/metadata/steam.ts +++ b/server/internal/metadata/steam.ts @@ -9,8 +9,8 @@ import type { GameMetadataRating, } from "./types"; import type { TaskRunContext } from "../tasks"; -import axios from "axios"; import * as jdenticon from "jdenticon"; +import { load } from "cheerio"; /** * Note: The Steam API is largely undocumented. @@ -188,19 +188,15 @@ export class SteamProvider implements MetadataProvider { } async search(query: string): Promise { - const response = await axios.get( + const response = await $fetch( `https://steamcommunity.com/actions/SearchApps/${query}`, ); - if ( - response.status !== 200 || - !response.data || - response.data.length === 0 - ) { + if (!response || response.length === 0) { return []; } - const result: GameMetadataSearchResult[] = response.data.map((item) => ({ + const result: GameMetadataSearchResult[] = response.map((item) => ({ id: item.appid, name: item.name, icon: item.icon || "", @@ -208,7 +204,7 @@ export class SteamProvider implements MetadataProvider { year: 0, })); - const ids = response.data.map((i) => i.appid); + const ids = response.map((i) => i.appid); const detailsResponse = await this._fetchGameDetails(ids, { include_basic_info: true, @@ -235,7 +231,7 @@ export class SteamProvider implements MetadataProvider { } async fetchGame( - { id, publisher, developer, createObject }: _FetchGameMetadataParams, + { id, company, createObject }: _FetchGameMetadataParams, context?: TaskRunContext, ): Promise { context?.logger.info(`Starting Steam metadata fetch for game ID: ${id}`); @@ -294,38 +290,66 @@ export class SteamProvider implements MetadataProvider { context?.progress(70); context?.logger.info("Processing publishers and developers..."); + const storePage = await $fetch( + `https://store.steampowered.com/app/${id}/`, + ); + const $ = load(storePage); + + const companyLinks = $("a") + .toArray() + .filter( + (v) => + v.attribs["href"]?.startsWith( + "https://store.steampowered.com/developer/", + ) || + v.attribs["href"]?.startsWith( + "https://store.steampowered.com/publisher/", + ), + ) + .map((v) => v.attribs.href); + + const companies: { + [key: string]: { + pub: boolean; + dev: boolean; + }; + } = {}; + + companyLinks.forEach((v) => { + const [type, name] = v + .substring("https://store.steampowered.com/".length, v.indexOf("?")) + .split("/"); + + companies[name] ??= { pub: false, dev: false }; + switch (type) { + case "publisher": + companies[name].pub = true; + break; + case "developer": + companies[name].dev = true; + break; + } + }); + const publishers = []; - const publisherNames = currentGame.basic_info.publishers || []; - context?.logger.info( - `Found ${publisherNames.length} publisher(s) to process`, - ); - - for (const pub of publisherNames) { - context?.logger.info(`Processing publisher: "${pub.name}"`); - const comp = await publisher(pub.name); - if (!comp) { - context?.logger.warn(`Failed to import publisher "${pub.name}"`); - continue; - } - publishers.push(comp); - context?.logger.info(`Successfully imported publisher: "${pub.name}"`); - } - const developers = []; - const developerNames = currentGame.basic_info.developers || []; - context?.logger.info( - `Found ${developerNames.length} developer(s) to process`, - ); - for (const dev of developerNames) { - context?.logger.info(`Processing developer: "${dev.name}"`); - const comp = await developer(dev.name); - if (!comp) { - context?.logger.warn(`Failed to import developer "${dev.name}"`); - continue; + for (const [companyName, types] of Object.entries(companies)) { + context?.logger.info(`Processing company: "${companyName}"`); + const comp = await company(companyName); + + if (types.dev) { + developers.push(comp); + context?.logger.info( + `Successfully imported developer: "${companyName}"`, + ); + } + if (types.pub) { + publishers.push(comp); + context?.logger.info( + `Successfully imported publisher: "${companyName}"`, + ); } - developers.push(comp); - context?.logger.info(`Successfully imported developer: "${dev.name}"`); } context?.logger.info( @@ -425,23 +449,19 @@ export class SteamProvider implements MetadataProvider { l: "english", }); - const response = await axios.get( - `https://store.steampowered.com/developer/${query.replaceAll(" ", "")}/?${searchParams.toString()}`, - { - maxRedirects: 0, - }, - ); + const url = `https://store.steampowered.com/developer/${encodeURIComponent(query)}/?${searchParams.toString()}`; + const response = await $fetch(url); - if (response.status !== 200 || !response.data) { + if (!response) { return undefined; } - const html = response.data; + const html = response; // Extract metadata from HTML meta tags const metadata = this._extractMetaTagsFromHtml(html); - if (!metadata.title) { + if (!metadata.title || metadata.title == "Steam Search") { return undefined; } @@ -623,14 +643,12 @@ export class SteamProvider implements MetadataProvider { }), }); - const request = await axios.get( + const request = await $fetch( `https://api.steampowered.com/IStoreBrowseService/GetItems/v1/?${searchParams.toString()}`, ); - if (request.status !== 200) return []; - const result = []; - const storeItems = request.data?.response?.store_items ?? []; + const storeItems = request.response?.store_items ?? []; for (const item of storeItems) { if (item.success !== 1) continue; @@ -723,14 +741,14 @@ export class SteamProvider implements MetadataProvider { language, }); - const request = await axios.get( + const request = await $fetch( `https://api.steampowered.com/IStoreService/GetTagList/v1/?${searchParams.toString()}`, ); - if (request.status !== 200 || !request.data.response?.tags) return []; + if (!request.response?.tags) return []; const tagMap = new Map(); - for (const tag of request.data.response.tags) { + for (const tag of request.response.tags) { tagMap.set(tag.tagid, tag.name); } @@ -756,15 +774,11 @@ export class SteamProvider implements MetadataProvider { l: language, }); - const request = await axios.get( + const request = await $fetch( `https://store.steampowered.com/api/appdetails?${searchParams.toString()}`, ); - if (request.status !== 200) { - return undefined; - } - - const appData = request.data[appid]?.data; + const appData = request[appid]?.data; if (!appData) { return undefined; } diff --git a/server/internal/metadata/types.d.ts b/server/internal/metadata/types.d.ts index b5286a7..7604e8a 100644 --- a/server/internal/metadata/types.d.ts +++ b/server/internal/metadata/types.d.ts @@ -65,8 +65,7 @@ export interface _FetchGameMetadataParams { id: string; name: string; - publisher: (query: string) => Promise; - developer: (query: string) => Promise; + company: (query: string) => Promise; createObject: (data: TransactionDataType) => ObjectReference; } diff --git a/server/internal/news/index.ts b/server/internal/news/index.ts index 694ff26..b9ee8e9 100644 --- a/server/internal/news/index.ts +++ b/server/internal/news/index.ts @@ -117,10 +117,12 @@ class NewsManager { image?: string; }, ) { - return await prisma.article.update({ - where: { id }, - data, - }); + return ( + await prisma.article.updateManyAndReturn({ + where: { id }, + data, + }) + ).at(0); } async delete(id: string) { diff --git a/server/internal/saves/index.ts b/server/internal/saves/index.ts index 7b76f90..665c4b4 100644 --- a/server/internal/saves/index.ts +++ b/server/internal/saves/index.ts @@ -68,13 +68,11 @@ class SaveManager { }); } - const newSave = await prisma.saveSlot.update({ + const newSaves = await prisma.saveSlot.updateManyAndReturn({ where: { - id: { - userId, - gameId, - index, - }, + userId, + gameId, + index, }, data: { historyObjectIds: { @@ -86,6 +84,9 @@ class SaveManager { ...(clientId && { lastUsedClientId: clientId }), }, }); + const newSave = newSaves.at(0); + if (!newSave) + throw createError({ statusCode: 404, message: "Save not found" }); const historyLimit = await applicationSettings.get("saveSlotHistoryLimit"); if (newSave.historyObjectIds.length > historyLimit) { @@ -101,19 +102,20 @@ class SaveManager { await this.deleteObjectFromSave(gameId, userId, index, objectId); } - await prisma.saveSlot.update({ + const { count } = await prisma.saveSlot.updateMany({ where: { - id: { - userId, - gameId, - index, - }, + userId, + gameId, + index, }, data: { historyObjectIds: toKeepObjects, historyChecksums: toKeepHashes, }, }); + if (count == 0) { + throw createError({ statusCode: 404, message: "Save not found" }); + } } } } diff --git a/server/internal/services/index.ts b/server/internal/services/index.ts new file mode 100644 index 0000000..ed14d37 --- /dev/null +++ b/server/internal/services/index.ts @@ -0,0 +1,162 @@ +import type { ChildProcess } from "child_process"; +import { logger } from "../logging"; +import type { Logger } from "pino"; + +class ServiceManager { + private services: Map> = new Map(); + + register(name: string, service: Service) { + this.services.set(name, service); + } + + spin() { + for (const service of this.services.values()) { + service.spin(); + } + } + + kill() { + for (const service of this.services.values()) { + service.kill(); + } + } + + healthchecks() { + return this.services + .entries() + .map(([name, service]) => ({ name, healthy: service.serviceHealthy() })) + .toArray(); + } +} + +export type Executor = () => ChildProcess; +export type Setup = () => Promise; +export type Healthcheck = () => Promise; +export class Service { + name: string; + private executor: Executor; + private setup: Setup | undefined; + private healthcheck: Healthcheck | undefined; + + private logger: Logger; + + private currentProcess: ChildProcess | undefined; + + private runningHealthcheck: boolean = false; + private healthy: boolean = true; + private spun: boolean = false; + + private uutils: T; + + constructor( + name: string, + executor: Executor, + setup?: Setup, + healthcheck?: Healthcheck, + utils?: T, + ) { + this.name = name; + const serviceLogger = logger.child({ name: `service-${name}` }); + this.logger = serviceLogger; + this.executor = executor; + this.setup = setup; + this.healthcheck = healthcheck; + this.uutils = utils!; + } + + spin() { + if (this.spun) return; + this.launch(); + + if (this.healthcheck) { + setInterval(this.runHealthcheck, 1000 * 60 * 5); // Every 5 minutes + } + + this.spun = true; + } + + kill() { + this.spun = false; + this.currentProcess?.kill(); + } + + register() { + serviceManager.register(this.name, this); + } + + private async launch() { + if (this.currentProcess) return; + const disableEnv = `EXTERNAL_SERVICE_${this.name.toUpperCase()}`; + if (!process.env[disableEnv]) { + const serviceProcess = this.executor(); + this.logger.info("service launched"); + serviceProcess.on("close", async (code, signal) => { + serviceProcess.kill(); + this.currentProcess = undefined; + this.logger.warn( + `service exited with code ${code} (${signal}), restarting...`, + ); + 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; + } + + if (this.setup) { + while (true) { + try { + const hasSetup = await this.setup(); + if (hasSetup) break; + throw "setup function returned false..."; + } catch (e) { + this.logger.warn(`failed setup, trying again... | ${e}`); + await new Promise((r) => setTimeout(r, 7000)); + } + } + this.healthy = true; + } + } + + private async runHealthcheck() { + if (!this.healthcheck || !this.currentProcess || this.runningHealthcheck) + return; + this.runningHealthcheck = true; + let fails = 0; + + while (true) { + try { + const successful = await this.healthcheck(); + if (successful) break; + } finally { + /* empty */ + } + this.healthy = false; + fails++; + if (fails >= 5) { + this.currentProcess.kill(); + this.runningHealthcheck = false; + return; + } + } + + this.healthy = true; + this.runningHealthcheck = false; + } + + serviceHealthy() { + return this.healthy; + } + + utils() { + return this.uutils; + } +} + +export const serviceManager = new ServiceManager(); +export default serviceManager; diff --git a/server/internal/services/services/nginx.ts b/server/internal/services/services/nginx.ts new file mode 100644 index 0000000..ed55785 --- /dev/null +++ b/server/internal/services/services/nginx.ts @@ -0,0 +1,22 @@ +import { spawn } from "child_process"; +import { Service } from ".."; +import { systemConfig } from "../../config/sys-conf"; +import path from "path"; +import fs from "fs"; + +export const NGINX_SERVICE = new Service( + "nginx", + () => { + const nginxConfig = path.resolve( + process.env.NGINX_CONFIG ?? "./build/nginx.conf", + ); + const nginxPrefix = path.join(systemConfig.getDataFolder(), "nginx"); + fs.mkdirSync(nginxPrefix, { recursive: true }); + + return spawn("nginx", ["-c", nginxConfig, "-p", nginxPrefix]); + }, + undefined, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + async () => await $fetch(`http://127.0.0.1:8080/`), +); diff --git a/server/internal/services/services/torrential.ts b/server/internal/services/services/torrential.ts new file mode 100644 index 0000000..023ea99 --- /dev/null +++ b/server/internal/services/services/torrential.ts @@ -0,0 +1,62 @@ +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) 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); + } + }, + }, +); diff --git a/server/internal/session/db.ts b/server/internal/session/db.ts index de23a4c..7ddc292 100644 --- a/server/internal/session/db.ts +++ b/server/internal/session/db.ts @@ -16,9 +16,17 @@ export default function createDBSessionHandler(): SessionProvider { }, create: { token, - ...session, + ...(session.authenticated?.userId + ? { userId: session.authenticated?.userId } + : undefined), + expiresAt: session.expiresAt, + data: session as object, + }, + + update: { + expiresAt: session.expiresAt, + data: session as object, }, - update: session, }); return true; }, @@ -39,7 +47,7 @@ export default function createDBSessionHandler(): SessionProvider { // i hate casting // need to cast to unknown since result.data can be an N deep json object technically // ts doesn't like that be cast down to the more constraining session type - return result as unknown as T; + return result.data as unknown as T; }, async removeSession(token) { await cache.remove(token); diff --git a/server/internal/session/index.ts b/server/internal/session/index.ts index fa56108..74e0bd5 100644 --- a/server/internal/session/index.ts +++ b/server/internal/session/index.ts @@ -6,6 +6,7 @@ import type { MinimumRequestObject } from "~/server/h3"; import type { DurationLike } from "luxon"; import { DateTime } from "luxon"; import createDBSessionHandler from "./db"; +import prisma from "../db/database"; /* This implementation may need work. @@ -13,6 +14,9 @@ This implementation may need work. It exposes an API that should stay static, but there are plenty of opportunities for optimisation/organisation under the hood */ +// 10 minutes +const SUPERLEVEL_LENGTH = 10 * 60 * 1000; + const dropTokenCookieName = "drop-token"; const normalSessionLength: DurationLike = { days: 31, @@ -21,6 +25,8 @@ const extendedSessionLength: DurationLike = { year: 1, }; +type SigninResult = ["signin", "2fa", "fail"][number]; + export class SessionHandler { private sessionProvider: SessionProvider; @@ -31,14 +37,53 @@ export class SessionHandler { // this.sessionProvider = createMemorySessionProvider(); } - async signin(h3: H3Event, userId: string, rememberMe: boolean = false) { + async signin( + h3: H3Event, + userId: string, + rememberMe: boolean = false, + ): Promise { + const mfaCount = await prisma.linkedMFAMec.count({ + where: { userId, enabled: true }, + }); + const expiresAt = this.createExipreAt(rememberMe); - const token = this.createSessionCookie(h3, expiresAt); - return await this.sessionProvider.setSession(token, { - userId, + + const token = + this.getSessionToken(h3) ?? this.createSessionCookie(h3, expiresAt); + const session = (await this.sessionProvider.getSession(token)) ?? { expiresAt, data: {}, - }); + }; + const wasAuthenticated = !!session.authenticated; + session.authenticated = { + userId, + level: session.authenticated?.level ?? 10, + requiredLevel: mfaCount > 0 ? 20 : 10, + superleveledExpiry: undefined, + }; + if ( + !wasAuthenticated && + session.authenticated.level >= session.authenticated.requiredLevel + ) + session.authenticated.superleveledExpiry = Date.now() + SUPERLEVEL_LENGTH; + const success = await this.sessionProvider.setSession(token, session); + if (!success) return "fail"; + + if (session.authenticated.level < session.authenticated.requiredLevel) + return "2fa"; + return "signin"; + } + + async mfa(h3: H3Event, amount: number) { + const token = this.getSessionToken(h3); + if (!token) + throw createError({ statusCode: 403, message: "User not signed in" }); + const session = await this.sessionProvider.getSession(token); + if (!session || !session.authenticated) + throw createError({ statusCode: 403, message: "User not signed in" }); + + session.authenticated.level += amount; + await this.sessionProvider.setSession(token, session); } /** @@ -48,12 +93,54 @@ export class SessionHandler { async getSession(request: MinimumRequestObject) { const token = this.getSessionToken(request); if (!token) return undefined; - // TODO: should validate if session is expired or not here, not in application code const data = await this.sessionProvider.getSession(token); + if (!data) return undefined; + if (new Date(data.expiresAt).getTime() < Date.now()) return undefined; // Expired return data; } + async getSessionDataKey( + request: MinimumRequestObject, + key: string, + ): Promise { + const token = this.getSessionToken(request); + if (!token) return undefined; + + const session = await this.sessionProvider.getSession(token); + if (!session) return undefined; + return session.data[key] as T; + } + + async setSessionDataKey(request: H3Event, key: string, value: T) { + const expiresAt = this.createExipreAt(true); + + const token = + this.getSessionToken(request) ?? + this.createSessionCookie(request, expiresAt); + + const session = (await this.sessionProvider.getSession(token)) ?? { + expiresAt, + data: {}, + }; + console.log(session); + session.data[key] = value; + await this.sessionProvider.setSession(token, session); + return true; + } + + async deleteSessionDataKey(request: MinimumRequestObject, key: string) { + const token = this.getSessionToken(request); + if (!token) return false; + + const session = await this.sessionProvider.getSession(token); + if (!session) return false; + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete session.data[key]; + await this.sessionProvider.setSession(token, session); + return true; + } + /** * Signout session associated with request and deauthenticates it * @param request diff --git a/server/internal/session/types.d.ts b/server/internal/session/types.d.ts index dde4dc7..1a1d95c 100644 --- a/server/internal/session/types.d.ts +++ b/server/internal/session/types.d.ts @@ -1,5 +1,6 @@ export type Session = { - userId: string; + authenticated?: AuthenticatedSession; + expiresAt: Date; data: { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -7,6 +8,13 @@ export type Session = { }; }; +export interface AuthenticatedSession { + userId: string; + level: number; + requiredLevel: number; + superleveledExpiry: number | undefined; +} + export interface SessionProvider { getSession: (token: string) => Promise; setSession: (token: string, data: Session) => Promise; diff --git a/server/internal/tasks/index.ts b/server/internal/tasks/index.ts index 54433a9..1ad6d37 100644 --- a/server/internal/tasks/index.ts +++ b/server/internal/tasks/index.ts @@ -14,15 +14,19 @@ import pino from "pino"; import { logger } from "~/server/internal/logging"; import { Writable } from "node:stream"; +type TaskActionLink = `${string}:${string}`; + // a task that has been run type FinishedTask = { success: boolean; progress: number; + key: string | undefined; log: string[]; error: { title: string; description: string } | undefined; name: string; taskGroup: TaskGroup; acls: string[]; + actions: TaskActionLink[]; // ISO timestamp of when the task started startTime: string; @@ -53,7 +57,6 @@ class TaskHandler { "cleanup:invitations", "cleanup:sessions", "check:update", - "debug", ]; private weeklyScheduledTasks: TaskGroup[] = ["cleanup:objects"]; @@ -74,8 +77,12 @@ class TaskHandler { this.taskCreators.set(task.taskGroup, task.build); } - async create(task: Task) { - if (this.hasTask(task.id)) throw new Error("Task with ID already exists."); + async create(iTask: Omit) { + const task: Task = { ...iTask, id: crypto.randomUUID() }; + if (this.hasTaskID(task.id)) + throw new Error("Task with ID already exists."); + if (task.key && this.hasTaskKey(task.key)) + throw new Error("Task with key already exists"); let updateCollectTimeout: NodeJS.Timeout | undefined; let updateCollectResolves: Array<(value: unknown) => void> = []; @@ -115,6 +122,7 @@ class TaskHandler { error: taskEntry.error, log: taskEntry.log.slice(logOffset), reset, + actions: taskEntry.actions, }; logOffset = taskEntry.log.length; @@ -189,6 +197,7 @@ class TaskHandler { this.taskPool.set(task.id, { name: task.name, + key: task.key, taskGroup: task.taskGroup, success: false, progress: 0, @@ -198,6 +207,7 @@ class TaskHandler { acls: task.acls, startTime: new Date().toISOString(), endTime: undefined, + actions: task.initialActions ?? [], }); await updateAllClients(true); @@ -205,9 +215,13 @@ class TaskHandler { droplet.callAltThreadFunc(async () => { const taskEntry = this.taskPool.get(task.id); if (!taskEntry) throw new Error("No task entry"); + const addAction = (action: TaskActionLink) => { + taskEntry.actions.push(action); + updateAllClients(); + }; try { - await task.run({ progress, logger: taskLogger }); + await task.run({ progress, logger: taskLogger, addAction }); taskEntry.success = true; } catch (error: unknown) { taskEntry.success = false; @@ -239,6 +253,7 @@ class TaskHandler { log: taskEntry.log, acls: taskEntry.acls, + actions: taskEntry.actions, ...(taskEntry.error ? { error: taskEntry.error } : undefined), }, @@ -246,6 +261,8 @@ class TaskHandler { this.taskPool.delete(task.id); }); + + return task.id; } async connect( @@ -290,6 +307,7 @@ class TaskHandler { | undefined, log: task.log, progress: task.progress, + actions: task.actions as TaskActionLink[], }; peer.send(JSON.stringify(catchupMessage)); } @@ -336,10 +354,16 @@ class TaskHandler { .toArray(); } - hasTask(id: string) { + hasTaskID(id: string) { return this.taskPool.has(id); } + hasTaskKey(key: string) { + return ( + this.taskPool.values().find((v) => v.key && v.key == key) != undefined + ); + } + dailyTasks() { return this.dailyScheduledTasks; } @@ -355,8 +379,8 @@ class TaskHandler { return; } const task = taskConstructor(); - await this.create(task); - return task.id; + const id = await this.create(task); + return id; } /** @@ -415,6 +439,7 @@ class TaskHandler { export type TaskRunContext = { progress: (progress: number) => void; logger: typeof logger; + addAction: (link: TaskActionLink) => void; }; export function wrapTaskContext( @@ -426,6 +451,7 @@ export function wrapTaskContext( }); return { + ...context, progress(progress) { if (progress > 100 || progress < 0) { logger.warn("[wrapTaskContext] progress must be between 0 and 100"); @@ -444,10 +470,12 @@ export function wrapTaskContext( export interface Task { id: string; + key?: string; taskGroup: TaskGroup; name: string; run: (context: TaskRunContext) => Promise; acls: GlobalACL[]; + initialActions?: TaskActionLink[]; } export type TaskMessage = { @@ -458,6 +486,7 @@ export type TaskMessage = { error: null | undefined | { title: string; description: string }; log: string[]; reset?: boolean; + actions: TaskActionLink[]; }; export type PeerImpl = { @@ -471,6 +500,7 @@ export interface BuildTask { name: string; run: (context: TaskRunContext) => Promise; acls: GlobalACL[]; + initialActions?: TaskActionLink[]; } interface DropTask { @@ -519,6 +549,7 @@ export function defineDropTask(buildTask: BuildTask): DropTask { name: buildTask.name, run: buildTask.run, acls: buildTask.acls, + initialActions: buildTask.initialActions ?? [], }), }; } diff --git a/server/internal/tasks/registry/objects.ts b/server/internal/tasks/registry/objects.ts index 63a5e71..eee05eb 100644 --- a/server/internal/tasks/registry/objects.ts +++ b/server/internal/tasks/registry/objects.ts @@ -11,7 +11,7 @@ type FieldReferenceMap = { }; export default defineDropTask({ - buildId: () => `cleanup:objects:${new Date().toISOString()}`, + buildId: () => `cleanup:objects:${Date.now()}`, name: "Cleanup Objects", acls: ["system:maintenance:read"], taskGroup: "cleanup:objects", diff --git a/server/plugins/03.metadata-init.ts b/server/plugins/03.metadata-init.ts index 7a289ca..8b8e2c5 100644 --- a/server/plugins/03.metadata-init.ts +++ b/server/plugins/03.metadata-init.ts @@ -1,7 +1,6 @@ import { applicationSettings } from "../internal/config/application-configuration"; import type { MetadataProvider } from "../internal/metadata"; import metadataHandler from "../internal/metadata"; -import { GiantBombProvider } from "../internal/metadata/giantbomb"; import { IGDBProvider } from "../internal/metadata/igdb"; import { ManualMetadataProvider } from "../internal/metadata/manual"; import { PCGamingWikiProvider } from "../internal/metadata/pcgamingwiki"; @@ -10,7 +9,7 @@ import { logger } from "~/server/internal/logging"; export default defineNitroPlugin(async (_nitro) => { const metadataProviders = [ - GiantBombProvider, + //GiantBombProvider, // GiantBomb changed their API SteamProvider, PCGamingWikiProvider, IGDBProvider, diff --git a/server/plugins/05.library-init.ts b/server/plugins/05.library-init.ts index 9891b51..04a1349 100644 --- a/server/plugins/05.library-init.ts +++ b/server/plugins/05.library-init.ts @@ -1,11 +1,9 @@ -import { LibraryBackend } from "~/prisma/client/enums"; +import type { LibraryBackend } from "~/prisma/client/enums"; import prisma from "../internal/db/database"; import type { JsonValue } from "@prisma/client/runtime/library"; import type { LibraryProvider } from "../internal/library/provider"; -import type { FilesystemProviderConfig } from "../internal/library/providers/filesystem"; import { FilesystemProvider } from "../internal/library/providers/filesystem"; import libraryManager from "../internal/library"; -import path from "path"; import { FlatFilesystemProvider } from "../internal/library/providers/flat"; import { logger } from "~/server/internal/logging"; @@ -33,42 +31,6 @@ export default defineNitroPlugin(async () => { let successes = 0; const libraries = await prisma.library.findMany({}); - // Add migration handler - const legacyPath = process.env.LIBRARY; - if (legacyPath && libraries.length == 0) { - const options: typeof FilesystemProviderConfig.infer = { - baseDir: path.resolve(legacyPath), - }; - - const library = await prisma.library.create({ - data: { - name: "Auto-created", - backend: LibraryBackend.Filesystem, - options, - }, - }); - - libraries.push(library); - - // Update all existing games - await prisma.game.updateMany({ - where: { - libraryId: null, - }, - data: { - libraryId: library.id, - }, - }); - } - - // Delete all games that don't have a library provider after the legacy handler - // (leftover from a bug) - await prisma.game.deleteMany({ - where: { - libraryId: null, - }, - }); - for (const library of libraries) { const constructor = libraryConstructors[library.backend]; try { diff --git a/server/plugins/06.service-spinup.ts b/server/plugins/06.service-spinup.ts new file mode 100644 index 0000000..391932c --- /dev/null +++ b/server/plugins/06.service-spinup.ts @@ -0,0 +1,14 @@ +import serviceManager from "../internal/services"; +import { NGINX_SERVICE } from "../internal/services/services/nginx"; +import { TORRENTIAL_SERVICE } from "../internal/services/services/torrential"; + +export default defineNitroPlugin(async (nitro) => { + TORRENTIAL_SERVICE.register(); + NGINX_SERVICE.register(); + + serviceManager.spin(); + + nitro.hooks.hookOnce("close", async () => { + serviceManager.kill(); + }); +}); diff --git a/server/routes/auth/callback/oidc.get.ts b/server/routes/auth/callback/oidc.get.ts index 5aba8fd..ac2bb7d 100644 --- a/server/routes/auth/callback/oidc.get.ts +++ b/server/routes/auth/callback/oidc.get.ts @@ -38,7 +38,16 @@ export default defineEventHandler(async (h3) => { statusMessage: `Failed to sign in: "${result}". Please try again.`, }); - await sessionHandler.signin(h3, result.user.id, true); + const sessionResult = await sessionHandler.signin(h3, result.user.id, true); + if (sessionResult == "fail") + throw createError({ statusCode: 500, message: "Failed to set session" }); + + if (sessionResult == "2fa") { + return sendRedirect( + h3, + `/auth/mfa?redirect=${result.options.redirect ? encodeURIComponent(result.options.redirect) : "/"}`, + ); + } if (result.options.redirect) { return sendRedirect(h3, result.options.redirect); diff --git a/server/tasks/downloadCleanup.ts b/server/tasks/downloadCleanup.ts deleted file mode 100644 index 7509531..0000000 --- a/server/tasks/downloadCleanup.ts +++ /dev/null @@ -1,11 +0,0 @@ -import contextManager from "../internal/downloads/coordinator"; - -export default defineTask({ - meta: { - name: "downloadCleanup", - }, - async run() { - await contextManager.cleanup(); - return { result: true }; - }, -}); diff --git a/server/tsconfig.json b/server/tsconfig.json index ca5c731..2f85657 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -1,6 +1,6 @@ { - "extends": "../.nuxt/tsconfig.server.json", "compilerOptions": { "exactOptionalPropertyTypes": true - } + }, + "extends": "../.nuxt/tsconfig.server.json" } diff --git a/torrential b/torrential new file mode 160000 index 0000000..57f0b9b --- /dev/null +++ b/torrential @@ -0,0 +1 @@ +Subproject commit 57f0b9b548b8fe8750e9250c5ee7bfb936764075 diff --git a/tsconfig.json b/tsconfig.json index 3d75e0f..eeaba1a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,8 @@ { - // https://nuxt.com/docs/guide/concepts/typescript - "extends": "./.nuxt/tsconfig.json", "compilerOptions": { - "exactOptionalPropertyTypes": false, - "allowImportingTsExtensions": true - } + "allowImportingTsExtensions": true, + "exactOptionalPropertyTypes": false + }, + // https://nuxt.com/docs/guide/concepts/typescript + "extends": "./.nuxt/tsconfig.json" }
+
{{ game.mShortDescription }}
- {{ $t("library.admin.metadata.companies.addGame.noGames") }} -
+ Select a launch option as an executor for your new launch option. +
{{ error?.statusCode }}
{{ $t("errors.occurred") }}
- {{ $t("library.admin.import.version.setupDesc") }} -
+ {{ $t("library.admin.import.version.setupDesc") }} +
- {{ $t("library.admin.import.version.launchDesc") }} -
+ {{ $t("library.admin.import.version.launchDesc") }} +
+ {{ serviceMetadata[service.name].description }} +
+ Two-factor authentication is enabled on your account. Choose one of the + options below to continue. +
+ Use a one-time code to sign in to your Drop account. +
+ Use a passkey, like biometrics, a hardware security device, or other + compatible device to sign in to your Drop account. +
- {{ $t("auth.signin.noAccount") }} + {{ + superlevel + ? "We need you to sign in again for security reasons while attempting to access more sensitive actions." + : $t("auth.signin.noAccount") + }}
{{ $t("setup.welcomeDescription") }}