From 63ac2b8ffced42c4e0d7c87e724b6d25739c851f Mon Sep 17 00:00:00 2001 From: DecDuck Date: Tue, 13 Jan 2026 15:32:39 +1100 Subject: [PATCH] Depot API & v4 (#298) * feat: nginx + torrential basics & services system * fix: lint + i18n * fix: update torrential to remove openssl * feat: add torrential to Docker build * feat: move to self hosted runner * fix: move off self-hosted runner * fix: update nginx.conf * feat: torrential cache invalidation * fix: update torrential for cache invalidation * feat: integrity check task * fix: lint * feat: move to version ids * fix: client fixes and client-side checks * feat: new depot apis and version id fixes * feat: update torrential * feat: droplet bump and remove unsafe update functions * fix: lint * feat: v4 featureset: emulators, multi-launch commands * fix: lint * fix: mobile ui for game editor * feat: launch options * fix: lint * fix: remove axios, use $fetch * feat: metadata and task api improvements * feat: task actions * fix: slight styling issue * feat: fix style and lints * feat: totp backend routes * feat: oidc groups * fix: update drop-base * feat: creation of passkeys & totp * feat: totp signin * feat: webauthn mfa/signin * feat: launch selecting ui * fix: manually running tasks * feat: update add company game modal to use new SelectorGame * feat: executor selector * fix(docker): update rust to rust nightly for torrential build (#305) * feat: new version ui * feat: move package lookup to build time to allow for deno dev * fix: lint * feat: localisation cleanup * feat: apply localisation cleanup * feat: potential i18n refactor logic * feat: remove args from commands * fix: lint * fix: lockfile --------- Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- .gitmodules | 3 + .prettierignore | 2 + .prettierrc.json | 5 + .vscode/settings.json | 46 +- Dockerfile | 33 +- app.vue | 15 +- build/nginx.conf | 41 ++ components/AccountSidebar.vue | 29 +- components/Auth/Simple.vue | 57 +- components/CodeInput.vue | 86 +++ components/ExecutorWidget.vue | 41 ++ components/GameEditor/Metadata.vue | 18 +- components/GameEditor/Version.vue | 260 +++++--- components/GameEditor/VersionConfig.vue | 58 ++ components/ImportVersionLaunchRow.vue | 253 ++++++++ components/Modal/AddCompanyGame.vue | 93 +-- components/Modal/SelectLaunch.vue | 229 +++++++ components/NotificationItem.vue | 1 - components/Selector/Combox.vue | 91 +++ components/Selector/Game.vue | 132 ++++ .../Language.vue} | 2 +- .../LanguageListbox.vue} | 0 .../MultiItem.vue} | 2 +- .../Platform.vue} | 9 +- components/SourceTable.vue | 8 +- components/StoreView.vue | 16 +- components/TaskWidget.vue | 9 + components/UserFooter.vue | 2 +- composables/frontend.d.ts | 22 + composables/icons.ts | 8 +- composables/kjua.d.ts | 1 + composables/request.ts | 16 +- composables/task.ts | 1 + composables/types.ts | 6 - composables/user.ts | 9 + deploy-template/compose.yml | 2 - drop-base | 2 +- drop.session.sql | 1 + error.vue | 19 +- i18n/locales/de.json | 4 +- i18n/locales/en_au.json | 6 +- i18n/locales/en_pirate.json | 4 +- i18n/locales/en_us.json | 173 +++-- i18n/locales/fr.json | 4 +- i18n/scripts/detect-keys.ts | 32 + i18n/scripts/rewrite-keys.ts | 54 ++ i18n/scripts/utils.ts | 122 ++++ layouts/default.vue | 8 +- nuxt.config.ts | 20 +- package.json | 13 +- pages/account/security.vue | 231 ++++++- pages/admin/library/[id]/import.vue | 556 ++++------------ pages/admin/library/[id]/index.vue | 8 +- pages/admin/settings.vue | 7 + pages/admin/settings/services.vue | 91 +++ pages/admin/settings/tokens.vue | 1 - pages/admin/task/[id]/index.vue | 19 +- pages/admin/task/index.vue | 4 +- pages/auth/mfa.vue | 40 ++ pages/auth/mfa/index.vue | 65 ++ pages/auth/mfa/totp.vue | 78 +++ pages/auth/mfa/webauthn.vue | 92 +++ pages/auth/signin.vue | 19 +- pages/client/code/index.vue | 63 +- pages/library/collection/[id]/index.vue | 13 - pages/library/index.vue | 10 - pages/mfa/setup/successful.vue | 32 + pages/mfa/setup/totp.vue | 91 +++ pages/mfa/setup/webauthn.vue | 133 ++++ pages/news/[id]/index.vue | 13 - pages/setup.vue | 2 +- pages/store/[id]/index.vue | 49 +- pnpm-lock.yaml | 611 +++++++++++++----- pnpm-workspace.yaml | 4 + .../migration.sql | 28 + .../20251218081603_add_depots/migration.sql | 14 + .../migration.sql | 60 ++ .../migration.sql | 14 + .../20251220103245_multi_setups/migration.sql | 26 + .../migration.sql | 23 + .../migration.sql | 8 + .../20251230040838_add_mfa/migration.sql | 22 + .../migration.sql | 8 + .../migration.sql | 8 + .../migrations/20260111022718_/migration.sql | 11 + .../migration.sql | 24 + prisma/models/app.prisma | 6 + prisma/models/auth.prisma | 22 +- prisma/models/content.prisma | 67 +- prisma/models/task.prisma | 2 + prisma/models/user.prisma | 4 +- rules/no-prisma-delete.mts | 8 +- .../api/v1/admin/company/[id]/banner.post.ts | 24 +- .../api/v1/admin/company/[id]/game.delete.ts | 9 + .../api/v1/admin/company/[id]/game.patch.ts | 5 + server/api/v1/admin/company/[id]/game.post.ts | 9 + server/api/v1/admin/company/[id]/icon.post.ts | 25 +- .../api/v1/admin/company/[id]/index.patch.ts | 18 +- server/api/v1/admin/depot/index.post.ts | 21 + server/api/v1/admin/depot/manifest.get.ts | 59 ++ server/api/v1/admin/depot/versions.get.ts | 19 + server/api/v1/admin/game/[id]/index.get.ts | 107 ++- server/api/v1/admin/game/[id]/index.patch.ts | 19 +- .../api/v1/admin/game/[id]/metadata.post.ts | 17 +- server/api/v1/admin/game/[id]/tags.patch.ts | 8 + .../versions}/index.delete.ts | 7 +- .../v1/admin/game/[id]/versions/index.get.ts | 35 + .../admin/game/[id]/versions/index.patch.ts | 67 ++ .../api/v1/admin/game/image/index.delete.ts | 32 +- server/api/v1/admin/game/image/index.post.ts | 20 +- .../api/v1/admin/game/version/index.patch.ts | 72 --- .../api/v1/admin/import/version/index.post.ts | 91 ++- .../v1/admin/import/version/preload.get.ts | 27 +- .../v1/admin/library/sources/index.patch.ts | 44 +- server/api/v1/admin/search/game.get.ts | 39 ++ server/api/v1/admin/services/index.get.ts | 10 + server/api/v1/auth/mfa/index.get.ts | 22 + server/api/v1/auth/mfa/totp.post.ts | 48 ++ .../api/v1/auth/mfa/webauthn/finish.post.ts | 108 ++++ server/api/v1/auth/mfa/webauthn/start.post.ts | 49 ++ server/api/v1/auth/passkey/finish.post.ts | 105 +++ server/api/v1/auth/passkey/start.post.ts | 26 + server/api/v1/auth/signin/simple.post.ts | 23 +- .../api/v1/client/auth/callback/index.post.ts | 7 +- server/api/v1/client/auth/code/index.get.ts | 2 +- server/api/v1/client/auth/code/index.post.ts | 4 +- server/api/v1/client/auth/index.get.ts | 12 +- server/api/v1/client/auth/initiate.post.ts | 29 +- server/api/v1/client/capability/index.post.ts | 7 +- server/api/v1/client/chunk.get.ts | 83 --- .../version/[versionid]/index.get.ts} | 19 +- server/api/v1/client/game/manifest.get.ts | 9 +- server/api/v1/client/game/versions.get.ts | 4 + server/api/v1/client/user/webtoken.post.ts | 1 + server/api/v1/depot/STUB.md | 3 + server/api/v1/games/[id]/index.get.ts | 7 +- server/api/v1/notifications/[id]/read.post.ts | 20 +- server/api/v1/store/index.get.ts | 14 +- server/api/v1/user/auth/index.get.ts | 19 + server/api/v1/user/mfa/index.get.ts | 38 ++ server/api/v1/user/mfa/totp/finish.post.ts | 61 ++ server/api/v1/user/mfa/totp/start.post.ts | 63 ++ .../api/v1/user/mfa/webauthn/finish.post.ts | 110 ++++ server/api/v1/user/mfa/webauthn/start.post.ts | 56 ++ server/api/v1/user/superlevel.ts | 6 + server/api/v2/client/chunk.post.ts | 95 --- server/api/v2/client/context.post.ts | 22 - server/internal/acls/descriptions.ts | 3 + server/internal/acls/index.ts | 41 +- server/internal/auth/base32/index.d.ts | 2 + server/internal/auth/base32/index.js | 69 ++ server/internal/auth/index.ts | 2 +- server/internal/auth/oidc/index.ts | 25 +- server/internal/auth/totp.ts | 22 + server/internal/auth/webauthn.ts | 128 ++++ server/internal/clients/ca-store.ts | 20 +- server/internal/clients/capabilities.ts | 26 +- server/internal/clients/event-handler.ts | 3 +- server/internal/downloads/coordinator.ts | 68 -- server/internal/downloads/manifest.ts | 117 ---- server/internal/gamesize/index.ts | 40 +- server/internal/library/index.ts | 139 ++-- server/internal/library/manifest.ts | 27 + .../internal/library/providers/filesystem.ts | 30 +- server/internal/library/providers/flat.ts | 29 +- server/internal/metadata/giantbomb.ts | 29 +- server/internal/metadata/igdb.ts | 77 +-- server/internal/metadata/index.ts | 24 +- server/internal/metadata/pcgamingwiki.ts | 40 +- server/internal/metadata/steam.ts | 134 ++-- server/internal/metadata/types.d.ts | 3 +- server/internal/news/index.ts | 10 +- server/internal/saves/index.ts | 26 +- server/internal/services/index.ts | 162 +++++ server/internal/services/services/nginx.ts | 22 + .../internal/services/services/torrential.ts | 62 ++ server/internal/session/db.ts | 14 +- server/internal/session/index.ts | 99 ++- server/internal/session/types.d.ts | 10 +- server/internal/tasks/index.ts | 45 +- server/internal/tasks/registry/objects.ts | 2 +- server/plugins/03.metadata-init.ts | 3 +- server/plugins/05.library-init.ts | 40 +- server/plugins/06.service-spinup.ts | 14 + server/routes/auth/callback/oidc.get.ts | 11 +- server/tasks/downloadCleanup.ts | 11 - server/tsconfig.json | 4 +- torrential | 1 + tsconfig.json | 10 +- 190 files changed, 5848 insertions(+), 2309 deletions(-) create mode 100644 .prettierrc.json create mode 100644 build/nginx.conf create mode 100644 components/CodeInput.vue create mode 100644 components/ExecutorWidget.vue create mode 100644 components/GameEditor/VersionConfig.vue create mode 100644 components/ImportVersionLaunchRow.vue create mode 100644 components/Modal/SelectLaunch.vue create mode 100644 components/Selector/Combox.vue create mode 100644 components/Selector/Game.vue rename components/{LanguageSelector.vue => Selector/Language.vue} (95%) rename components/{LanguageSelectorListbox.vue => Selector/LanguageListbox.vue} (100%) rename components/{MultiItemSelector.vue => Selector/MultiItem.vue} (97%) rename components/{PlatformSelector.vue => Selector/Platform.vue} (92%) create mode 100644 composables/frontend.d.ts create mode 100644 composables/kjua.d.ts create mode 100644 drop.session.sql create mode 100644 i18n/scripts/detect-keys.ts create mode 100644 i18n/scripts/rewrite-keys.ts create mode 100644 i18n/scripts/utils.ts create mode 100644 pages/admin/settings/services.vue create mode 100644 pages/auth/mfa.vue create mode 100644 pages/auth/mfa/index.vue create mode 100644 pages/auth/mfa/totp.vue create mode 100644 pages/auth/mfa/webauthn.vue create mode 100644 pages/mfa/setup/successful.vue create mode 100644 pages/mfa/setup/totp.vue create mode 100644 pages/mfa/setup/webauthn.vue create mode 100644 prisma/migrations/20251210231153_move_to_version_id/migration.sql create mode 100644 prisma/migrations/20251218081603_add_depots/migration.sql create mode 100644 prisma/migrations/20251220094201_add_launch_configurations_and_emulation_targets/migration.sql create mode 100644 prisma/migrations/20251220095108_make_setup_optional/migration.sql create mode 100644 prisma/migrations/20251220103245_multi_setups/migration.sql create mode 100644 prisma/migrations/20251220230511_add_cascading_deletions/migration.sql create mode 100644 prisma/migrations/20251230030847_add_task_actions/migration.sql create mode 100644 prisma/migrations/20251230040838_add_mfa/migration.sql create mode 100644 prisma/migrations/20260104040733_make_session_userids_optional/migration.sql create mode 100644 prisma/migrations/20260104074505_add_trgm_index_for_game_m_name/migration.sql create mode 100644 prisma/migrations/20260111022718_/migration.sql create mode 100644 prisma/migrations/20260111054324_remove_args_from_commands/migration.sql create mode 100644 server/api/v1/admin/depot/index.post.ts create mode 100644 server/api/v1/admin/depot/manifest.get.ts create mode 100644 server/api/v1/admin/depot/versions.get.ts rename server/api/v1/admin/game/{version => [id]/versions}/index.delete.ts (83%) create mode 100644 server/api/v1/admin/game/[id]/versions/index.get.ts create mode 100644 server/api/v1/admin/game/[id]/versions/index.patch.ts delete mode 100644 server/api/v1/admin/game/version/index.patch.ts create mode 100644 server/api/v1/admin/search/game.get.ts create mode 100644 server/api/v1/admin/services/index.get.ts create mode 100644 server/api/v1/auth/mfa/index.get.ts create mode 100644 server/api/v1/auth/mfa/totp.post.ts create mode 100644 server/api/v1/auth/mfa/webauthn/finish.post.ts create mode 100644 server/api/v1/auth/mfa/webauthn/start.post.ts create mode 100644 server/api/v1/auth/passkey/finish.post.ts create mode 100644 server/api/v1/auth/passkey/start.post.ts delete mode 100644 server/api/v1/client/chunk.get.ts rename server/api/v1/client/game/{version.get.ts => [id]/version/[versionid]/index.get.ts} (66%) create mode 100644 server/api/v1/depot/STUB.md create mode 100644 server/api/v1/user/auth/index.get.ts create mode 100644 server/api/v1/user/mfa/index.get.ts create mode 100644 server/api/v1/user/mfa/totp/finish.post.ts create mode 100644 server/api/v1/user/mfa/totp/start.post.ts create mode 100644 server/api/v1/user/mfa/webauthn/finish.post.ts create mode 100644 server/api/v1/user/mfa/webauthn/start.post.ts create mode 100644 server/api/v1/user/superlevel.ts delete mode 100644 server/api/v2/client/chunk.post.ts delete mode 100644 server/api/v2/client/context.post.ts create mode 100644 server/internal/auth/base32/index.d.ts create mode 100644 server/internal/auth/base32/index.js create mode 100644 server/internal/auth/totp.ts create mode 100644 server/internal/auth/webauthn.ts delete mode 100644 server/internal/downloads/coordinator.ts delete mode 100644 server/internal/downloads/manifest.ts create mode 100644 server/internal/library/manifest.ts create mode 100644 server/internal/services/index.ts create mode 100644 server/internal/services/services/nginx.ts create mode 100644 server/internal/services/services/torrential.ts create mode 100644 server/plugins/06.service-spinup.ts delete mode 100644 server/tasks/downloadCleanup.ts create mode 160000 torrential 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 @@ + + + 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 @@ + + + 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 @@ diff --git a/pages/mfa/setup/totp.vue b/pages/mfa/setup/totp.vue new file mode 100644 index 0000000..3ee6c2d --- /dev/null +++ b/pages/mfa/setup/totp.vue @@ -0,0 +1,91 @@ + + + diff --git a/pages/mfa/setup/webauthn.vue b/pages/mfa/setup/webauthn.vue new file mode 100644 index 0000000..4cf1222 --- /dev/null +++ b/pages/mfa/setup/webauthn.vue @@ -0,0 +1,133 @@ + + + diff --git a/pages/news/[id]/index.vue b/pages/news/[id]/index.vue index 17837d3..f630868 100644 --- a/pages/news/[id]/index.vue +++ b/pages/news/[id]/index.vue @@ -152,16 +152,3 @@ useHead({ border-radius: 0.5rem; } - - 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 @@
-
- -
@@ -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" }