From bcf7802e3ca0d558ca3a8a977c9f386afc26cc39 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Mon, 1 Dec 2025 20:56:50 +1100 Subject: [PATCH 01/47] feat: nginx + torrential basics & services system --- build/nginx.conf | 39 +++++ components/SourceTable.vue | 3 +- pages/admin/settings.vue | 7 + pages/admin/settings/services.vue | 90 +++++++++++ pages/admin/task/index.vue | 8 +- server/api/v1/admin/depot/context.get.ts | 31 ++++ .../api/v1/admin/library/sources/index.get.ts | 1 + server/api/v1/admin/services/index.get.ts | 10 ++ server/api/v1/depot/STUB.md | 2 + server/internal/acls/descriptions.ts | 1 + server/internal/acls/index.ts | 1 + server/internal/auth/index.ts | 2 +- server/internal/services/index.ts | 150 ++++++++++++++++++ server/internal/services/services/nginx.ts | 22 +++ .../internal/services/services/torrential.ts | 48 ++++++ server/internal/tasks/group.ts | 3 + server/internal/tasks/index.ts | 1 - server/plugins/06.service-spinup.ts | 14 ++ 18 files changed, 427 insertions(+), 6 deletions(-) create mode 100644 build/nginx.conf create mode 100644 pages/admin/settings/services.vue create mode 100644 server/api/v1/admin/depot/context.get.ts create mode 100644 server/api/v1/admin/services/index.get.ts create mode 100644 server/api/v1/depot/STUB.md 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 diff --git a/build/nginx.conf b/build/nginx.conf new file mode 100644 index 00000000..ab6fda69 --- /dev/null +++ b/build/nginx.conf @@ -0,0 +1,39 @@ +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; + } + } +} diff --git a/components/SourceTable.vue b/components/SourceTable.vue index e9491e56..c1aa0d74 100644 --- a/components/SourceTable.vue +++ b/components/SourceTable.vue @@ -59,7 +59,6 @@ = [ @@ -57,6 +58,12 @@ const navigation: Array = [ prefix: "/admin/settings/tokens", icon: CodeBracketIcon, }, + { + label: "Services", + route: "/admin/settings/services", + prefix: "/admin/settings/services", + icon: ServerIcon, + }, ]; // const notifications = useNotifications(); diff --git a/pages/admin/settings/services.vue b/pages/admin/settings/services.vue new file mode 100644 index 00000000..69a070d8 --- /dev/null +++ b/pages/admin/settings/services.vue @@ -0,0 +1,90 @@ + + + diff --git a/pages/admin/task/index.vue b/pages/admin/task/index.vue index 22068de0..54f656dc 100644 --- a/pages/admin/task/index.vue +++ b/pages/admin/task/index.vue @@ -164,9 +164,13 @@ const scheduledTasks: { description: "", }, debug: { - name: "Debug Task", - description: "Does debugging things.", + name: "", + description: "", }, + "export:game": { + name: "", + description: "" + } }; async function startTask(taskGroup: string) { diff --git a/server/api/v1/admin/depot/context.get.ts b/server/api/v1/admin/depot/context.get.ts new file mode 100644 index 00000000..173aca09 --- /dev/null +++ b/server/api/v1/admin/depot/context.get.ts @@ -0,0 +1,31 @@ +import { ArkErrors, type } from "arktype"; +import aclManager from "~/server/internal/acls"; +import contextManager from "~/server/internal/downloads/coordinator"; + +const Query = type({ + game: "string", + version: "string", +}); + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["depot"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const query = Query(getQuery(h3)); + if (query instanceof ArkErrors) + throw createError({ statusCode: 400, message: query.summary }); + + const contextId = await contextManager.createContext( + query.game, + query.version, + ); + if (!contextId) + throw createError({ + statusCode: 400, + statusMessage: "Invalid game or version", + }); + + const contextObject = await contextManager.fetchContext(contextId); + + return contextObject!; +}); diff --git a/server/api/v1/admin/library/sources/index.get.ts b/server/api/v1/admin/library/sources/index.get.ts index 25b0f0b3..c0216648 100644 --- a/server/api/v1/admin/library/sources/index.get.ts +++ b/server/api/v1/admin/library/sources/index.get.ts @@ -11,6 +11,7 @@ export default defineEventHandler(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, [ "library:sources:read", "setup", + "depot" ]); if (!allowed) throw createError({ statusCode: 403 }); 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 00000000..4232f22a --- /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/depot/STUB.md b/server/api/v1/depot/STUB.md new file mode 100644 index 00000000..ecc574ac --- /dev/null +++ b/server/api/v1/depot/STUB.md @@ -0,0 +1,2 @@ +# Don't add anything here +This route is overriden by the reverse proxy, and forwarded to the Rust depot. diff --git a/server/internal/acls/descriptions.ts b/server/internal/acls/descriptions.ts index af1a007b..3c0c10e9 100644 --- a/server/internal/acls/descriptions.ts +++ b/server/internal/acls/descriptions.ts @@ -46,6 +46,7 @@ export const userACLDescriptions: ObjectFromList = { export const systemACLDescriptions: ObjectFromList = { setup: "All permissions required to setup a new Drop instance (setup wizard).", + depot: "All permissions required to use a download depot with Drop.", "auth:read": "Fetch the list of enabled authentication mechanisms configured.", diff --git a/server/internal/acls/index.ts b/server/internal/acls/index.ts index d6997c5b..ce4c7455 100644 --- a/server/internal/acls/index.ts +++ b/server/internal/acls/index.ts @@ -42,6 +42,7 @@ export type UserACL = Array<(typeof userACLs)[number]>; export const systemACLs = [ "setup", + "depot", "auth:read", "auth:simple:invitation:read", diff --git a/server/internal/auth/index.ts b/server/internal/auth/index.ts index ee9cadc9..fa8a0fa1 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/services/index.ts b/server/internal/services/index.ts new file mode 100644 index 00000000..9f6b1aac --- /dev/null +++ b/server/internal/services/index.ts @@ -0,0 +1,150 @@ +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 { + private 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; + + constructor( + name: string, + executor: Executor, + setup?: Setup, + healthcheck?: Healthcheck, + ) { + this.name = name; + const serviceLogger = logger.child({ name: `service-${name}` }); + this.logger = serviceLogger; + this.executor = executor; + this.setup = setup; + this.healthcheck = healthcheck; + } + + 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 process = this.executor(); + this.logger.info("service launched"); + process.on("close", async (code, signal) => { + process.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(); + }); + process.stdout?.on("data", (data) => + this.logger.info(data.toString().trim()), + ); + process.stderr?.on("data", (data) => + this.logger.error(data.toString().trim()), + ); + this.currentProcess = process; + 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; + } +} + +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 00000000..ed557859 --- /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 00000000..f216a7eb --- /dev/null +++ b/server/internal/services/services/torrential.ts @@ -0,0 +1,48 @@ +import { spawn } from "child_process"; +import { Service } from ".."; +import fs from "fs"; +import prisma from "../../db/database"; +import { APITokenMode } from "~/prisma/client/enums"; + +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 token = await prisma.aPIToken.upsert({ + where: { + id: "torrential", + }, + update: { + name: "Torrential token", + acls: ["depot"], + }, + create: { + id: "torrential", + name: "Torrential token", + acls: ["depot"], + mode: APITokenMode.System, + }, + }); + + await $fetch(`${INTERNAL_DEPOT_URL.toString()}token`, { + method: "POST", + body: { token: token.token }, + }); + return true; + }, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + async () => await $fetch(`${INTERNAL_DEPOT_URL.toString()}healthcheck`), +); diff --git a/server/internal/tasks/group.ts b/server/internal/tasks/group.ts index f914272a..107793ce 100644 --- a/server/internal/tasks/group.ts +++ b/server/internal/tasks/group.ts @@ -17,6 +17,9 @@ export const taskGroups = { debug: { concurrency: true, }, + "export:game": { + concurrency: true, + }, } as const; export type TaskGroup = keyof typeof taskGroups; diff --git a/server/internal/tasks/index.ts b/server/internal/tasks/index.ts index 54433a99..08751cd2 100644 --- a/server/internal/tasks/index.ts +++ b/server/internal/tasks/index.ts @@ -53,7 +53,6 @@ class TaskHandler { "cleanup:invitations", "cleanup:sessions", "check:update", - "debug", ]; private weeklyScheduledTasks: TaskGroup[] = ["cleanup:objects"]; diff --git a/server/plugins/06.service-spinup.ts b/server/plugins/06.service-spinup.ts new file mode 100644 index 00000000..391932c4 --- /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(); + }); +}); From 0bd0a4a874a6c92e070336e441e8f5522d81105e Mon Sep 17 00:00:00 2001 From: DecDuck Date: Mon, 1 Dec 2025 21:00:35 +1100 Subject: [PATCH 02/47] fix: lint + i18n --- components/SourceTable.vue | 5 +- i18n/locales/en_us.json | 94 ++++++++++--------- pages/admin/settings/services.vue | 15 +-- pages/admin/task/index.vue | 4 +- .../api/v1/admin/library/sources/index.get.ts | 2 +- server/api/v1/depot/STUB.md | 1 + 6 files changed, 64 insertions(+), 57 deletions(-) diff --git a/components/SourceTable.vue b/components/SourceTable.vue index c1aa0d74..b794eebd 100644 --- a/components/SourceTable.vue +++ b/components/SourceTable.vue @@ -56,10 +56,7 @@ - + diff --git a/i18n/locales/en_us.json b/i18n/locales/en_us.json index d167034d..24571883 100644 --- a/i18n/locales/en_us.json +++ b/i18n/locales/en_us.json @@ -11,39 +11,37 @@ }, "notifications": { "all": "View all {arrow}", + "clear": "Clear notifications", "desc": "View and manage your notifications.", "markAllAsRead": "Mark all as read", - "clear": "Clear notifications", "markAsRead": "Mark as read", "none": "No notifications", "notifications": "Notifications", "title": "Notifications", "unread": "Unread Notifications" }, + "settings": "Settings", + "title": "Account Settings", "token": { - "title": "API Tokens", - "subheader": "Manage your API tokens, and what they can access.", - "name": "API token name", - "nameDesc": "The name of the token, for reference.", - "namePlaceholder": "My New Token", "acls": "ACLs/scopes", "aclsDesc": "Defines what this token has the authority to do. You should avoid selecting all ACLs, if they are not necessary.", "expiry": "Expiry", - "noExpiry": "No expiry", - "revoke": "Revoke", - "noTokens": "No tokens connected to your account.", - - "expiryMonth": "A month", "expiry3Month": "3 months", + "expiry5Year": "5 years", "expiry6Month": "6 months", + "expiryMonth": "A month", "expiryYear": "A year", - "expiry5Year": "5 years", - + "name": "API token name", + "nameDesc": "The name of the token, for reference.", + "namePlaceholder": "My New Token", + "noExpiry": "No expiry", + "noTokens": "No tokens connected to your account.", + "revoke": "Revoke", + "subheader": "Manage your API tokens, and what they can access.", "success": "Successfully created token.", - "successNote": "Make sure to copy it now, as it won't be shown again." - }, - "settings": "Settings", - "title": "Account Settings" + "successNote": "Make sure to copy it now, as it won't be shown again.", + "title": "API Tokens" + } }, "actions": "Actions", "add": "Add", @@ -109,7 +107,9 @@ "friends": "Friends", "groups": "Groups", "insert": "Insert", + "labelValueColon": "{label}: {value}", "name": "Name", + "noData": "No data", "noResults": "No results", "noSelected": "No items selected.", "remove": "Remove", @@ -118,9 +118,7 @@ "servers": "Servers", "srLoading": "Loading…", "tags": "Tags", - "today": "Today", - "labelValueColon": "{label}: {value}", - "noData": "No data" + "today": "Today" }, "delete": "Delete", "drop": { @@ -265,14 +263,14 @@ "header": { "admin": { "admin": "Admin", + "home": "Home", + "library": "Library", "metadata": "Meta", "settings": { - "title": "Settings", "store": "Store", + "title": "Settings", "tokens": "API tokens" }, - "home": "Home", - "library": "Library", "tasks": "Tasks", "users": "Users" }, @@ -283,20 +281,20 @@ "highest": "highest", "home": { "admin": { - "title": "Home", - "subheader": "Instance summary", - "games": "Games", - "librarySources": "Library sources", - "version": "Version", "activeInactiveUsers": "Active/inactive users", "activeUsers": "Active users", - "inactiveUsers": "Inactive users", - "goToUsers": "Go to users", - "users": "Users", + "allVersionsCombined": "All versions combined", + "biggestGamesOnServer": "Biggest games on server", "biggestGamesToDownload": "Biggest games to download", + "games": "Games", + "goToUsers": "Go to users", + "inactiveUsers": "Inactive users", "latestVersionOnly": "Latest version only", - "biggestGamesOnServer": "Biggest games on server", - "allVersionsCombined": "All versions combined" + "librarySources": "Library sources", + "subheader": "Instance summary", + "title": "Home", + "users": "Users", + "version": "Version" } }, "library": { @@ -363,6 +361,8 @@ }, "withoutMetadata": "Import without metadata" }, + "libraryHint": "No libraries configured.", + "libraryHintDocsLink": "What does this mean? {arrow}", "metadata": { "companies": { "action": "Manage {arrow}", @@ -421,8 +421,6 @@ }, "metadataProvider": "Metadata provider", "noGames": "No games imported", - "libraryHint": "No libraries configured.", - "libraryHintDocsLink": "What does this mean? {arrow}", "offline": "Drop couldn't access this game.", "offlineTitle": "Game offline", "openEditor": "Open in Editor {arrow}", @@ -434,6 +432,7 @@ "desc": "Configure your library sources, where Drop will look for new games and versions to import.", "documentationLink": "Documentation {arrow}", "edit": "Edit source", + "freeSpace": "Free space", "fsDesc": "Imports games from a path on disk. Requires version-based folder structure, and supports archived games.", "fsFlatDesc": "Imports games from a path on disk, but without a separate version subfolder. Useful when migrating an existing library to Drop.", "fsFlatTitle": "Compatibility", @@ -444,13 +443,12 @@ "link": "Sources {arrow}", "nameDesc": "The name of your source, for reference.", "namePlaceholder": "My New Source", + "percentage": "{number}%", "sources": "Library Sources", - "typeDesc": "The type of your source. Changes the required options.", - "working": "Working?", - "freeSpace": "Free space", "totalSpace": "Total space", + "typeDesc": "The type of your source. Changes the required options.", "utilizationPercentage": "Utilization percentage", - "percentage": "{number}%" + "working": "Working?" }, "subheader": "As you add folders to your library sources, Drop will detect it and prompt you to import it. Each game needs to be imported before you can import a version.", "title": "Libraries", @@ -517,6 +515,16 @@ "options": "Options", "security": "Security", "selectLanguage": "Select language", + "services": { + "nginx": { + "description": "Built-in simple reverse proxy to connect all the Drop components together.", + "title": "NGINX" + }, + "torrential": { + "description": "The internal download server for Drop.", + "title": "Torrential" + } + }, "settings": { "admin": { "description": "Configure Drop settings", @@ -580,7 +588,6 @@ "openFeatured": "Star games in Admin Library {arrow}", "platform": "Platform | Platform | Platforms", "publishers": "Publishers | Publisher | Publishers", - "size": "Size", "rating": "Rating", "readLess": "Click to read less", "readMore": "Click to read more", @@ -589,6 +596,7 @@ "recentlyUpdated": "Recently Updated", "released": "Released", "reviews": "({0} Reviews)", + "size": "Size", "tags": "Tags", "title": "Store", "view": { @@ -605,7 +613,9 @@ "back": "{arrow} Back to Tasks", "completedTasksTitle": "Completed tasks", "dailyScheduledTitle": "Daily scheduled tasks", + "execute": "{arrow} Execute", "noTasksRunning": "No tasks currently running", + "progress": "{0}%", "runningTasksTitle": "Running tasks", "scheduled": { "checkUpdateDescription": "Check if Drop has an update.", @@ -618,9 +628,7 @@ "cleanupSessionsName": "Clean up sessions." }, "viewTask": "View {arrow}", - "weeklyScheduledTitle": "Weekly scheduled tasks", - "progress": "{0}%", - "execute": "{arrow} Execute" + "weeklyScheduledTitle": "Weekly scheduled tasks" } }, "title": "Drop", diff --git a/pages/admin/settings/services.vue b/pages/admin/settings/services.vue index 69a070d8..31397bb1 100644 --- a/pages/admin/settings/services.vue +++ b/pages/admin/settings/services.vue @@ -70,21 +70,22 @@ definePageMeta({ const services = await $dropFetch("/api/v1/admin/services"); -const serviceMetadata = { +const { t } = useI18n(); + +const serviceMetadata = computed(() => ({ torrential: { - title: "Torrential", - description: "The internal download server for Drop.", + title: t("services.torrential.title"), + description: t("services.torrential.description"), iconForeground: "text-blue-400", iconBackground: "bg-blue-500/10", icon: ArrowDownTrayIcon, }, nginx: { - title: "NGINX", - description: - "Built-in simple reverse proxy to connect all the Drop components together.", + title: t("services.nginx.title"), + description: t("services.nginx.description"), iconForeground: "text-green-400", iconBackground: "bg-green-500/10", icon: ArrowDownTrayIcon, }, -}; +})); diff --git a/pages/admin/task/index.vue b/pages/admin/task/index.vue index 54f656dc..a8d25e18 100644 --- a/pages/admin/task/index.vue +++ b/pages/admin/task/index.vue @@ -169,8 +169,8 @@ const scheduledTasks: { }, "export:game": { name: "", - description: "" - } + description: "", + }, }; async function startTask(taskGroup: string) { diff --git a/server/api/v1/admin/library/sources/index.get.ts b/server/api/v1/admin/library/sources/index.get.ts index c0216648..c6f03f47 100644 --- a/server/api/v1/admin/library/sources/index.get.ts +++ b/server/api/v1/admin/library/sources/index.get.ts @@ -11,7 +11,7 @@ export default defineEventHandler(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, [ "library:sources:read", "setup", - "depot" + "depot", ]); if (!allowed) throw createError({ statusCode: 403 }); diff --git a/server/api/v1/depot/STUB.md b/server/api/v1/depot/STUB.md index ecc574ac..c461524d 100644 --- a/server/api/v1/depot/STUB.md +++ b/server/api/v1/depot/STUB.md @@ -1,2 +1,3 @@ # Don't add anything here + This route is overriden by the reverse proxy, and forwarded to the Rust depot. From fe6aff06f6bf4675f71dea65dfecad0e334514df Mon Sep 17 00:00:00 2001 From: DecDuck Date: Mon, 1 Dec 2025 21:29:53 +1100 Subject: [PATCH 03/47] fix: update torrential to remove openssl --- .gitmodules | 3 +++ torrential | 1 + 2 files changed, 4 insertions(+) create mode 160000 torrential diff --git a/.gitmodules b/.gitmodules index e24bb0cb..07aa3daf 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/torrential b/torrential new file mode 160000 index 00000000..49607d08 --- /dev/null +++ b/torrential @@ -0,0 +1 @@ +Subproject commit 49607d08a9bf7ef6c547a5410fa8816c8ea0da3f From 45f3216b64f1d55ee04829db6c0bd207c5437e78 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Mon, 1 Dec 2025 21:33:16 +1100 Subject: [PATCH 04/47] feat: add torrential to Docker build --- Dockerfile | 33 ++++++++++++++++++++++----------- deploy-template/compose.yml | 2 -- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5003fb43..06396a7c 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 rust:1.91.1-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/deploy-template/compose.yml b/deploy-template/compose.yml index 16ca2f49..423c7de4 100644 --- a/deploy-template/compose.yml +++ b/deploy-template/compose.yml @@ -2,8 +2,6 @@ services: postgres: # using alpine image to reduce image size image: postgres:alpine - ports: - - 5432:5432 healthcheck: test: pg_isready -d drop -U drop interval: 30s From 11a00fb377fbc3e7d8ab5c1c2e47de5ad5b97c6f Mon Sep 17 00:00:00 2001 From: DecDuck Date: Mon, 1 Dec 2025 22:16:47 +1100 Subject: [PATCH 05/47] feat: move to self hosted runner --- .github/workflows/release.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8045e05d..b2b3188e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,6 @@ name: Release Workflow + on: workflow_dispatch: {} release: @@ -10,8 +11,8 @@ on: jobs: web: - name: Push website Docker image to registry - runs-on: ubuntu-latest + name: Build Docker image + runs-on: self-hosted permissions: packages: write contents: read From 85e3e6641aad3ccbae7d2118d2a4e0f7a73a08fd Mon Sep 17 00:00:00 2001 From: DecDuck Date: Tue, 2 Dec 2025 08:00:48 +1100 Subject: [PATCH 06/47] fix: move off self-hosted runner --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b2b3188e..40ca2b5c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ on: jobs: web: name: Build Docker image - runs-on: self-hosted + runs-on: ubuntu-latest permissions: packages: write contents: read From b83d6f679e3f62e726c6650c8a9acadc26e2d000 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Wed, 3 Dec 2025 06:32:23 +1100 Subject: [PATCH 07/47] fix: update nginx.conf --- build/nginx.conf | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build/nginx.conf b/build/nginx.conf index ab6fda69..d5f678d6 100644 --- a/build/nginx.conf +++ b/build/nginx.conf @@ -34,6 +34,8 @@ http { location /api/v1/depot/ { proxy_pass http://localhost:5000; + proxy_read_timeout 300s; + proxy_connect_timeout 75s; } } } From b76d60d841b3fa7a8e421586c43a9a3d4b52b726 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Wed, 3 Dec 2025 13:57:52 +1100 Subject: [PATCH 08/47] feat: torrential cache invalidation --- drop-base | 2 +- server/internal/library/index.ts | 3 ++ server/internal/services/index.ts | 54 +++++++++++-------- .../internal/services/services/torrential.ts | 8 +++ torrential | 2 +- 5 files changed, 46 insertions(+), 23 deletions(-) diff --git a/drop-base b/drop-base index 06bea063..14f4e3e2 160000 --- a/drop-base +++ b/drop-base @@ -1 +1 @@ -Subproject commit 06bea063633ed4bf7513e29ad7149bc067561199 +Subproject commit 14f4e3e20b9abe41d9561d580060cf3578149640 diff --git a/server/internal/library/index.ts b/server/internal/library/index.ts index 4c5b54c7..4e95df52 100644 --- a/server/internal/library/index.ts +++ b/server/internal/library/index.ts @@ -17,6 +17,7 @@ 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"; export function createGameImportTaskId(libraryId: string, libraryPath: string) { return createHash("md5") @@ -347,6 +348,8 @@ class LibraryManager { await libraryManager.cacheCombinedGameSize(gameId); await libraryManager.cacheGameVersionSize(gameId, versionName); + + await TORRENTIAL_SERVICE.utils().invalidate(gameId, versionName); progress(100); }, }); diff --git a/server/internal/services/index.ts b/server/internal/services/index.ts index 9f6b1aac..3f5dba4a 100644 --- a/server/internal/services/index.ts +++ b/server/internal/services/index.ts @@ -3,9 +3,9 @@ import { logger } from "../logging"; import type { Logger } from "pino"; class ServiceManager { - private services: Map = new Map(); + private services: Map> = new Map(); - register(name: string, service: Service) { + register(name: string, service: Service) { this.services.set(name, service); } @@ -32,8 +32,8 @@ class ServiceManager { export type Executor = () => ChildProcess; export type Setup = () => Promise; export type Healthcheck = () => Promise; -export class Service { - private name: string; +export class Service { + name: string; private executor: Executor; private setup: Setup | undefined; private healthcheck: Healthcheck | undefined; @@ -46,11 +46,14 @@ export class Service { 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}` }); @@ -58,6 +61,7 @@ export class Service { this.executor = executor; this.setup = setup; this.healthcheck = healthcheck; + this.uutils = utils!; } spin() { @@ -82,24 +86,28 @@ export class Service { private async launch() { if (this.currentProcess) return; - const process = this.executor(); - this.logger.info("service launched"); - process.on("close", async (code, signal) => { - process.kill(); - this.currentProcess = undefined; - this.logger.warn( - `service exited with code ${code} (${signal}), restarting...`, + const disableEnv = `DISABLE_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()), ); - await new Promise((r) => setTimeout(r, 5000)); - if (this.spun) this.launch(); - }); - process.stdout?.on("data", (data) => - this.logger.info(data.toString().trim()), - ); - process.stderr?.on("data", (data) => - this.logger.error(data.toString().trim()), - ); - this.currentProcess = process; + this.currentProcess = serviceProcess; + } + if (this.setup) { while (true) { try { @@ -144,6 +152,10 @@ export class Service { serviceHealthy() { return this.healthy; } + + utils() { + return this.uutils + } } export const serviceManager = new ServiceManager(); diff --git a/server/internal/services/services/torrential.ts b/server/internal/services/services/torrential.ts index f216a7eb..3ab52898 100644 --- a/server/internal/services/services/torrential.ts +++ b/server/internal/services/services/torrential.ts @@ -45,4 +45,12 @@ export const TORRENTIAL_SERVICE = new Service( // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore async () => await $fetch(`${INTERNAL_DEPOT_URL.toString()}healthcheck`), + { + async invalidate(gameId: string, versionName: string) { + await $fetch(`${INTERNAL_DEPOT_URL.toString()}invalidate`, {method: "POST", body: { + game_id: gameId, + version_name: versionName + }}); + } + } ); diff --git a/torrential b/torrential index 49607d08..28dfa090 160000 --- a/torrential +++ b/torrential @@ -1 +1 @@ -Subproject commit 49607d08a9bf7ef6c547a5410fa8816c8ea0da3f +Subproject commit 28dfa090840fae60c2cdc1957a1e79551a636318 From f1e04e4af0609d22529f26441eed8d2a81a2cf6b Mon Sep 17 00:00:00 2001 From: DecDuck Date: Thu, 4 Dec 2025 20:45:18 +1100 Subject: [PATCH 09/47] fix: update torrential for cache invalidation --- torrential | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/torrential b/torrential index 28dfa090..70f8ab0e 160000 --- a/torrential +++ b/torrential @@ -1 +1 @@ -Subproject commit 28dfa090840fae60c2cdc1957a1e79551a636318 +Subproject commit 70f8ab0ea208481fceb687a8d290d24e278400b1 From 71af776473a00d3b60ea555be3ad6da2fef629cc Mon Sep 17 00:00:00 2001 From: DecDuck Date: Fri, 5 Dec 2025 17:27:01 +1100 Subject: [PATCH 10/47] feat: integrity check task --- package.json | 2 +- pages/admin/task/index.vue | 8 +- pnpm-lock.yaml | 90 ++++++------ server/api/v1/client/chunk.get.ts | 83 ----------- server/api/v2/client/chunk.post.ts | 95 ------------- server/api/v2/client/context.post.ts | 22 --- server/internal/library/index.ts | 4 + .../internal/library/providers/filesystem.ts | 25 +--- server/internal/library/providers/flat.ts | 24 +--- .../internal/services/services/torrential.ts | 20 ++- server/internal/tasks/group.ts | 6 +- server/internal/tasks/index.ts | 4 +- server/internal/tasks/registry/integrity.ts | 134 ++++++++++++++++++ 13 files changed, 220 insertions(+), 297 deletions(-) delete mode 100644 server/api/v1/client/chunk.get.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/tasks/registry/integrity.ts diff --git a/package.json b/package.json index 5873e08b..4a517119 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@discordapp/twemoji": "^16.0.1", - "@drop-oss/droplet": "3.5.0", + "@drop-oss/droplet": "4.0.0", "@headlessui/vue": "^1.7.23", "@heroicons/vue": "^2.1.5", "@lobomfz/prismark": "0.0.3", diff --git a/pages/admin/task/index.vue b/pages/admin/task/index.vue index a8d25e18..bf5d6872 100644 --- a/pages/admin/task/index.vue +++ b/pages/admin/task/index.vue @@ -167,10 +167,10 @@ const scheduledTasks: { name: "", description: "", }, - "export:game": { - name: "", - description: "", - }, + "check:integrity": { + name: "Integrity check", + description: "Checks that all versions match their internal download manifest. Run this task if clients are having issues validating their downloads." + } }; async function startTask(taskGroup: string) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8179aab..1fbb09bd 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: 4.0.0 + version: 4.0.0 '@headlessui/vue': specifier: ^1.7.23 version: 1.7.23(vue@3.5.22(typescript@5.8.3)) @@ -408,67 +408,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@4.0.0': + resolution: {integrity: sha512-SQMfBsALuObZxbT2lQCmZBLd9WlP9USukxRUSa9BleZMaWsHBi7d/VD8unUGxBxROPIEPTWFRl0ItgewOqaumw==} 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@4.0.0': + resolution: {integrity: sha512-awmjFlc1lP0KfucrkwNEKEyByJTUDWLkAqhdM2Dzk4e0l1SVRJcfk0P6LSImFekQp3zY7s0FjDeOfx8SmWWvIA==} 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@4.0.0': + resolution: {integrity: sha512-OHPSe8u73cd/UbhP6hf9wfKktZqZOwC+2h8+e1Ln5fFMZ1WU3JGLZFaI/uk1oy/dRWjVM8EIzdtgC4gN1WtMOw==} 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@4.0.0': + resolution: {integrity: sha512-F563kZRwWHPJ8OrbTiy8mjTcPKkm2ij5fr9dVQVGOEePr1gtvSeV1SbKEhMhOxVUoEBmnv46KgVHi5FL5m19Dg==} 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@4.0.0': + resolution: {integrity: sha512-WbRkkLiEzbMt6FhCPXVOIpfAmKsa1RkUFjyrqMzGt64a5JeNUHrs58iMQwPN/Vllm1IcB73BL8aUfXge0DhJ1A==} 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@4.0.0': + resolution: {integrity: sha512-xOIEyE+fxbRsOzKQV5l9UISIo250BwmCZXGxy2Ch98xx2G3VttsrDqoUuwxKZtD6DjeEGeYLM15tuzf0sTlZbg==} 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@4.0.0': + resolution: {integrity: sha512-igvUuj8GzPzGWTPe0saSyx1ujkmv3W6yDdcqbh0UBR0BMN90nVVNMwPvmwmtdWtfjPRDG7M9S2Reftv6qk2VwA==} 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@4.0.0': + resolution: {integrity: sha512-Q1Fl09TgX69faMbKEoPUuVzCXoNs+01t/jn8FrK6mx5ThU24JYz43f17UIUzhoGHEsnzFQHQDZa4ApztfwjVGQ==} 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@4.0.0': + resolution: {integrity: sha512-3gq2NCDCY7Sm7WdZMPSvD3GXN8oU+FayK6xhFA4i8o8QPVWBGD3imkwt2xzkutXTyTQludZE8Qh5iEFm39Nlrw==} 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@4.0.0': + resolution: {integrity: sha512-gikZlcL9xJY7BpWb+fw+zcWq1cnQNLm+cba2u0YOwa1B/ggzQ/xtyttPeY6s0KDxBD9qsgOIe/SDqCtH19FbcQ==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@drop-oss/droplet@3.5.0': - resolution: {integrity: sha512-FaTwGRl9uWdA1aw/WnbZfyZ7W/b2nEPdBLkdauonQ8OPKqK0k8KgolgyZ87yPFWw0BPyzUyCz4imrmdIIKhFYw==} + '@drop-oss/droplet@4.0.0': + resolution: {integrity: sha512-A/f77qppaMmHj0q2A0Mv3yqPecZnaxauncJgVABAbKEYvHun9vrrC6IaZ16OoFOWdirACU33Odv62dhZcrnz0A==} engines: {node: '>= 10'} '@dxup/nuxt@0.2.2': @@ -7110,48 +7110,48 @@ snapshots: jsonfile: 5.0.0 universalify: 0.1.2 - '@drop-oss/droplet-darwin-arm64@3.5.0': + '@drop-oss/droplet-darwin-arm64@4.0.0': optional: true - '@drop-oss/droplet-darwin-universal@3.5.0': + '@drop-oss/droplet-darwin-universal@4.0.0': optional: true - '@drop-oss/droplet-darwin-x64@3.5.0': + '@drop-oss/droplet-darwin-x64@4.0.0': optional: true - '@drop-oss/droplet-linux-arm64-gnu@3.5.0': + '@drop-oss/droplet-linux-arm64-gnu@4.0.0': optional: true - '@drop-oss/droplet-linux-arm64-musl@3.5.0': + '@drop-oss/droplet-linux-arm64-musl@4.0.0': optional: true - '@drop-oss/droplet-linux-riscv64-gnu@3.5.0': + '@drop-oss/droplet-linux-riscv64-gnu@4.0.0': optional: true - '@drop-oss/droplet-linux-x64-gnu@3.5.0': + '@drop-oss/droplet-linux-x64-gnu@4.0.0': optional: true - '@drop-oss/droplet-linux-x64-musl@3.5.0': + '@drop-oss/droplet-linux-x64-musl@4.0.0': optional: true - '@drop-oss/droplet-win32-arm64-msvc@3.5.0': + '@drop-oss/droplet-win32-arm64-msvc@4.0.0': optional: true - '@drop-oss/droplet-win32-x64-msvc@3.5.0': + '@drop-oss/droplet-win32-x64-msvc@4.0.0': optional: true - '@drop-oss/droplet@3.5.0': + '@drop-oss/droplet@4.0.0': 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': 4.0.0 + '@drop-oss/droplet-darwin-universal': 4.0.0 + '@drop-oss/droplet-darwin-x64': 4.0.0 + '@drop-oss/droplet-linux-arm64-gnu': 4.0.0 + '@drop-oss/droplet-linux-arm64-musl': 4.0.0 + '@drop-oss/droplet-linux-riscv64-gnu': 4.0.0 + '@drop-oss/droplet-linux-x64-gnu': 4.0.0 + '@drop-oss/droplet-linux-x64-musl': 4.0.0 + '@drop-oss/droplet-win32-arm64-msvc': 4.0.0 + '@drop-oss/droplet-win32-x64-msvc': 4.0.0 '@dxup/nuxt@0.2.2(magicast@0.5.1)': dependencies: diff --git a/server/api/v1/client/chunk.get.ts b/server/api/v1/client/chunk.get.ts deleted file mode 100644 index 030b7960..00000000 --- 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/v2/client/chunk.post.ts b/server/api/v2/client/chunk.post.ts deleted file mode 100644 index 648961bd..00000000 --- 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 e54356a9..00000000 --- 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/library/index.ts b/server/internal/library/index.ts index 4e95df52..0002d477 100644 --- a/server/internal/library/index.ts +++ b/server/internal/library/index.ts @@ -42,6 +42,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({}); diff --git a/server/internal/library/providers/filesystem.ts b/server/internal/library/providers/filesystem.ts index 17e5e971..c4508d47 100644 --- a/server/internal/library/providers/filesystem.ts +++ b/server/internal/library/providers/filesystem.ts @@ -7,15 +7,13 @@ 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 +62,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 +70,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 +81,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 +100,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 cb1f1310..d38a6a74 100644 --- a/server/internal/library/providers/flat.ts +++ b/server/internal/library/providers/flat.ts @@ -4,8 +4,7 @@ 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 +47,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 +64,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 +75,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 +92,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/services/services/torrential.ts b/server/internal/services/services/torrential.ts index 3ab52898..c0404080 100644 --- a/server/internal/services/services/torrential.ts +++ b/server/internal/services/services/torrential.ts @@ -3,6 +3,7 @@ import { Service } from ".."; import fs from "fs"; import prisma from "../../db/database"; import { APITokenMode } from "~/prisma/client/enums"; +import { logger } from "../../logging"; const INTERNAL_DEPOT_URL = new URL( process.env.INTERNAL_DEPOT_URL ?? "http://localhost:5000", @@ -47,10 +48,17 @@ export const TORRENTIAL_SERVICE = new Service( async () => await $fetch(`${INTERNAL_DEPOT_URL.toString()}healthcheck`), { async invalidate(gameId: string, versionName: string) { - await $fetch(`${INTERNAL_DEPOT_URL.toString()}invalidate`, {method: "POST", body: { - game_id: gameId, - version_name: versionName - }}); - } - } + try { + await $fetch(`${INTERNAL_DEPOT_URL.toString()}invalidate`, { + method: "POST", + body: { + game_id: gameId, + version_name: versionName, + }, + }); + } catch (e) { + logger.warn("invalidate torrential cache failed with error: " + e); + } + }, + }, ); diff --git a/server/internal/tasks/group.ts b/server/internal/tasks/group.ts index 107793ce..e992ded3 100644 --- a/server/internal/tasks/group.ts +++ b/server/internal/tasks/group.ts @@ -11,15 +11,15 @@ export const taskGroups = { "check:update": { concurrency: false, }, + "check:integrity": { + concurrency: false + }, "import:game": { concurrency: true, }, debug: { concurrency: true, }, - "export:game": { - concurrency: true, - }, } as const; export type TaskGroup = keyof typeof taskGroups; diff --git a/server/internal/tasks/index.ts b/server/internal/tasks/index.ts index 08751cd2..1b011522 100644 --- a/server/internal/tasks/index.ts +++ b/server/internal/tasks/index.ts @@ -7,6 +7,7 @@ import cleanupInvites from "./registry/invitations"; import cleanupSessions from "./registry/sessions"; import checkUpdate from "./registry/update"; import cleanupObjects from "./registry/objects"; +import checkIntegrity from "./registry/integrity"; import { taskGroups, type TaskGroup } from "./group"; import prisma from "../db/database"; import { type } from "arktype"; @@ -54,7 +55,7 @@ class TaskHandler { "cleanup:sessions", "check:update", ]; - private weeklyScheduledTasks: TaskGroup[] = ["cleanup:objects"]; + private weeklyScheduledTasks: TaskGroup[] = ["cleanup:objects", "check:integrity"]; constructor() { // register the cleanup invitations task @@ -62,6 +63,7 @@ class TaskHandler { this.saveScheduledTask(cleanupSessions); this.saveScheduledTask(checkUpdate); this.saveScheduledTask(cleanupObjects); + this.saveScheduledTask(checkIntegrity); //this.saveScheduledTask(debug); } diff --git a/server/internal/tasks/registry/integrity.ts b/server/internal/tasks/registry/integrity.ts new file mode 100644 index 00000000..ce8d0b5c --- /dev/null +++ b/server/internal/tasks/registry/integrity.ts @@ -0,0 +1,134 @@ +import prisma from "~/server/internal/db/database"; +import { defineDropTask } from ".."; +import type { DropManifest } from "../../downloads/manifest"; +import libraryManager from "../../library"; +import crypto from "crypto"; + +export default defineDropTask({ + buildId: () => `check:integrity:${new Date().toISOString()}`, + name: "Integrity check", + acls: ["system:maintenance:read"], + taskGroup: "check:integrity", + async run({ progress, logger }) { + const versions = await prisma.gameVersion.findMany({ + include: { game: true }, + }); + logger.info(`checking integrity for ${versions.length} versions...`); + + let currentProgress = 0; + for (const version of versions) { + const minProgress = (currentProgress / versions.length) * 100; + const maxProgress = ((currentProgress + 1) / versions.length) * 100; + const progressBudget = maxProgress - minProgress; + progress(minProgress); + logger.info( + `starting integrity check for ${version.game.mName} ${version.versionName}`, + ); + + const manifest = JSON.parse( + version.dropletManifest as string, + ) as DropManifest; + const manifestChunks = Object.entries(manifest); + let valid = true; + let manifestProgress = 0; + manifest_loop: for (const [filename, chunk] of manifestChunks) { + let offset = 0; + for (let i = 0; i < chunk.lengths.length; i++) { + const length = chunk.lengths[i]; + const checksum = chunk.checksums[i]; + + const fileStream = await libraryManager.readFile( + version.game.libraryId!, + version.game.libraryPath, + version.versionName, + filename, + { start: offset, end: offset + length }, + ); + if (!fileStream) { + logger.warn("couldn't create file stream"); + valid = false; + break manifest_loop; + } + let realLength = 0; + const hash = crypto.createHash("md5"); + await fileStream.pipeTo( + new WritableStream({ + write(chunk) { + hash.update(chunk); + realLength += chunk.length; + }, + }), + ); + if (realLength != length) { + logger.warn("real length doesn't match"); + + valid = false; + break manifest_loop; + } + const hashHex = hash.digest("hex"); + if (hashHex != checksum) { + logger.warn("hash doesn't match"); + + valid = false; + break manifest_loop; + } + + offset += length; + } + const currentManifestProgress = + minProgress + + progressBudget * (manifestProgress / manifestChunks.length); + progress(currentManifestProgress); + manifestProgress++; + } + + if (!valid) { + logger.info( + `integrity check for ${version.game.mName} ${version.versionName} failed, reimporting...`, + ); + progress(minProgress); + const library = await libraryManager.getLibrary( + version.game.libraryId!, + ); + if (!library) + throw new Error( + `Library doesn't exist for ${version.game.mName} ${version.versionName}`, + ); + + const manifest = await library.generateDropletManifest( + version.game.libraryPath, + version.versionName, + (_, manifestProgress) => { + const currentManifestProgress = + minProgress + progressBudget * (manifestProgress / 100); + progress(currentManifestProgress); + }, + (_, _logline) => { + //logger.info(`[import:${version.gameId}] ${logline}`); + }, + ); + + await prisma.gameVersion.update({ + where: { + gameId_versionName: { + gameId: version.gameId, + versionName: version.versionName, + }, + }, + data: { + dropletManifest: manifest, + }, + }); + } else { + logger.info( + `integrity check for ${version.game.mName} ${version.versionName} succeeded!`, + ); + } + + currentProgress++; + } + + logger.info("integrity check done!"); + progress(100); + }, +}); From ee889a7187719280ccb17859771aea269ec2fb57 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Fri, 5 Dec 2025 20:12:12 +1100 Subject: [PATCH 11/47] fix: lint --- .github/workflows/release.yml | 1 - .prettierignore | 2 ++ pages/admin/task/index.vue | 5 +++-- server/internal/library/providers/filesystem.ts | 7 ++++++- server/internal/library/providers/flat.ts | 7 ++++++- server/internal/services/index.ts | 2 +- server/internal/tasks/group.ts | 2 +- server/internal/tasks/index.ts | 5 ++++- 8 files changed, 23 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 40ca2b5c..a3fb0312 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,5 @@ name: Release Workflow - on: workflow_dispatch: {} release: diff --git a/.prettierignore b/.prettierignore index df3c464a..20d033a8 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/pages/admin/task/index.vue b/pages/admin/task/index.vue index bf5d6872..6fc4edc0 100644 --- a/pages/admin/task/index.vue +++ b/pages/admin/task/index.vue @@ -169,8 +169,9 @@ const scheduledTasks: { }, "check:integrity": { name: "Integrity check", - description: "Checks that all versions match their internal download manifest. Run this task if clients are having issues validating their downloads." - } + description: + "Checks that all versions match their internal download manifest. Run this task if clients are having issues validating their downloads.", + }, }; async function startTask(taskGroup: string) { diff --git a/server/internal/library/providers/filesystem.ts b/server/internal/library/providers/filesystem.ts index c4508d47..c433b9f5 100644 --- a/server/internal/library/providers/filesystem.ts +++ b/server/internal/library/providers/filesystem.ts @@ -7,7 +7,12 @@ import { import { LibraryBackend } from "~/prisma/client/enums"; import fs from "fs"; import path from "path"; -import droplet, { hasBackendForPath, listFiles, peekFile, readFile } from "@drop-oss/droplet"; +import droplet, { + hasBackendForPath, + listFiles, + peekFile, + readFile, +} from "@drop-oss/droplet"; import { fsStats } from "~/server/internal/utils/files"; export const FilesystemProviderConfig = type({ diff --git a/server/internal/library/providers/flat.ts b/server/internal/library/providers/flat.ts index d38a6a74..39d9901e 100644 --- a/server/internal/library/providers/flat.ts +++ b/server/internal/library/providers/flat.ts @@ -4,7 +4,12 @@ import { VersionNotFoundError } from "../provider"; import { LibraryBackend } from "~/prisma/client/enums"; import fs from "fs"; import path from "path"; -import droplet, { hasBackendForPath, listFiles, peekFile, readFile } from "@drop-oss/droplet"; +import droplet, { + hasBackendForPath, + listFiles, + peekFile, + readFile, +} from "@drop-oss/droplet"; import { fsStats } from "~/server/internal/utils/files"; export const FlatFilesystemProviderConfig = type({ diff --git a/server/internal/services/index.ts b/server/internal/services/index.ts index 3f5dba4a..65f4c362 100644 --- a/server/internal/services/index.ts +++ b/server/internal/services/index.ts @@ -154,7 +154,7 @@ export class Service { } utils() { - return this.uutils + return this.uutils; } } diff --git a/server/internal/tasks/group.ts b/server/internal/tasks/group.ts index e992ded3..3e6074e2 100644 --- a/server/internal/tasks/group.ts +++ b/server/internal/tasks/group.ts @@ -12,7 +12,7 @@ export const taskGroups = { concurrency: false, }, "check:integrity": { - concurrency: false + concurrency: false, }, "import:game": { concurrency: true, diff --git a/server/internal/tasks/index.ts b/server/internal/tasks/index.ts index 1b011522..951d223d 100644 --- a/server/internal/tasks/index.ts +++ b/server/internal/tasks/index.ts @@ -55,7 +55,10 @@ class TaskHandler { "cleanup:sessions", "check:update", ]; - private weeklyScheduledTasks: TaskGroup[] = ["cleanup:objects", "check:integrity"]; + private weeklyScheduledTasks: TaskGroup[] = [ + "cleanup:objects", + "check:integrity", + ]; constructor() { // register the cleanup invitations task From fc2f9a6b785013b3c6796d6d306d4009511a54f7 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Sun, 14 Dec 2025 14:45:17 +1100 Subject: [PATCH 12/47] feat: move to version ids --- .vscode/settings.json | 3 +- components/AccountSidebar.vue | 4 +- components/GameEditor/Version.vue | 12 ++-- i18n/locales/en_us.json | 6 ++ .../migration.sql | 28 ++++++++ prisma/models/content.prisma | 18 ++--- server/api/v1/admin/depot/context.get.ts | 31 --------- server/api/v1/admin/game/[id]/index.get.ts | 2 +- .../api/v1/admin/game/version/index.delete.ts | 4 +- .../api/v1/admin/game/version/index.patch.ts | 12 ++-- server/api/v1/client/game/version.get.ts | 4 +- server/internal/downloads/coordinator.ts | 68 ------------------- server/internal/downloads/manifest.ts | 8 +-- server/internal/gamesize/index.ts | 22 +++--- server/internal/library/index.ts | 35 ++++------ server/internal/tasks/registry/integrity.ts | 16 ++--- server/plugins/05.library-init.ts | 40 +---------- server/tasks/downloadCleanup.ts | 11 --- 18 files changed, 104 insertions(+), 220 deletions(-) create mode 100644 prisma/migrations/20251210231153_move_to_version_id/migration.sql delete mode 100644 server/api/v1/admin/depot/context.get.ts delete mode 100644 server/internal/downloads/coordinator.ts delete mode 100644 server/tasks/downloadCleanup.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 76d084c8..26d84d66 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -33,5 +33,6 @@ "pages/admin/library/sources/index.vue": ["Filesystem"], "components/NewsArticleCreateButton.vue": ["[", "`", "Enter"], "server/api/v1/auth/signin/simple.post.ts": ["boolean | undefined"] - } + }, + "prisma.pinToPrisma6": true } diff --git a/components/AccountSidebar.vue b/components/AccountSidebar.vue index 2c0a3ece..38dc1b38 100644 --- a/components/AccountSidebar.vue +++ b/components/AccountSidebar.vue @@ -54,9 +54,9 @@ const notifications = useNotifications(); const { t } = useI18n(); const navigation: (NavigationItem & { icon: Component; count?: number })[] = [ - { label: t("home"), route: "/account", icon: HomeIcon, prefix: "/account" }, + { label: t("account.home.title"), route: "/account", icon: HomeIcon, prefix: "/account" }, { - label: t("security"), + label: t("account.security.title"), route: "/account/security", prefix: "/account/security", icon: LockClosedIcon, diff --git a/components/GameEditor/Version.vue b/components/GameEditor/Version.vue index 59507ce5..5d399ec1 100644 --- a/components/GameEditor/Version.vue +++ b/components/GameEditor/Version.vue @@ -57,7 +57,7 @@ class="w-full inline-flex items-center px-4 py-2 bg-zinc-800 rounded justify-between w-full flex" >
- {{ item.versionName }} + {{ item.displayName }}
-
@@ -156,7 +156,7 @@ async function updateVersionOrder() { method: "PATCH", body: { id: game.value.id, - versions: game.value.versions.map((e) => e.versionName), + versions: game.value.versions.map((e) => e.versionId), }, }); game.value.versions = newVersions; @@ -175,17 +175,17 @@ async function updateVersionOrder() { } } -async function deleteVersion(versionName: string) { +async function deleteVersion(versionId: string) { try { await $dropFetch("/api/v1/admin/game/version", { method: "DELETE", body: { id: game.value.id, - versionName: versionName, + version: versionId, }, }); game.value.versions.splice( - game.value.versions.findIndex((e) => e.versionName === versionName), + game.value.versions.findIndex((e) => e.versionId === versionId), 1, ); hasDeleted.value = true; diff --git a/i18n/locales/en_us.json b/i18n/locales/en_us.json index 24571883..ab135964 100644 --- a/i18n/locales/en_us.json +++ b/i18n/locales/en_us.json @@ -20,6 +20,12 @@ "title": "Notifications", "unread": "Unread Notifications" }, + "home": { + "title": "Home" + }, + "security": { + "title": "Security" + }, "settings": "Settings", "title": "Account Settings", "token": { 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 00000000..db19fcc5 --- /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/models/content.prisma b/prisma/models/content.prisma index 1d8a0029..bf1c1189 100644 --- a/prisma/models/content.prisma +++ b/prisma/models/content.prisma @@ -36,8 +36,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[] @@ -57,12 +57,11 @@ 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,9 +82,12 @@ 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()) @@ -104,7 +106,7 @@ model GameVersion { versionIndex Int delta Boolean @default(false) - @@id([gameId, versionName]) + @@id([gameId, versionId]) } // A save slot for a game diff --git a/server/api/v1/admin/depot/context.get.ts b/server/api/v1/admin/depot/context.get.ts deleted file mode 100644 index 173aca09..00000000 --- a/server/api/v1/admin/depot/context.get.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ArkErrors, type } from "arktype"; -import aclManager from "~/server/internal/acls"; -import contextManager from "~/server/internal/downloads/coordinator"; - -const Query = type({ - game: "string", - version: "string", -}); - -export default defineEventHandler(async (h3) => { - const allowed = await aclManager.allowSystemACL(h3, ["depot"]); - if (!allowed) throw createError({ statusCode: 403 }); - - const query = Query(getQuery(h3)); - if (query instanceof ArkErrors) - throw createError({ statusCode: 400, message: query.summary }); - - const contextId = await contextManager.createContext( - query.game, - query.version, - ); - if (!contextId) - throw createError({ - statusCode: 400, - statusMessage: "Invalid game or version", - }); - - const contextObject = await contextManager.fetchContext(contextId); - - return contextObject!; -}); diff --git a/server/api/v1/admin/game/[id]/index.get.ts b/server/api/v1/admin/game/[id]/index.get.ts index 54f1af8d..e62fbe95 100644 --- a/server/api/v1/admin/game/[id]/index.get.ts +++ b/server/api/v1/admin/game/[id]/index.get.ts @@ -32,7 +32,7 @@ export default defineEventHandler(async (h3) => { const getGameVersionSize = async (version: GameVersion) => { const size = await libraryManager.getGameVersionSize( gameId, - version.versionName, + version.versionId, ); return { ...version, size }; }; diff --git a/server/api/v1/admin/game/version/index.delete.ts b/server/api/v1/admin/game/version/index.delete.ts index 60378586..f68fa64e 100644 --- a/server/api/v1/admin/game/version/index.delete.ts +++ b/server/api/v1/admin/game/version/index.delete.ts @@ -5,7 +5,7 @@ import libraryManager from "~/server/internal/library"; const DeleteVersion = type({ id: "string", - versionName: "string", + version: "string", }).configure(throwingArktype); export default defineEventHandler<{ body: typeof DeleteVersion }>( @@ -18,7 +18,7 @@ export default defineEventHandler<{ body: typeof DeleteVersion }>( const body = await readDropValidatedBody(h3, DeleteVersion); const gameId = body.id.toString(); - const version = body.versionName.toString(); + const version = body.version.toString(); await libraryManager.deleteGameVersion(gameId, version); return {}; diff --git a/server/api/v1/admin/game/version/index.patch.ts b/server/api/v1/admin/game/version/index.patch.ts index 54e05470..2c9d1340 100644 --- a/server/api/v1/admin/game/version/index.patch.ts +++ b/server/api/v1/admin/game/version/index.patch.ts @@ -20,10 +20,10 @@ export default defineEventHandler<{ body: typeof UpdateVersionOrder }>( // We expect an array of the version names for this game const unsortedVersions = await prisma.gameVersion.findMany({ where: { - versionName: { in: body.versions }, + versionId: { in: body.versions }, }, select: { - versionName: true, + versionId: true, versionIndex: true, delta: true, platform: true, @@ -31,7 +31,7 @@ export default defineEventHandler<{ body: typeof UpdateVersionOrder }>( }); const versions = body.versions - .map((e) => unsortedVersions.find((v) => v.versionName === e)) + .map((e) => unsortedVersions.find((v) => v.versionId === e)) .filter((e) => e !== undefined); if (versions.length !== unsortedVersions.length) @@ -46,7 +46,7 @@ export default defineEventHandler<{ body: typeof UpdateVersionOrder }>( if (version.delta && !has[version.platform]) throw createError({ statusCode: 400, - statusMessage: `"${version.versionName}" requires a base version to apply the delta to.`, + statusMessage: `"${version.versionId}" requires a base version to apply the delta to.`, }); has[version.platform] = true; } @@ -55,9 +55,9 @@ export default defineEventHandler<{ body: typeof UpdateVersionOrder }>( versions.map((version, versionIndex) => prisma.gameVersion.update({ where: { - gameId_versionName: { + gameId_versionId: { gameId: gameId, - versionName: version.versionName, + versionId: version.versionId, }, }, data: { diff --git a/server/api/v1/client/game/version.get.ts b/server/api/v1/client/game/version.get.ts index 2ed74220..3353ebdc 100644 --- a/server/api/v1/client/game/version.get.ts +++ b/server/api/v1/client/game/version.get.ts @@ -14,9 +14,9 @@ export default defineClientEventHandler(async (h3) => { const gameVersion = await prisma.gameVersion.findUnique({ where: { - gameId_versionName: { + gameId_versionId: { gameId: id, - versionName: version, + versionId: version, }, }, }); diff --git a/server/internal/downloads/coordinator.ts b/server/internal/downloads/coordinator.ts deleted file mode 100644 index bc2dfe5f..00000000 --- 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 index 2da9b57e..19e94126 100644 --- a/server/internal/downloads/manifest.ts +++ b/server/internal/downloads/manifest.ts @@ -54,14 +54,14 @@ class ManifestGenerator { } // Local function because eventual caching - async generateManifest(gameId: string, versionName: string) { + async generateManifest(gameId: string, versionId: string) { const versions: GameVersionModel[] = []; const baseVersion = await prisma.gameVersion.findUnique({ where: { - gameId_versionName: { + gameId_versionId: { gameId: gameId, - versionName: versionName, + versionId, }, }, }); @@ -92,7 +92,7 @@ class ManifestGenerator { manifest: JSON.parse( e.dropletManifest?.toString() ?? "{}", ) as DropManifest, - versionName: e.versionName, + versionName: e.versionId, }; }); diff --git a/server/internal/gamesize/index.ts b/server/internal/gamesize/index.ts index 9e20976c..3ec6f2da 100644 --- a/server/internal/gamesize/index.ts +++ b/server/internal/gamesize/index.ts @@ -57,9 +57,9 @@ class GameSizeManager { async getGameVersionSize( gameId: string, - versionName?: string, + versionId?: string, ): Promise { - if (!versionName) { + if (!versionId) { const version = await prisma.gameVersion.findFirst({ where: { gameId }, orderBy: { @@ -69,12 +69,12 @@ class GameSizeManager { if (!version) { return null; } - versionName = version.versionName; + versionId = version.versionId; } const manifest = await manifestGenerator.generateManifest( gameId, - versionName, + versionId, ); if (!manifest) { return null; @@ -88,7 +88,7 @@ class GameSizeManager { version: GameVersion, ): Promise { return gameVersions.length > 0 - ? gameVersions[0].versionName === version.versionName + ? gameVersions[0].versionId === version.versionId : false; } @@ -162,16 +162,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 +186,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 0002d477..72762894 100644 --- a/server/internal/library/index.ts +++ b/server/internal/library/index.ts @@ -112,11 +112,11 @@ 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 && + game.versions.findIndex((v) => v.versionPath == e) == -1 && !taskHandler.hasTask(createVersionImportTaskId(game.id, e)), ); return unimportedVersions; @@ -132,11 +132,6 @@ class LibraryManager { async fetchGamesWithStatus() { const games = await prisma.game.findMany({ include: { - versions: { - select: { - versionName: true, - }, - }, library: true, }, orderBy: { @@ -154,7 +149,7 @@ class LibraryManager { game: e, status: versions ? { - noVersions: e.versions.length == 0, + noVersions: versions.length == 0, unimportedVersions: versions, } : ("offline" as const), @@ -248,7 +243,7 @@ class LibraryManager { async importVersion( gameId: string, - versionName: string, + versionPath: string, metadata: { platform: string; onlySetup: boolean; @@ -262,7 +257,7 @@ class LibraryManager { umuId: string; }, ) { - const taskId = createVersionImportTaskId(gameId, versionName); + const taskId = createVersionImportTaskId(gameId, versionPath); const platform = parsePlatform(metadata.platform); if (!platform) return undefined; @@ -279,14 +274,14 @@ class LibraryManager { taskHandler.create({ id: taskId, 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); @@ -308,7 +303,7 @@ class LibraryManager { await prisma.gameVersion.create({ data: { gameId: gameId, - versionName: versionName, + versionPath, dropletManifest: manifest, versionIndex: currentIndex, delta: metadata.delta, @@ -324,7 +319,7 @@ class LibraryManager { await prisma.gameVersion.create({ data: { gameId: gameId, - versionName: versionName, + versionPath, dropletManifest: manifest, versionIndex: currentIndex, delta: metadata.delta, @@ -343,17 +338,17 @@ class LibraryManager { 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, versionName); + await TORRENTIAL_SERVICE.utils().invalidate(gameId, versionPath); progress(100); }, }); @@ -388,7 +383,7 @@ class LibraryManager { await prisma.gameVersion.deleteMany({ where: { gameId: gameId, - versionName: version, + versionId: version, }, }); diff --git a/server/internal/tasks/registry/integrity.ts b/server/internal/tasks/registry/integrity.ts index ce8d0b5c..560d3365 100644 --- a/server/internal/tasks/registry/integrity.ts +++ b/server/internal/tasks/registry/integrity.ts @@ -22,7 +22,7 @@ export default defineDropTask({ const progressBudget = maxProgress - minProgress; progress(minProgress); logger.info( - `starting integrity check for ${version.game.mName} ${version.versionName}`, + `starting integrity check for ${version.game.mName} ${version.versionId}`, ); const manifest = JSON.parse( @@ -40,7 +40,7 @@ export default defineDropTask({ const fileStream = await libraryManager.readFile( version.game.libraryId!, version.game.libraryPath, - version.versionName, + version.versionId, filename, { start: offset, end: offset + length }, ); @@ -84,7 +84,7 @@ export default defineDropTask({ if (!valid) { logger.info( - `integrity check for ${version.game.mName} ${version.versionName} failed, reimporting...`, + `integrity check for ${version.game.mName} ${version.versionId} failed, reimporting...`, ); progress(minProgress); const library = await libraryManager.getLibrary( @@ -92,12 +92,12 @@ export default defineDropTask({ ); if (!library) throw new Error( - `Library doesn't exist for ${version.game.mName} ${version.versionName}`, + `Library doesn't exist for ${version.game.mName} ${version.versionId}`, ); const manifest = await library.generateDropletManifest( version.game.libraryPath, - version.versionName, + version.versionPath, (_, manifestProgress) => { const currentManifestProgress = minProgress + progressBudget * (manifestProgress / 100); @@ -110,9 +110,9 @@ export default defineDropTask({ await prisma.gameVersion.update({ where: { - gameId_versionName: { + gameId_versionId: { gameId: version.gameId, - versionName: version.versionName, + versionId: version.versionId, }, }, data: { @@ -121,7 +121,7 @@ export default defineDropTask({ }); } else { logger.info( - `integrity check for ${version.game.mName} ${version.versionName} succeeded!`, + `integrity check for ${version.game.mName} ${version.versionId} succeeded!`, ); } diff --git a/server/plugins/05.library-init.ts b/server/plugins/05.library-init.ts index 9891b51e..04a1349f 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/tasks/downloadCleanup.ts b/server/tasks/downloadCleanup.ts deleted file mode 100644 index 75095319..00000000 --- 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 }; - }, -}); From bfdbc7fd5208f3d531b30e13b8f5b01bd9ccbc53 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Thu, 18 Dec 2025 18:36:18 +1100 Subject: [PATCH 13/47] fix: client fixes and client-side checks --- app.vue | 3 +- components/MultiItemSelector.vue | 2 +- components/StoreView.vue | 2 +- composables/request.ts | 12 ++++++++ layouts/default.vue | 8 +++-- nuxt.config.ts | 2 +- server/api/v1/client/auth/initiate.post.ts | 29 ++++++++++--------- server/api/v1/client/capability/index.post.ts | 5 ++-- server/api/v1/client/user/webtoken.post.ts | 1 + 9 files changed, 40 insertions(+), 24 deletions(-) diff --git a/app.vue b/app.vue index 26c93ff8..e76d0ab2 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"); diff --git a/components/MultiItemSelector.vue b/components/MultiItemSelector.vue index d99fec74..3c1e72d4 100644 --- a/components/MultiItemSelector.vue +++ b/components/MultiItemSelector.vue @@ -39,7 +39,7 @@ @blur="search = ''" /> diff --git a/components/StoreView.vue b/components/StoreView.vue index e4f94ea2..ed277f30 100644 --- a/components/StoreView.vue +++ b/components/StoreView.vue @@ -304,7 +304,7 @@
{ if (import.meta.server) state.value = data; return data; }; + +export function isClientRequest() { + const existingState = useState("clientMode", () => false); + if (import.meta.server) { + const headers = useRequestHeaders(["User-Agent"]); + const calculatedClientRequest = + headers["user-agent"] == "Drop Desktop Client"; + existingState.value = calculatedClientRequest; + } + + return existingState.value; +} diff --git a/layouts/default.vue b/layouts/default.vue index 42288c09..5b453632 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -1,5 +1,8 @@ - - diff --git a/components/GameEditor/Version.vue b/components/GameEditor/Version.vue index 5d399ec1..67d910c6 100644 --- a/components/GameEditor/Version.vue +++ b/components/GameEditor/Version.vue @@ -57,7 +57,7 @@ class="w-full inline-flex items-center px-4 py-2 bg-zinc-800 rounded justify-between w-full flex" >
- {{ item.displayName }} + {{ item.displayName || item.versionPath }}
{ { title: opts.failTitle, description: - (e as FetchError)?.statusMessage ?? (e as string).toString(), + (e as FetchError)?.data?.message ?? (e as string).toString(), //buttonText: $t("common.close"), }, (_, c) => c(), diff --git a/nuxt.config.ts b/nuxt.config.ts index fdab4457..31d18a0a 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -140,7 +140,6 @@ export default defineNuxtConfig({ scheduledTasks: { "0 * * * *": ["dailyTasks"], - "*/30 * * * *": ["downloadCleanup"], }, storage: { diff --git a/package.json b/package.json index 4a517119..0712dff2 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@discordapp/twemoji": "^16.0.1", - "@drop-oss/droplet": "4.0.0", + "@drop-oss/droplet": "5.1.1", "@headlessui/vue": "^1.7.23", "@heroicons/vue": "^2.1.5", "@lobomfz/prismark": "0.0.3", diff --git a/pages/admin/library/[id]/import.vue b/pages/admin/library/[id]/import.vue index 485a57c4..08f8e0c5 100644 --- a/pages/admin/library/[id]/import.vue +++ b/pages/admin/library/[id]/import.vue @@ -639,15 +639,22 @@ async function updateCurrentlySelectedVersion(value: number) { if (currentlySelectedVersion.value == value) return; currentlySelectedVersion.value = value; const version = versions[currentlySelectedVersion.value]; - const results = await $dropFetch( - `/api/v1/admin/import/version/preload?id=${encodeURIComponent( - gameId, - )}&version=${encodeURIComponent(version)}`, - ); - versionGuesses.value = results.map((e) => ({ - ...e, - platform: e.platform as PlatformClient, - })); + try { + const results = await $dropFetch( + `/api/v1/admin/import/version/preload?id=${encodeURIComponent( + gameId, + )}&version=${encodeURIComponent(version)}`, + { + failTitle: "Failed to fetch version information", + }, + ); + versionGuesses.value = results.map((e) => ({ + ...e, + platform: e.platform as PlatformClient, + })); + } catch { + currentlySelectedVersion.value = -1; + } } async function startImport() { diff --git a/pages/admin/settings/tokens.vue b/pages/admin/settings/tokens.vue index c307695e..4e9aab8c 100644 --- a/pages/admin/settings/tokens.vue +++ b/pages/admin/settings/tokens.vue @@ -206,7 +206,6 @@ async function createToken( }, failTitle: "Failed to create API token.", }); - console.log(result); newToken.value = result.token; tokens.value.push(result); } catch { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1fbb09bd..1b71dc9b 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: 4.0.0 - version: 4.0.0 + specifier: 5.1.1 + version: 5.1.1 '@headlessui/vue': specifier: ^1.7.23 version: 1.7.23(vue@3.5.22(typescript@5.8.3)) @@ -408,67 +408,67 @@ packages: '@discordapp/twemoji@16.0.1': resolution: {integrity: sha512-figLiBWzjS5cyrAjLaGjM8AAaowO3qvK8rg5bA2dElB4qsaPMvBVlFDMO2d3x+nC1igt7kgWH4dvNmvvUHUF8w==} - '@drop-oss/droplet-darwin-arm64@4.0.0': - resolution: {integrity: sha512-SQMfBsALuObZxbT2lQCmZBLd9WlP9USukxRUSa9BleZMaWsHBi7d/VD8unUGxBxROPIEPTWFRl0ItgewOqaumw==} + '@drop-oss/droplet-darwin-arm64@5.1.1': + resolution: {integrity: sha512-gPOoEdmEg6zzqgysSM6i+i/ATRjw2dKtvKqy3h2FAUGQNjYoxZBsxy/PgvHW46IuAge4XWLN1ERrqk+CZLoqcA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@drop-oss/droplet-darwin-universal@4.0.0': - resolution: {integrity: sha512-awmjFlc1lP0KfucrkwNEKEyByJTUDWLkAqhdM2Dzk4e0l1SVRJcfk0P6LSImFekQp3zY7s0FjDeOfx8SmWWvIA==} + '@drop-oss/droplet-darwin-universal@5.1.1': + resolution: {integrity: sha512-5qEirLWhbIDsWShRQcLQosjiRftn89EiX+Z7VeK7hpm5ADD4Bflgi1K1iIJfjVRfOTzeeHnT2QcUPejGy0XULg==} engines: {node: '>= 10'} os: [darwin] - '@drop-oss/droplet-darwin-x64@4.0.0': - resolution: {integrity: sha512-OHPSe8u73cd/UbhP6hf9wfKktZqZOwC+2h8+e1Ln5fFMZ1WU3JGLZFaI/uk1oy/dRWjVM8EIzdtgC4gN1WtMOw==} + '@drop-oss/droplet-darwin-x64@5.1.1': + resolution: {integrity: sha512-dxmHg573erA5vCnZ5JjsBs5nWMio+q1p5yTjtsUCDZrhc+FeE0/WfQ6RzPS3FzDjYrPkHrPjsN+ehrmYkPLcJA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@drop-oss/droplet-linux-arm64-gnu@4.0.0': - resolution: {integrity: sha512-F563kZRwWHPJ8OrbTiy8mjTcPKkm2ij5fr9dVQVGOEePr1gtvSeV1SbKEhMhOxVUoEBmnv46KgVHi5FL5m19Dg==} + '@drop-oss/droplet-linux-arm64-gnu@5.1.1': + resolution: {integrity: sha512-UgBaIiwEi6kz4hsAI7Z0wjn1m0ZNaDffgIwlr4Zo1w9Xd6aZHCxeDvKXbwW/VqMW7soUY+VfJsZCflxCHj6L5A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@drop-oss/droplet-linux-arm64-musl@4.0.0': - resolution: {integrity: sha512-WbRkkLiEzbMt6FhCPXVOIpfAmKsa1RkUFjyrqMzGt64a5JeNUHrs58iMQwPN/Vllm1IcB73BL8aUfXge0DhJ1A==} + '@drop-oss/droplet-linux-arm64-musl@5.1.1': + resolution: {integrity: sha512-gswNRjRLrveHOx+tx7DS3gPPyO9VjNHAhSIn3dRQVqvaA5xXbHYAESO0jzl4rgseTnu83gWOcJBWZKsCr9nkFw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@drop-oss/droplet-linux-riscv64-gnu@4.0.0': - resolution: {integrity: sha512-xOIEyE+fxbRsOzKQV5l9UISIo250BwmCZXGxy2Ch98xx2G3VttsrDqoUuwxKZtD6DjeEGeYLM15tuzf0sTlZbg==} + '@drop-oss/droplet-linux-riscv64-gnu@5.1.1': + resolution: {integrity: sha512-/4gczpKe0+VyL7/pYZWFEs7kCyx2QwcEQaPf0I3QK/3jfw9I9s6UGC3ukTazDrPHEmXWe5Q3IONFfFWMM88d4Q==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - '@drop-oss/droplet-linux-x64-gnu@4.0.0': - resolution: {integrity: sha512-igvUuj8GzPzGWTPe0saSyx1ujkmv3W6yDdcqbh0UBR0BMN90nVVNMwPvmwmtdWtfjPRDG7M9S2Reftv6qk2VwA==} + '@drop-oss/droplet-linux-x64-gnu@5.1.1': + resolution: {integrity: sha512-iddV2q/JSIxUEgNNncz5vOy1NNnOyHi09WpEm1/Q1/EOXVcQwBMchnqRCJdw980MT4YiB3qJlx3toKMADBomQg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@drop-oss/droplet-linux-x64-musl@4.0.0': - resolution: {integrity: sha512-Q1Fl09TgX69faMbKEoPUuVzCXoNs+01t/jn8FrK6mx5ThU24JYz43f17UIUzhoGHEsnzFQHQDZa4ApztfwjVGQ==} + '@drop-oss/droplet-linux-x64-musl@5.1.1': + resolution: {integrity: sha512-nZrm3rFgX0UvpvqQvvoJSvFqRKOUvSV0N7CbMhEitCoMVMvoBCBoHw66H7Nq8pcj1uUuwcBMVBS5M2wtmvSFHA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@drop-oss/droplet-win32-arm64-msvc@4.0.0': - resolution: {integrity: sha512-3gq2NCDCY7Sm7WdZMPSvD3GXN8oU+FayK6xhFA4i8o8QPVWBGD3imkwt2xzkutXTyTQludZE8Qh5iEFm39Nlrw==} + '@drop-oss/droplet-win32-arm64-msvc@5.1.1': + resolution: {integrity: sha512-I9YnRazsP+L32Za4ISPC0EXPA3nMoz296CGOleH4LyeDSsg+QSqwQ3OchNBUyvGWESGfLWJcdjtfZQU2FPdfYg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@drop-oss/droplet-win32-x64-msvc@4.0.0': - resolution: {integrity: sha512-gikZlcL9xJY7BpWb+fw+zcWq1cnQNLm+cba2u0YOwa1B/ggzQ/xtyttPeY6s0KDxBD9qsgOIe/SDqCtH19FbcQ==} + '@drop-oss/droplet-win32-x64-msvc@5.1.1': + resolution: {integrity: sha512-xfwcEnaSY0lYpTj/L0+OW9zSZLBlTYVb0JNmue3ezgB1IRqAsLpPUAeS8wGhdnG0m0r2qh+lpwNCc9RNpfJX9w==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@drop-oss/droplet@4.0.0': - resolution: {integrity: sha512-A/f77qppaMmHj0q2A0Mv3yqPecZnaxauncJgVABAbKEYvHun9vrrC6IaZ16OoFOWdirACU33Odv62dhZcrnz0A==} + '@drop-oss/droplet@5.1.1': + resolution: {integrity: sha512-JW8IhfVFkcM/ldUUDv9WRsyJrLy7rZXh6mTb9tjQ1Nk0G6JBfxJiA0veBUdjQl1DmGOujFEamNoPEiQLZAPcFA==} engines: {node: '>= 10'} '@dxup/nuxt@0.2.2': @@ -7110,48 +7110,48 @@ snapshots: jsonfile: 5.0.0 universalify: 0.1.2 - '@drop-oss/droplet-darwin-arm64@4.0.0': + '@drop-oss/droplet-darwin-arm64@5.1.1': optional: true - '@drop-oss/droplet-darwin-universal@4.0.0': + '@drop-oss/droplet-darwin-universal@5.1.1': optional: true - '@drop-oss/droplet-darwin-x64@4.0.0': + '@drop-oss/droplet-darwin-x64@5.1.1': optional: true - '@drop-oss/droplet-linux-arm64-gnu@4.0.0': + '@drop-oss/droplet-linux-arm64-gnu@5.1.1': optional: true - '@drop-oss/droplet-linux-arm64-musl@4.0.0': + '@drop-oss/droplet-linux-arm64-musl@5.1.1': optional: true - '@drop-oss/droplet-linux-riscv64-gnu@4.0.0': + '@drop-oss/droplet-linux-riscv64-gnu@5.1.1': optional: true - '@drop-oss/droplet-linux-x64-gnu@4.0.0': + '@drop-oss/droplet-linux-x64-gnu@5.1.1': optional: true - '@drop-oss/droplet-linux-x64-musl@4.0.0': + '@drop-oss/droplet-linux-x64-musl@5.1.1': optional: true - '@drop-oss/droplet-win32-arm64-msvc@4.0.0': + '@drop-oss/droplet-win32-arm64-msvc@5.1.1': optional: true - '@drop-oss/droplet-win32-x64-msvc@4.0.0': + '@drop-oss/droplet-win32-x64-msvc@5.1.1': optional: true - '@drop-oss/droplet@4.0.0': + '@drop-oss/droplet@5.1.1': optionalDependencies: - '@drop-oss/droplet-darwin-arm64': 4.0.0 - '@drop-oss/droplet-darwin-universal': 4.0.0 - '@drop-oss/droplet-darwin-x64': 4.0.0 - '@drop-oss/droplet-linux-arm64-gnu': 4.0.0 - '@drop-oss/droplet-linux-arm64-musl': 4.0.0 - '@drop-oss/droplet-linux-riscv64-gnu': 4.0.0 - '@drop-oss/droplet-linux-x64-gnu': 4.0.0 - '@drop-oss/droplet-linux-x64-musl': 4.0.0 - '@drop-oss/droplet-win32-arm64-msvc': 4.0.0 - '@drop-oss/droplet-win32-x64-msvc': 4.0.0 + '@drop-oss/droplet-darwin-arm64': 5.1.1 + '@drop-oss/droplet-darwin-universal': 5.1.1 + '@drop-oss/droplet-darwin-x64': 5.1.1 + '@drop-oss/droplet-linux-arm64-gnu': 5.1.1 + '@drop-oss/droplet-linux-arm64-musl': 5.1.1 + '@drop-oss/droplet-linux-riscv64-gnu': 5.1.1 + '@drop-oss/droplet-linux-x64-gnu': 5.1.1 + '@drop-oss/droplet-linux-x64-musl': 5.1.1 + '@drop-oss/droplet-win32-arm64-msvc': 5.1.1 + '@drop-oss/droplet-win32-x64-msvc': 5.1.1 '@dxup/nuxt@0.2.2(magicast@0.5.1)': dependencies: diff --git a/prisma/migrations/20251218081603_add_depots/migration.sql b/prisma/migrations/20251218081603_add_depots/migration.sql new file mode 100644 index 00000000..ebd5ef59 --- /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/models/app.prisma b/prisma/models/app.prisma index 338af50a..dffd165e 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/server/api/v1/admin/depot/index.post.ts b/server/api/v1/admin/depot/index.post.ts new file mode 100644 index 00000000..73d55833 --- /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 00000000..1f0b7b7f --- /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 00000000..0e0aafaf --- /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/import/version/preload.get.ts b/server/api/v1/admin/import/version/preload.get.ts index d83b9363..bcd7c45c 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) + try { + const preload = await libraryManager.fetchUnimportedVersionInformation( + gameId, + versionName, + ); + if (!preload) + throw createError({ + statusCode: 400, + statusMessage: "Invalid game or version id/name", + }); + + return preload; + } catch (e) { throw createError({ - statusCode: 400, - statusMessage: "Invalid game or version id/name", + statusCode: 500, + message: `Failed to fetch preload information for ${gameId}: ${e}` }); - - return preload; + } }); diff --git a/server/api/v1/admin/library/sources/index.get.ts b/server/api/v1/admin/library/sources/index.get.ts index c6f03f47..25b0f0b3 100644 --- a/server/api/v1/admin/library/sources/index.get.ts +++ b/server/api/v1/admin/library/sources/index.get.ts @@ -11,7 +11,6 @@ export default defineEventHandler(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, [ "library:sources:read", "setup", - "depot", ]); if (!allowed) throw createError({ statusCode: 403 }); 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 81% rename from server/api/v1/client/game/version.get.ts rename to server/api/v1/client/game/[id]/version/[versionid]/index.get.ts index 3353ebdc..6d0bfb84 100644 --- a/server/api/v1/client/game/version.get.ts +++ b/server/api/v1/client/game/[id]/version/[versionid]/index.get.ts @@ -3,13 +3,12 @@ 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({ diff --git a/server/api/v1/client/game/manifest.get.ts b/server/api/v1/client/game/manifest.get.ts index 80535e5e..77a278ed 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/internal/acls/descriptions.ts b/server/internal/acls/descriptions.ts index 3c0c10e9..be4ad0d9 100644 --- a/server/internal/acls/descriptions.ts +++ b/server/internal/acls/descriptions.ts @@ -46,7 +46,6 @@ export const userACLDescriptions: ObjectFromList = { export const systemACLDescriptions: ObjectFromList = { setup: "All permissions required to setup a new Drop instance (setup wizard).", - depot: "All permissions required to use a download depot with Drop.", "auth:read": "Fetch the list of enabled authentication mechanisms configured.", @@ -104,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 ce4c7455..7d292155 100644 --- a/server/internal/acls/index.ts +++ b/server/internal/acls/index.ts @@ -42,7 +42,9 @@ export type UserACL = Array<(typeof userACLs)[number]>; export const systemACLs = [ "setup", - "depot", + + "depot:new", + "depot:delete", "auth:read", "auth:simple:invitation:read", diff --git a/server/internal/downloads/manifest.ts b/server/internal/downloads/manifest.ts deleted file mode 100644 index 19e94126..00000000 --- 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, versionId: string) { - const versions: GameVersionModel[] = []; - - const baseVersion = await prisma.gameVersion.findUnique({ - where: { - gameId_versionId: { - gameId: gameId, - versionId, - }, - }, - }); - 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.versionId, - }; - }); - - 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 3ec6f2da..dac70b96 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,11 +46,7 @@ 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); } @@ -72,15 +68,11 @@ class GameSizeManager { versionId = version.versionId; } - const manifest = await manifestGenerator.generateManifest( - gameId, - versionId, - ); - 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( diff --git a/server/internal/library/index.ts b/server/internal/library/index.ts index 72762894..af734797 100644 --- a/server/internal/library/index.ts +++ b/server/internal/library/index.ts @@ -133,6 +133,7 @@ class LibraryManager { const games = await prisma.game.findMany({ include: { library: true, + versions: true }, orderBy: { mName: "asc", @@ -149,7 +150,7 @@ class LibraryManager { game: e, status: versions ? { - noVersions: versions.length == 0, + noVersions: e.versions.length == 0, unimportedVersions: versions, } : ("offline" as const), diff --git a/server/internal/library/manifest.ts b/server/internal/library/manifest.ts new file mode 100644 index 00000000..1571b9b8 --- /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/services/index.ts b/server/internal/services/index.ts index 65f4c362..ed14d37a 100644 --- a/server/internal/services/index.ts +++ b/server/internal/services/index.ts @@ -86,7 +86,7 @@ export class Service { private async launch() { if (this.currentProcess) return; - const disableEnv = `DISABLE_SERVICE_${this.name.toUpperCase()}`; + const disableEnv = `EXTERNAL_SERVICE_${this.name.toUpperCase()}`; if (!process.env[disableEnv]) { const serviceProcess = this.executor(); this.logger.info("service launched"); diff --git a/server/internal/services/services/torrential.ts b/server/internal/services/services/torrential.ts index c0404080..023ea99f 100644 --- a/server/internal/services/services/torrential.ts +++ b/server/internal/services/services/torrential.ts @@ -2,8 +2,8 @@ import { spawn } from "child_process"; import { Service } from ".."; import fs from "fs"; import prisma from "../../db/database"; -import { APITokenMode } from "~/prisma/client/enums"; import { logger } from "../../logging"; +import { systemConfig } from "../../config/sys-conf"; const INTERNAL_DEPOT_URL = new URL( process.env.INTERNAL_DEPOT_URL ?? "http://localhost:5000", @@ -21,25 +21,23 @@ export const TORRENTIAL_SERVICE = new Service( return spawn("torrential", [], {}); }, async () => { - const token = await prisma.aPIToken.upsert({ + const externalUrl = systemConfig.getExternalUrl(); + const depot = await prisma.depot.upsert({ where: { id: "torrential", }, update: { - name: "Torrential token", - acls: ["depot"], + endpoint: `${externalUrl}/api/v1/depot`, }, create: { id: "torrential", - name: "Torrential token", - acls: ["depot"], - mode: APITokenMode.System, + endpoint: `${externalUrl}/api/v1/depot`, }, }); - await $fetch(`${INTERNAL_DEPOT_URL.toString()}token`, { + await $fetch(`${INTERNAL_DEPOT_URL.toString()}key`, { method: "POST", - body: { token: token.token }, + body: { key: depot.key }, }); return true; }, @@ -47,13 +45,13 @@ export const TORRENTIAL_SERVICE = new Service( // @ts-ignore async () => await $fetch(`${INTERNAL_DEPOT_URL.toString()}healthcheck`), { - async invalidate(gameId: string, versionName: string) { + async invalidate(gameId: string, versionId: string) { try { await $fetch(`${INTERNAL_DEPOT_URL.toString()}invalidate`, { method: "POST", body: { - game_id: gameId, - version_name: versionName, + game: gameId, + version: versionId, }, }); } catch (e) { diff --git a/server/internal/tasks/registry/integrity.ts b/server/internal/tasks/registry/integrity.ts index 560d3365..7a42ddc4 100644 --- a/server/internal/tasks/registry/integrity.ts +++ b/server/internal/tasks/registry/integrity.ts @@ -1,6 +1,5 @@ import prisma from "~/server/internal/db/database"; import { defineDropTask } from ".."; -import type { DropManifest } from "../../downloads/manifest"; import libraryManager from "../../library"; import crypto from "crypto"; @@ -27,60 +26,10 @@ export default defineDropTask({ const manifest = JSON.parse( version.dropletManifest as string, - ) as DropManifest; + ); const manifestChunks = Object.entries(manifest); let valid = true; - let manifestProgress = 0; - manifest_loop: for (const [filename, chunk] of manifestChunks) { - let offset = 0; - for (let i = 0; i < chunk.lengths.length; i++) { - const length = chunk.lengths[i]; - const checksum = chunk.checksums[i]; - - const fileStream = await libraryManager.readFile( - version.game.libraryId!, - version.game.libraryPath, - version.versionId, - filename, - { start: offset, end: offset + length }, - ); - if (!fileStream) { - logger.warn("couldn't create file stream"); - valid = false; - break manifest_loop; - } - let realLength = 0; - const hash = crypto.createHash("md5"); - await fileStream.pipeTo( - new WritableStream({ - write(chunk) { - hash.update(chunk); - realLength += chunk.length; - }, - }), - ); - if (realLength != length) { - logger.warn("real length doesn't match"); - valid = false; - break manifest_loop; - } - const hashHex = hash.digest("hex"); - if (hashHex != checksum) { - logger.warn("hash doesn't match"); - - valid = false; - break manifest_loop; - } - - offset += length; - } - const currentManifestProgress = - minProgress + - progressBudget * (manifestProgress / manifestChunks.length); - progress(currentManifestProgress); - manifestProgress++; - } if (!valid) { logger.info( From 71990f61fe548f7f6375fa45b4f78315d7637ee5 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Sat, 20 Dec 2025 01:11:47 +1100 Subject: [PATCH 15/47] feat: update torrential --- torrential | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/torrential b/torrential index 70f8ab0e..990bd6cf 160000 --- a/torrential +++ b/torrential @@ -1 +1 @@ -Subproject commit 70f8ab0ea208481fceb687a8d290d24e278400b1 +Subproject commit 990bd6cf34374c66f2f4ae3097b5dd5a2a3b4e52 From 52da9c32d739be96f79db28f57d52b80fdd0fad2 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Sat, 20 Dec 2025 20:27:19 +1100 Subject: [PATCH 16/47] feat: droplet bump and remove unsafe update functions --- components/GameEditor/Version.vue | 9 +- package.json | 5 +- pages/admin/task/index.vue | 5 - pnpm-lock.yaml | 197 +++++++++++++----- 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/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 + .../api/v1/admin/game/image/index.delete.ts | 32 +-- server/api/v1/admin/game/image/index.post.ts | 4 +- .../api/v1/admin/game/version/index.patch.ts | 8 +- .../api/v1/admin/import/version/index.post.ts | 1 + .../v1/admin/library/sources/index.patch.ts | 40 ++-- server/api/v1/notifications/[id]/read.post.ts | 20 +- server/internal/clients/ca-store.ts | 20 +- server/internal/clients/capabilities.ts | 26 +-- server/internal/clients/event-handler.ts | 3 +- server/internal/news/index.ts | 10 +- server/internal/saves/index.ts | 26 +-- server/internal/tasks/group.ts | 3 - server/internal/tasks/index.ts | 3 - server/internal/tasks/registry/integrity.ts | 83 -------- torrential | 2 +- 29 files changed, 336 insertions(+), 303 deletions(-) delete mode 100644 server/internal/tasks/registry/integrity.ts diff --git a/components/GameEditor/Version.vue b/components/GameEditor/Version.vue index 67d910c6..2380790e 100644 --- a/components/GameEditor/Version.vue +++ b/components/GameEditor/Version.vue @@ -4,7 +4,7 @@
@@ -56,8 +56,11 @@
-
- {{ item.displayName || item.versionPath }} +
+
+ {{ item.displayName || item.versionPath }} +
+ {{ item.versionId }}
= 10'} cpu: [arm64] os: [darwin] - '@drop-oss/droplet-darwin-universal@5.1.1': - resolution: {integrity: sha512-5qEirLWhbIDsWShRQcLQosjiRftn89EiX+Z7VeK7hpm5ADD4Bflgi1K1iIJfjVRfOTzeeHnT2QcUPejGy0XULg==} + '@drop-oss/droplet-darwin-universal@5.3.1': + resolution: {integrity: sha512-ifvQGjoWtGfPssA9jYRb7hFjDIQSa4SUWYAdBpOWstan6hb36ak9Q7uxrMY+7cWuTayZrYOUd1O4IfRTGEojKg==} engines: {node: '>= 10'} os: [darwin] - '@drop-oss/droplet-darwin-x64@5.1.1': - resolution: {integrity: sha512-dxmHg573erA5vCnZ5JjsBs5nWMio+q1p5yTjtsUCDZrhc+FeE0/WfQ6RzPS3FzDjYrPkHrPjsN+ehrmYkPLcJA==} + '@drop-oss/droplet-darwin-x64@5.3.1': + resolution: {integrity: sha512-u9nbl/y7QpuIWEI5qurpoAdl8pUSXh8Gl2+Mu/UFXvLLqgR3UAhIsNwppTQvyBNANSJdzRRQSZzMhI45Ta2CUg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@drop-oss/droplet-linux-arm64-gnu@5.1.1': - resolution: {integrity: sha512-UgBaIiwEi6kz4hsAI7Z0wjn1m0ZNaDffgIwlr4Zo1w9Xd6aZHCxeDvKXbwW/VqMW7soUY+VfJsZCflxCHj6L5A==} + '@drop-oss/droplet-linux-arm64-gnu@5.3.1': + resolution: {integrity: sha512-KhE+YUjMup6D42T2iclrf9pCAUqkUfK83lwlVTMo1WOd+DEY/03UuIpJe4Q2TKwCvtt10Catz8+QMmMfBqE4Ww==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@drop-oss/droplet-linux-arm64-musl@5.1.1': - resolution: {integrity: sha512-gswNRjRLrveHOx+tx7DS3gPPyO9VjNHAhSIn3dRQVqvaA5xXbHYAESO0jzl4rgseTnu83gWOcJBWZKsCr9nkFw==} + '@drop-oss/droplet-linux-arm64-musl@5.3.1': + resolution: {integrity: sha512-BNNqtcM+hNOYIHKvtzn7dT1A28+uAEHz7iXJztiHYJIk4AkwDQkyngtlvbciYOE8rJRSJJodNNTgQJWYjV8oOA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@drop-oss/droplet-linux-riscv64-gnu@5.1.1': - resolution: {integrity: sha512-/4gczpKe0+VyL7/pYZWFEs7kCyx2QwcEQaPf0I3QK/3jfw9I9s6UGC3ukTazDrPHEmXWe5Q3IONFfFWMM88d4Q==} + '@drop-oss/droplet-linux-riscv64-gnu@5.3.1': + resolution: {integrity: sha512-Lwf9elNVmboa6ktm4KaHsqYymOpBjpDS4W37+CG/m0u3krmPmnZDMPXHRx5f5920tG35UWsziH/DnYDAvs6QLg==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - '@drop-oss/droplet-linux-x64-gnu@5.1.1': - resolution: {integrity: sha512-iddV2q/JSIxUEgNNncz5vOy1NNnOyHi09WpEm1/Q1/EOXVcQwBMchnqRCJdw980MT4YiB3qJlx3toKMADBomQg==} + '@drop-oss/droplet-linux-x64-gnu@5.3.1': + resolution: {integrity: sha512-NpR1i+bGSHFS7RbBz4RvGjhinUlZVg/Ne3hCzOqZ641XZgjlsdL7OD4DGeC0oYgOFpjazogAimNA7JTZi5LZcg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@drop-oss/droplet-linux-x64-musl@5.1.1': - resolution: {integrity: sha512-nZrm3rFgX0UvpvqQvvoJSvFqRKOUvSV0N7CbMhEitCoMVMvoBCBoHw66H7Nq8pcj1uUuwcBMVBS5M2wtmvSFHA==} + '@drop-oss/droplet-linux-x64-musl@5.3.1': + resolution: {integrity: sha512-LLH9U1q+rPFUTCHFNoGxmIC9YFsmYMes1F7RDvHTYoZdUGtEX3zd2AuTbQhh4XM/AbKU5Iu4N9hPxJZ/0v63SA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@drop-oss/droplet-win32-arm64-msvc@5.1.1': - resolution: {integrity: sha512-I9YnRazsP+L32Za4ISPC0EXPA3nMoz296CGOleH4LyeDSsg+QSqwQ3OchNBUyvGWESGfLWJcdjtfZQU2FPdfYg==} + '@drop-oss/droplet-win32-arm64-msvc@5.3.1': + resolution: {integrity: sha512-j4fQ9/2emaxNENMha6Y4MhiSgb6iVyq7KttNIZOQeNsGxGfUdpuJOeUYJwPGPFJzBxPD7uYcWaqwMP9LdB7+vg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@drop-oss/droplet-win32-x64-msvc@5.1.1': - resolution: {integrity: sha512-xfwcEnaSY0lYpTj/L0+OW9zSZLBlTYVb0JNmue3ezgB1IRqAsLpPUAeS8wGhdnG0m0r2qh+lpwNCc9RNpfJX9w==} + '@drop-oss/droplet-win32-x64-msvc@5.3.1': + resolution: {integrity: sha512-eN0WwtZcyb83O8eRpcHG3O9NMsTMaSPrgHWNLy0RU1TDbS+Sb52qtG9jIwE/Vv1IX3Pd5prgiLETtUK0Y8c5ag==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@drop-oss/droplet@5.1.1': - resolution: {integrity: sha512-JW8IhfVFkcM/ldUUDv9WRsyJrLy7rZXh6mTb9tjQ1Nk0G6JBfxJiA0veBUdjQl1DmGOujFEamNoPEiQLZAPcFA==} + '@drop-oss/droplet@5.3.1': + resolution: {integrity: sha512-8tHYSsAk5tFGkJ9FX3S0ikHlj/rnUvedWR9OpP7KC3sRQmH1zmTA8Z9QxeMFSRiWlK57JY+mcU/qQO9LMLqJ3g==} engines: {node: '>= 10'} '@dxup/nuxt@0.2.2': @@ -2447,16 +2450,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} @@ -2468,12 +2487,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} @@ -2481,10 +2510,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: @@ -7110,48 +7150,48 @@ snapshots: jsonfile: 5.0.0 universalify: 0.1.2 - '@drop-oss/droplet-darwin-arm64@5.1.1': + '@drop-oss/droplet-darwin-arm64@5.3.1': optional: true - '@drop-oss/droplet-darwin-universal@5.1.1': + '@drop-oss/droplet-darwin-universal@5.3.1': optional: true - '@drop-oss/droplet-darwin-x64@5.1.1': + '@drop-oss/droplet-darwin-x64@5.3.1': optional: true - '@drop-oss/droplet-linux-arm64-gnu@5.1.1': + '@drop-oss/droplet-linux-arm64-gnu@5.3.1': optional: true - '@drop-oss/droplet-linux-arm64-musl@5.1.1': + '@drop-oss/droplet-linux-arm64-musl@5.3.1': optional: true - '@drop-oss/droplet-linux-riscv64-gnu@5.1.1': + '@drop-oss/droplet-linux-riscv64-gnu@5.3.1': optional: true - '@drop-oss/droplet-linux-x64-gnu@5.1.1': + '@drop-oss/droplet-linux-x64-gnu@5.3.1': optional: true - '@drop-oss/droplet-linux-x64-musl@5.1.1': + '@drop-oss/droplet-linux-x64-musl@5.3.1': optional: true - '@drop-oss/droplet-win32-arm64-msvc@5.1.1': + '@drop-oss/droplet-win32-arm64-msvc@5.3.1': optional: true - '@drop-oss/droplet-win32-x64-msvc@5.1.1': + '@drop-oss/droplet-win32-x64-msvc@5.3.1': optional: true - '@drop-oss/droplet@5.1.1': + '@drop-oss/droplet@5.3.1': optionalDependencies: - '@drop-oss/droplet-darwin-arm64': 5.1.1 - '@drop-oss/droplet-darwin-universal': 5.1.1 - '@drop-oss/droplet-darwin-x64': 5.1.1 - '@drop-oss/droplet-linux-arm64-gnu': 5.1.1 - '@drop-oss/droplet-linux-arm64-musl': 5.1.1 - '@drop-oss/droplet-linux-riscv64-gnu': 5.1.1 - '@drop-oss/droplet-linux-x64-gnu': 5.1.1 - '@drop-oss/droplet-linux-x64-musl': 5.1.1 - '@drop-oss/droplet-win32-arm64-msvc': 5.1.1 - '@drop-oss/droplet-win32-x64-msvc': 5.1.1 + '@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: @@ -7955,7 +7995,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.24)(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.24)(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3)': dependencies: '@antfu/install-pkg': 1.1.0 '@clack/prompts': 0.11.0 @@ -7969,7 +8009,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)) @@ -7989,17 +8029,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.24)(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.24)(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.24)(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.24)(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 @@ -9194,15 +9234,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 @@ -9217,6 +9275,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) @@ -9233,6 +9293,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)) @@ -9244,11 +9319,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.24(typescript@5.8.3))': dependencies: hookable: 5.5.3 @@ -10677,7 +10768,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 @@ -10690,7 +10781,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 diff --git a/rules/no-prisma-delete.mts b/rules/no-prisma-delete.mts index aa8546f3..22e8b817 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 0c8744a5..02920e6e 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 0a9fe211..b6c120ee 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 9e0260f4..8de52861 100644 --- a/server/api/v1/admin/company/[id]/game.patch.ts +++ b/server/api/v1/admin/company/[id]/game.patch.ts @@ -20,7 +20,7 @@ export default defineEventHandler(async (h3) => { const action = body.action === "developed" ? "developers" : "publishers"; const actionType = body.enabled ? "connect" : "disconnect"; - await prisma.game.update({ + const { count } = await prisma.game.updateMany({ where: { id: body.id, }, @@ -33,5 +33,8 @@ export default defineEventHandler(async (h3) => { }, }); + if (count == 0) + throw createError({ statusCode: 404, message: "Company not found" }); + return; }); diff --git a/server/api/v1/admin/company/[id]/game.post.ts b/server/api/v1/admin/company/[id]/game.post.ts index f8731186..0f56d83f 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 0a4cefdb..cba9876b 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 74511e29..5c3ba1de 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/game/[id]/index.patch.ts b/server/api/v1/admin/game/[id]/index.patch.ts index 410adeeb..0b51adf0 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 cd1dcbc6..3d46b2e2 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 d7192ad5..83f14328 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/image/index.delete.ts b/server/api/v1/admin/game/image/index.delete.ts index d89ae76a..546d58fb 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 e230e9fd..fa4c2004 100644 --- a/server/api/v1/admin/game/image/index.post.ts +++ b/server/api/v1/admin/game/image/index.post.ts @@ -42,7 +42,7 @@ export default defineEventHandler(async (h3) => { throw createError({ statusCode: 400, statusMessage: "Invalid game ID" }); } - const result = await prisma.game.update({ + const result = (await prisma.game.updateManyAndReturn({ where: { id: gameId, }, @@ -51,7 +51,7 @@ export default defineEventHandler(async (h3) => { 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 index 2c9d1340..59f5938e 100644 --- a/server/api/v1/admin/game/version/index.patch.ts +++ b/server/api/v1/admin/game/version/index.patch.ts @@ -53,12 +53,10 @@ export default defineEventHandler<{ body: typeof UpdateVersionOrder }>( await prisma.$transaction( versions.map((version, versionIndex) => - prisma.gameVersion.update({ + prisma.gameVersion.updateMany({ where: { - gameId_versionId: { - gameId: gameId, - versionId: version.versionId, - }, + gameId: gameId, + versionId: version.versionId, }, data: { versionIndex: versionIndex, diff --git a/server/api/v1/admin/import/version/index.post.ts b/server/api/v1/admin/import/version/index.post.ts index ab9ad1cb..1bea3289 100644 --- a/server/api/v1/admin/import/version/index.post.ts +++ b/server/api/v1/admin/import/version/index.post.ts @@ -8,6 +8,7 @@ import { parsePlatform } from "~/server/internal/utils/parseplatform"; const ImportVersion = type({ id: "string", version: "string", + displayName: "string?", platform: "string", launch: "string = ''", diff --git a/server/api/v1/admin/library/sources/index.patch.ts b/server/api/v1/admin/library/sources/index.patch.ts index 7cec3279..325b25a2 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, }, + }) + ).at(0); + if (!updatedSource) + throw createError({ + statusCode: 404, + message: "Library source not found", }); - libraryManager.removeLibrary(source.id); - libraryManager.addLibrary(newLibrary); + libraryManager.removeLibrary(source.id); + libraryManager.addLibrary(newLibrary); - const workingSource: WorkingLibrarySource = { - ...updatedSource, - working: true, - }; + const workingSource: WorkingLibrarySource = { + ...updatedSource, + working: true, + }; - return workingSource; - } catch (e) { - throw createError({ - statusCode: 400, - statusMessage: `Failed to create source: ${e}`, - }); - } + return workingSource; }, ); diff --git a/server/api/v1/notifications/[id]/read.post.ts b/server/api/v1/notifications/[id]/read.post.ts index 4ffe007e..cc286e71 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/internal/clients/ca-store.ts b/server/internal/clients/ca-store.ts index a424731b..9faa3498 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 868a9f62..4bebb812 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 dfbdf0d2..badbb2db 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/news/index.ts b/server/internal/news/index.ts index 694ff268..b9ee8e97 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 7b76f90e..665c4b4c 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/tasks/group.ts b/server/internal/tasks/group.ts index 3e6074e2..f914272a 100644 --- a/server/internal/tasks/group.ts +++ b/server/internal/tasks/group.ts @@ -11,9 +11,6 @@ export const taskGroups = { "check:update": { concurrency: false, }, - "check:integrity": { - concurrency: false, - }, "import:game": { concurrency: true, }, diff --git a/server/internal/tasks/index.ts b/server/internal/tasks/index.ts index 951d223d..d24cf0e4 100644 --- a/server/internal/tasks/index.ts +++ b/server/internal/tasks/index.ts @@ -7,7 +7,6 @@ import cleanupInvites from "./registry/invitations"; import cleanupSessions from "./registry/sessions"; import checkUpdate from "./registry/update"; import cleanupObjects from "./registry/objects"; -import checkIntegrity from "./registry/integrity"; import { taskGroups, type TaskGroup } from "./group"; import prisma from "../db/database"; import { type } from "arktype"; @@ -57,7 +56,6 @@ class TaskHandler { ]; private weeklyScheduledTasks: TaskGroup[] = [ "cleanup:objects", - "check:integrity", ]; constructor() { @@ -66,7 +64,6 @@ class TaskHandler { this.saveScheduledTask(cleanupSessions); this.saveScheduledTask(checkUpdate); this.saveScheduledTask(cleanupObjects); - this.saveScheduledTask(checkIntegrity); //this.saveScheduledTask(debug); } diff --git a/server/internal/tasks/registry/integrity.ts b/server/internal/tasks/registry/integrity.ts deleted file mode 100644 index 7a42ddc4..00000000 --- a/server/internal/tasks/registry/integrity.ts +++ /dev/null @@ -1,83 +0,0 @@ -import prisma from "~/server/internal/db/database"; -import { defineDropTask } from ".."; -import libraryManager from "../../library"; -import crypto from "crypto"; - -export default defineDropTask({ - buildId: () => `check:integrity:${new Date().toISOString()}`, - name: "Integrity check", - acls: ["system:maintenance:read"], - taskGroup: "check:integrity", - async run({ progress, logger }) { - const versions = await prisma.gameVersion.findMany({ - include: { game: true }, - }); - logger.info(`checking integrity for ${versions.length} versions...`); - - let currentProgress = 0; - for (const version of versions) { - const minProgress = (currentProgress / versions.length) * 100; - const maxProgress = ((currentProgress + 1) / versions.length) * 100; - const progressBudget = maxProgress - minProgress; - progress(minProgress); - logger.info( - `starting integrity check for ${version.game.mName} ${version.versionId}`, - ); - - const manifest = JSON.parse( - version.dropletManifest as string, - ); - const manifestChunks = Object.entries(manifest); - let valid = true; - - - if (!valid) { - logger.info( - `integrity check for ${version.game.mName} ${version.versionId} failed, reimporting...`, - ); - progress(minProgress); - const library = await libraryManager.getLibrary( - version.game.libraryId!, - ); - if (!library) - throw new Error( - `Library doesn't exist for ${version.game.mName} ${version.versionId}`, - ); - - const manifest = await library.generateDropletManifest( - version.game.libraryPath, - version.versionPath, - (_, manifestProgress) => { - const currentManifestProgress = - minProgress + progressBudget * (manifestProgress / 100); - progress(currentManifestProgress); - }, - (_, _logline) => { - //logger.info(`[import:${version.gameId}] ${logline}`); - }, - ); - - await prisma.gameVersion.update({ - where: { - gameId_versionId: { - gameId: version.gameId, - versionId: version.versionId, - }, - }, - data: { - dropletManifest: manifest, - }, - }); - } else { - logger.info( - `integrity check for ${version.game.mName} ${version.versionId} succeeded!`, - ); - } - - currentProgress++; - } - - logger.info("integrity check done!"); - progress(100); - }, -}); diff --git a/torrential b/torrential index 990bd6cf..57f0b9b5 160000 --- a/torrential +++ b/torrential @@ -1 +1 @@ -Subproject commit 990bd6cf34374c66f2f4ae3097b5dd5a2a3b4e52 +Subproject commit 57f0b9b548b8fe8750e9250c5ee7bfb936764075 From 54dde50526e2671736bd8a11993c8c3290e96545 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Sat, 20 Dec 2025 20:30:01 +1100 Subject: [PATCH 17/47] fix: lint --- components/AccountSidebar.vue | 7 ++++++- components/GameEditor/Version.vue | 4 +++- nuxt.config.ts | 2 +- rules/no-prisma-delete.mts | 2 +- server/api/v1/admin/company/[id]/icon.post.ts | 2 +- server/api/v1/admin/game/image/index.post.ts | 20 ++++++++++--------- .../v1/admin/import/version/preload.get.ts | 2 +- server/api/v1/client/capability/index.post.ts | 4 +++- server/api/v1/client/user/webtoken.post.ts | 2 +- server/internal/acls/descriptions.ts | 4 ++-- server/internal/library/index.ts | 2 +- server/internal/library/manifest.ts | 2 +- server/internal/tasks/index.ts | 4 +--- 13 files changed, 33 insertions(+), 24 deletions(-) diff --git a/components/AccountSidebar.vue b/components/AccountSidebar.vue index 38dc1b38..1a9c6b61 100644 --- a/components/AccountSidebar.vue +++ b/components/AccountSidebar.vue @@ -54,7 +54,12 @@ const notifications = useNotifications(); const { t } = useI18n(); const navigation: (NavigationItem & { icon: Component; count?: number })[] = [ - { label: t("account.home.title"), route: "/account", icon: HomeIcon, prefix: "/account" }, + { + label: t("account.home.title"), + route: "/account", + icon: HomeIcon, + prefix: "/account", + }, { label: t("account.security.title"), route: "/account/security", diff --git a/components/GameEditor/Version.vue b/components/GameEditor/Version.vue index 2380790e..bcaf6176 100644 --- a/components/GameEditor/Version.vue +++ b/components/GameEditor/Version.vue @@ -60,7 +60,9 @@
{{ item.displayName || item.versionPath }}
- {{ item.versionId }} + {{ + item.versionId + }}
{ await dump(); throw createError({ statusCode: 404, message: "Company not found" }); } - + await pull(); return { id: id }; diff --git a/server/api/v1/admin/game/image/index.post.ts b/server/api/v1/admin/game/image/index.post.ts index fa4c2004..4f176137 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.updateManyAndReturn({ - where: { - id: gameId, - }, - data: { - mImageLibraryObjectIds: { - push: ids, + const result = ( + await prisma.game.updateManyAndReturn({ + where: { + id: gameId, }, - }, - })).at(0); + data: { + mImageLibraryObjectIds: { + push: ids, + }, + }, + }) + ).at(0); await pull(); return result; diff --git a/server/api/v1/admin/import/version/preload.get.ts b/server/api/v1/admin/import/version/preload.get.ts index bcd7c45c..5e0476ce 100644 --- a/server/api/v1/admin/import/version/preload.get.ts +++ b/server/api/v1/admin/import/version/preload.get.ts @@ -29,7 +29,7 @@ export default defineEventHandler(async (h3) => { } catch (e) { throw createError({ statusCode: 500, - message: `Failed to fetch preload information for ${gameId}: ${e}` + message: `Failed to fetch preload information for ${gameId}: ${e}`, }); } }); diff --git a/server/api/v1/client/capability/index.post.ts b/server/api/v1/client/capability/index.post.ts index 4326edeb..b9dd0d5a 100644 --- a/server/api/v1/client/capability/index.post.ts +++ b/server/api/v1/client/capability/index.post.ts @@ -22,7 +22,9 @@ export default defineClientEventHandler( statusMessage: "configuration must be an object", }); - const capability = validCapabilities.find((v) => v.toLowerCase() === rawCapability.toLowerCase()); + const capability = validCapabilities.find( + (v) => v.toLowerCase() === rawCapability.toLowerCase(), + ); if (!capability) throw createError({ diff --git a/server/api/v1/client/user/webtoken.post.ts b/server/api/v1/client/user/webtoken.post.ts index a7385f82..abae355b 100644 --- a/server/api/v1/client/user/webtoken.post.ts +++ b/server/api/v1/client/user/webtoken.post.ts @@ -14,7 +14,7 @@ export default defineClientEventHandler( "store:read", "collections:read", "object:read", - "settings:read" + "settings:read", ]; const token = await prisma.aPIToken.create({ diff --git a/server/internal/acls/descriptions.ts b/server/internal/acls/descriptions.ts index be4ad0d9..58b81663 100644 --- a/server/internal/acls/descriptions.ts +++ b/server/internal/acls/descriptions.ts @@ -103,7 +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" + "depot:delete": "Remove a download depot", }; diff --git a/server/internal/library/index.ts b/server/internal/library/index.ts index af734797..08464a6b 100644 --- a/server/internal/library/index.ts +++ b/server/internal/library/index.ts @@ -133,7 +133,7 @@ class LibraryManager { const games = await prisma.game.findMany({ include: { library: true, - versions: true + versions: true, }, orderBy: { mName: "asc", diff --git a/server/internal/library/manifest.ts b/server/internal/library/manifest.ts index 1571b9b8..c6f5d06a 100644 --- a/server/internal/library/manifest.ts +++ b/server/internal/library/manifest.ts @@ -23,5 +23,5 @@ export type V2FileEntry = { }; export function castManifest(manifest: JsonValue): Manifest { - return JSON.parse(manifest as string) as Manifest; + return JSON.parse(manifest as string) as Manifest; } diff --git a/server/internal/tasks/index.ts b/server/internal/tasks/index.ts index d24cf0e4..08751cd2 100644 --- a/server/internal/tasks/index.ts +++ b/server/internal/tasks/index.ts @@ -54,9 +54,7 @@ class TaskHandler { "cleanup:sessions", "check:update", ]; - private weeklyScheduledTasks: TaskGroup[] = [ - "cleanup:objects", - ]; + private weeklyScheduledTasks: TaskGroup[] = ["cleanup:objects"]; constructor() { // register the cleanup invitations task From 1c06081bb6f33bde0f3489a8ea534db68785c722 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Sat, 20 Dec 2025 22:38:44 +1100 Subject: [PATCH 18/47] feat: v4 featureset: emulators, multi-launch commands --- .vscode/settings.json | 1 + components/AccountSidebar.vue | 20 ++-- components/GameEditor/Version.vue | 13 ++- pages/store/[id]/index.vue | 3 +- .../migration.sql | 60 ++++++++++++ .../migration.sql | 14 +++ .../20251220103245_multi_setups/migration.sql | 26 ++++++ prisma/models/content.prisma | 47 ++++++++-- server/api/v1/admin/game/[id]/index.get.ts | 21 +++-- .../api/v1/admin/game/version/index.patch.ts | 16 ++-- .../api/v1/admin/import/version/index.post.ts | 61 +++++-------- .../[id]/version/[versionid]/index.get.ts | 4 + server/api/v1/client/game/versions.get.ts | 4 + server/api/v1/games/[id]/index.get.ts | 7 +- server/api/v1/store/index.get.ts | 14 ++- server/internal/library/index.ts | 91 ++++++++++--------- 16 files changed, 278 insertions(+), 124 deletions(-) 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 diff --git a/.vscode/settings.json b/.vscode/settings.json index 26d84d66..a33f7edb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,6 +18,7 @@ }, // prioritize ArkType's "type" for autoimports "typescript.preferences.autoImportSpecifierExcludeRegexes": ["^(node:)?os$"], + "typescript.experimental.useTsgo": true, // i18n Ally settings "i18n-ally.sortKeys": true, "i18n-ally.keepFulfilled": true, diff --git a/components/AccountSidebar.vue b/components/AccountSidebar.vue index 1a9c6b61..b4e2b89c 100644 --- a/components/AccountSidebar.vue +++ b/components/AccountSidebar.vue @@ -53,7 +53,9 @@ import type { Component } from "vue"; const notifications = useNotifications(); const { t } = useI18n(); -const navigation: (NavigationItem & { icon: Component; count?: number })[] = [ +const navigation: Ref< + (NavigationItem & { icon: Component; count?: number })[] +> = computed(() => [ { label: t("account.home.title"), route: "/account", @@ -72,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", @@ -79,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/GameEditor/Version.vue b/components/GameEditor/Version.vue index bcaf6176..c4ce1602 100644 --- a/components/GameEditor/Version.vue +++ b/components/GameEditor/Version.vue @@ -74,7 +74,9 @@
hasDeleted.value || props.unimportedVersions.length > 0, ); -type GameVersionModelWithSize = GameVersionModel & { size: number }; +type GameVersionModelWithSize = GameVersionModel & { size: number } & { + launches: LaunchConfiguration[]; + setup: SetupConfiguration; +}; type GameAndVersions = GameModel & { versions: GameVersionModelWithSize[]; diff --git a/pages/store/[id]/index.vue b/pages/store/[id]/index.vue index 5fd4fa4d..69a8e052 100644 --- a/pages/store/[id]/index.vue +++ b/pages/store/[id]/index.vue @@ -301,7 +301,8 @@ 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 as PlatformClient)) + .flat() .flat() .filter((e, i, u) => u.indexOf(e) === i); 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 00000000..a2a30995 --- /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 00000000..aabff04b --- /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 00000000..e78fd411 --- /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/models/content.prisma b/prisma/models/content.prisma index bf1c1189..8414a124 100644 --- a/prisma/models/content.prisma +++ b/prisma/models/content.prisma @@ -91,15 +91,10 @@ model GameVersion { created DateTime @default(now()) - platform Platform - - 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) + launches LaunchConfiguration[] + setups SetupConfiguration[] - umuIdOverride String? + onlySetup Boolean @default(false) dropletManifest Json // Results from droplet @@ -109,6 +104,42 @@ model GameVersion { @@id([gameId, versionId]) } +model SetupConfiguration { + setupId String @id @default(uuid()) + + command String + args String[] + + platform Platform + + gameId String + versionId String + gameVersion GameVersion @relation(fields: [gameId, versionId], references: [gameId, versionId]) +} + +model LaunchConfiguration { + launchId String @id @default(uuid()) + + name String + + command String + args String[] + + platform Platform + + // For emulation targets + executorId String? + executor LaunchConfiguration? @relation(fields: [executorId], references: [launchId], name: "executor") + + umuIdOverride String? + + gameId String + versionId String + gameVersion GameVersion @relation(fields: [gameId, versionId], references: [gameId, versionId]) + + executions LaunchConfiguration[] @relation("executor") +} + // A save slot for a game model SaveSlot { gameId String diff --git a/server/api/v1/admin/game/[id]/index.get.ts b/server/api/v1/admin/game/[id]/index.get.ts index e62fbe95..8e8ea424 100644 --- a/server/api/v1/admin/game/[id]/index.get.ts +++ b/server/api/v1/admin/game/[id]/index.get.ts @@ -3,6 +3,14 @@ import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; import libraryManager from "~/server/internal/library"; +async function getGameVersionSize>(gameId: string, version: T) { + const size = await libraryManager.getGameVersionSize( + gameId, + version.versionId, + ); + return { ...version, size }; +} + export default defineEventHandler(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, ["game:read"]); if (!allowed) throw createError({ statusCode: 403 }); @@ -21,6 +29,10 @@ export default defineEventHandler(async (h3) => { omit: { dropletManifest: true, }, + include: { + launches: true, + setups: true, + }, }, tags: true, }, @@ -29,16 +41,9 @@ 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.versionId, - ); - 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/version/index.patch.ts b/server/api/v1/admin/game/version/index.patch.ts index 59f5938e..19073674 100644 --- a/server/api/v1/admin/game/version/index.patch.ts +++ b/server/api/v1/admin/game/version/index.patch.ts @@ -26,7 +26,7 @@ export default defineEventHandler<{ body: typeof UpdateVersionOrder }>( versionId: true, versionIndex: true, delta: true, - platform: true, + launches: { select: { platform: true } }, }, }); @@ -43,12 +43,14 @@ export default defineEventHandler<{ body: typeof UpdateVersionOrder }>( // 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.versionId}" requires a base version to apply the delta to.`, - }); - has[version.platform] = true; + 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( diff --git a/server/api/v1/admin/import/version/index.post.ts b/server/api/v1/admin/import/version/index.post.ts index 1bea3289..dcd8ec32 100644 --- a/server/api/v1/admin/import/version/index.post.ts +++ b/server/api/v1/admin/import/version/index.post.ts @@ -5,62 +5,54 @@ 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?", platform: "string", - launch: "string = ''", - launchArgs: "string = ''", - setup: "string = ''", - setupArgs: "string = ''", + launch: "string?", + launchArgs: "string?", + setup: "string?", + setupArgs: "string?", onlySetup: "boolean = false", delta: "boolean = false", - umuId: "string = ''", + 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); + const platformParsed = parsePlatform(body.platform); if (!platformParsed) throw createError({ statusCode: 400, statusMessage: "Invalid platform." }); - if (delta) { + if (body.delta) { const validOverlayVersions = await prisma.gameVersion.count({ - where: { gameId: id, platform: platformParsed, delta: false }, + where: { + gameId: body.id, + delta: false, + launches: { some: { platform: platformParsed } }, + }, }); if (validOverlayVersions == 0) throw createError({ statusCode: 400, - statusMessage: - "Update mode requires a pre-existing version for this platform.", + statusMessage: "Update mode requires a pre-existing version.", }); } - if (onlySetup) { - if (!setup) + if (body.onlySetup) { + if (!body.setup) throw createError({ statusCode: 400, statusMessage: 'Setup required in "setup mode".', }); } else { - if (!delta && !launch) + if (!body.delta && !body.launch) throw createError({ statusCode: 400, statusMessage: "Launch executable is required for non-update versions", @@ -68,18 +60,11 @@ export default defineEventHandler(async (h3) => { } // 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/client/game/[id]/version/[versionid]/index.get.ts b/server/api/v1/client/game/[id]/version/[versionid]/index.get.ts index 6d0bfb84..9848480b 100644 --- a/server/api/v1/client/game/[id]/version/[versionid]/index.get.ts +++ b/server/api/v1/client/game/[id]/version/[versionid]/index.get.ts @@ -18,6 +18,10 @@ export default defineClientEventHandler(async (h3) => { versionId: version, }, }, + include: { + launches: true, + setups: true, + } }); if (!gameVersion) diff --git a/server/api/v1/client/game/versions.get.ts b/server/api/v1/client/game/versions.get.ts index cd605444..6219a6e9 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/games/[id]/index.get.ts b/server/api/v1/games/[id]/index.get.ts index ec0b3f40..85b6950e 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/store/index.get.ts b/server/api/v1/store/index.get.ts index 26649f3a..5d98321e 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/internal/library/index.ts b/server/internal/library/index.ts index 08464a6b..b4670b97 100644 --- a/server/internal/library/index.ts +++ b/server/internal/library/index.ts @@ -18,6 +18,7 @@ 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") @@ -245,18 +246,7 @@ class LibraryManager { async importVersion( gameId: string, versionPath: string, - metadata: { - platform: string; - onlySetup: boolean; - - setup: string; - setupArgs: string; - launch: string; - launchArgs: string; - delta: boolean; - - umuId: string; - }, + metadata: typeof ImportVersion.infer, ) { const taskId = createVersionImportTaskId(gameId, versionPath); @@ -300,42 +290,53 @@ class LibraryManager { }); // Then, create the database object - if (metadata.onlySetup) { - await prisma.gameVersion.create({ - data: { - gameId: gameId, - versionPath, - 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, - versionPath, - 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: true, + setups: { + createMany: metadata.setup + ? { + data: [ + { + command: metadata.setup, + args: metadata.launchArgs?.split(" ") ?? [], + platform: platform, + }, + ], + } + : { data: [] }, }, - }); - } + launches: { + createMany: + !metadata.onlySetup && metadata.launch + ? { + data: [ + { + name: "default", + command: metadata.launch, + args: metadata.launchArgs?.split(" ") ?? [], + platform: platform, + }, + ], + } + : { data: [] }, + }, + }, + }); logger.info("Successfully created version!"); notificationSystem.systemPush({ From 5d2ea28a8535c6c15465a66d91a7bc2215eeb470 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Sat, 20 Dec 2025 22:39:40 +1100 Subject: [PATCH 19/47] fix: lint --- server/api/v1/admin/game/[id]/index.get.ts | 8 ++++++-- .../v1/client/game/[id]/version/[versionid]/index.get.ts | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/server/api/v1/admin/game/[id]/index.get.ts b/server/api/v1/admin/game/[id]/index.get.ts index 8e8ea424..a9c8f85c 100644 --- a/server/api/v1/admin/game/[id]/index.get.ts +++ b/server/api/v1/admin/game/[id]/index.get.ts @@ -3,7 +3,9 @@ import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; import libraryManager from "~/server/internal/library"; -async function getGameVersionSize>(gameId: string, version: T) { +async function getGameVersionSize< + T extends Omit, +>(gameId: string, version: T) { const size = await libraryManager.getGameVersionSize( gameId, version.versionId, @@ -43,7 +45,9 @@ export default defineEventHandler(async (h3) => { const gameWithVersionSize = { ...game, - versions: await Promise.all(game.versions.map((v) => getGameVersionSize(gameId, v))), + versions: await Promise.all( + game.versions.map((v) => getGameVersionSize(gameId, v)), + ), }; const unimportedVersions = await libraryManager.fetchUnimportedGameVersions( diff --git a/server/api/v1/client/game/[id]/version/[versionid]/index.get.ts b/server/api/v1/client/game/[id]/version/[versionid]/index.get.ts index 9848480b..9c689b4a 100644 --- a/server/api/v1/client/game/[id]/version/[versionid]/index.get.ts +++ b/server/api/v1/client/game/[id]/version/[versionid]/index.get.ts @@ -21,7 +21,7 @@ export default defineClientEventHandler(async (h3) => { include: { launches: true, setups: true, - } + }, }); if (!gameVersion) From 72a5226f2c315e4767c8435fde1d08e82f7b64fb Mon Sep 17 00:00:00 2001 From: DecDuck Date: Sun, 21 Dec 2025 09:34:44 +1100 Subject: [PATCH 20/47] fix: mobile ui for game editor --- components/GameEditor/Metadata.vue | 6 +++--- pages/admin/library/[id]/index.vue | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/components/GameEditor/Metadata.vue b/components/GameEditor/Metadata.vue index be224afd..11396bf1 100644 --- a/components/GameEditor/Metadata.vue +++ b/components/GameEditor/Metadata.vue @@ -1,7 +1,7 @@ + + diff --git a/pages/auth/signin.vue b/pages/auth/signin.vue index 4feba93c..1b9cf3c0 100644 --- a/pages/auth/signin.vue +++ b/pages/auth/signin.vue @@ -9,10 +9,18 @@

- {{ $t("auth.signin.title") }} + {{ + superlevel + ? "Sign in to access protected action" + : $t("auth.signin.title") + }}

- {{ $t("auth.signin.noAccount") }} + {{ + superlevel + ? "We need you to sign in again for security reasons while attempting to access more sensitive actions." + : $t("auth.signin.noAccount") + }}

@@ -49,11 +57,14 @@ import DropLogo from "~/components/DropLogo.vue"; const { t } = useI18n(); const enabledAuths = await $dropFetch("/api/v1/auth"); +const route = useRoute(); +const superlevel = route.query.superlevel; + definePageMeta({ layout: false, }); useHead({ - title: t("auth.signin.pageTitle"), + title: superlevel ? "Sign in to access protected action": t("auth.signin.pageTitle"), }); diff --git a/pages/client/code/index.vue b/pages/client/code/index.vue index 266f03ca..8ad7a806 100644 --- a/pages/client/code/index.vue +++ b/pages/client/code/index.vue @@ -8,20 +8,7 @@ {{ $t("auth.code.description") }}

- +
([]); - const router = useRouter(); const loading = ref(false); const error = ref(undefined); -function keydown(index: number, event: KeyboardEvent) { - if (event.key === "Backspace" && !code.value[index] && index > 0) { - codeElements.value![index - 1].focus(); - } -} - -function input(index: number) { - if (codeElements.value === null) return; - const v = code.value[index] ?? ""; - if (v.length > 1) code.value[index] = v[0]; - - if (!(index + 1 >= codeElements.value.length) && v) { - codeElements.value[index + 1].focus(); - } - - if (!(index - 1 < 0) && !v) { - codeElements.value[index - 1].focus(); - } - - console.log(index, codeLength - 1); - if (index == codeLength - 1) { - const assembledCode = code.value.join(""); - if (assembledCode.length == codeLength) { - complete(assembledCode); - } - } -} - -function select(index: number) { - if (!codeElements.value) return; - if (index >= codeElements.value.length) return; - codeElements.value[index].select(); -} - -function paste(index: number, event: ClipboardEvent) { - const newCode = event.clipboardData!.getData("text/plain"); - for (let i = 0; i < newCode.length && i < codeLength; i++) { - code.value[i] = newCode[i]; - codeElements.value![i].focus(); - } - event.preventDefault(); -} - async function complete(code: string) { loading.value = true; try { diff --git a/pages/mfa/setup/totp.vue b/pages/mfa/setup/totp.vue new file mode 100644 index 00000000..8d540a99 --- /dev/null +++ b/pages/mfa/setup/totp.vue @@ -0,0 +1,91 @@ +
+
+

+ Set up your authenticator +

+

+ Use your TOTP authenticator, like Google Authenticator, Aegis, or + Bitwarden, to add 2FA to your Drop account. +

+
+

+ Enter the generated code to enable TOTP +

+
+ +
+
+
+
+
+
+

+ {{ error }} +

+
+
+
+
+
+
+
+
+
+

+ {{ totpSecrets?.secret }} +

+
+
+ + + + diff --git a/pages/mfa/setup/webauthn.vue b/pages/mfa/setup/webauthn.vue new file mode 100644 index 00000000..547b00c8 --- /dev/null +++ b/pages/mfa/setup/webauthn.vue @@ -0,0 +1,167 @@ + + + diff --git a/pages/mfa/totp.vue b/pages/mfa/totp.vue new file mode 100644 index 00000000..56188271 --- /dev/null +++ b/pages/mfa/totp.vue @@ -0,0 +1,7 @@ + + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbb5bdf4..d9c1bb47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: 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 @@ -71,6 +74,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 +95,9 @@ importers: 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 @@ -401,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==} @@ -3001,6 +3014,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + base64url@3.0.1: + resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==} + engines: {node: '>=6.0.0'} + baseline-browser-mapping@2.8.29: resolution: {integrity: sha512-sXdt2elaVnhpDNRDz+1BDx1JQoJRuNk7oVlAlbGiFkLikHCAQiccexF/9e91zVi6RCgqspl04aP+6Cnl9zRLrA==} hasBin: true @@ -3136,6 +3153,14 @@ packages: 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'} @@ -3899,6 +3924,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==} @@ -4423,6 +4451,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'} @@ -4495,6 +4526,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'} @@ -5000,6 +5034,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} @@ -5191,6 +5229,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'} @@ -5949,6 +5990,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'} @@ -7098,6 +7142,8 @@ snapshots: '@colors/colors@1.6.0': {} + '@cto.af/wtf8@0.0.2': {} + '@dabh/diagnostics@2.0.3': dependencies: colorspace: 1.1.4 @@ -9895,6 +9941,8 @@ snapshots: base64-js@1.5.1: {} + base64url@3.0.1: {} + baseline-browser-mapping@2.8.29: {} basic-auth@2.0.1: @@ -10066,6 +10114,14 @@ snapshots: 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 @@ -10909,6 +10965,8 @@ snapshots: exsolve@1.0.8: {} + extensible-custom-error@0.0.7: {} + externality@1.0.2: dependencies: enhanced-resolve: 5.18.2 @@ -11492,6 +11550,8 @@ snapshots: jiti@2.6.1: {} + jose@4.15.9: {} + joycon@3.1.1: {} js-base64@3.7.7: {} @@ -11554,6 +11614,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kjua@0.10.0: {} + kleur@3.0.3: {} kleur@4.1.5: {} @@ -12254,6 +12316,8 @@ snapshots: dependencies: '@babel/parser': 7.28.0 + nofilter@3.1.0: {} + nopt@8.1.0: dependencies: abbrev: 3.0.1 @@ -12627,6 +12691,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: @@ -13449,6 +13520,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 diff --git a/server/api/v1/admin/company/[id]/game.patch.ts b/server/api/v1/admin/company/[id]/game.patch.ts index 8de52861..a85ea69f 100644 --- a/server/api/v1/admin/company/[id]/game.patch.ts +++ b/server/api/v1/admin/company/[id]/game.patch.ts @@ -20,7 +20,10 @@ export default defineEventHandler(async (h3) => { const action = body.action === "developed" ? "developers" : "publishers"; const actionType = body.enabled ? "connect" : "disconnect"; - const { count } = await prisma.game.updateMany({ + const game = await prisma.game.findUnique({ where: { id: body.id } }); + if (!game) throw createError({ statusCode: 404, message: "Game not found" }); + + await prisma.game.update({ where: { id: body.id, }, @@ -33,8 +36,5 @@ export default defineEventHandler(async (h3) => { }, }); - if (count == 0) - throw createError({ statusCode: 404, message: "Company not found" }); - return; }); 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 00000000..a6f7e652 --- /dev/null +++ b/server/api/v1/auth/mfa/index.get.ts @@ -0,0 +1,24 @@ +import sessionHandler from "~/server/internal/session"; +import { type } from "arktype"; +import prisma from "~/server/internal/db/database"; +import { MFAMec } from "~/prisma/client/client"; + +export default defineEventHandler(async (h3) => { + const session = await sessionHandler.getSession(h3); + if (!session || session.level == 0) + throw createError({ + statusCode: 403, + message: "Sign in before completing MFA", + }); + + const linkedMFAMec = await prisma.linkedMFAMec.findMany({ + where: { + userId: session.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 00000000..4ee67017 --- /dev/null +++ b/server/api/v1/auth/mfa/totp.post.ts @@ -0,0 +1,50 @@ +import sessionHandler from "~/server/internal/session"; +import { type } from "arktype"; +import prisma from "~/server/internal/db/database"; +import { MFAMec } from "~/prisma/client/client"; +import { + dropDecodeArrayBase64, + TOTPv1Credentials, +} 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.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.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/user/mfa/index.get.ts b/server/api/v1/user/mfa/index.get.ts index a45242f0..e814c42a 100644 --- a/server/api/v1/user/mfa/index.get.ts +++ b/server/api/v1/user/mfa/index.get.ts @@ -1,6 +1,7 @@ import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; import { MFAMec } from "~/prisma/client/enums"; +import { WebAuthNv1Credentials } from "~/server/internal/auth/webauthn"; export default defineEventHandler(async (h3) => { const userId = await aclManager.getUserIdACL(h3, []); // No ACLs only allows session authentication @@ -10,10 +11,23 @@ export default defineEventHandler(async (h3) => { where: { userId, }, - omit: { - credentials: true, - }, }); - const mfaMecMap = Object.fromEntries(mfaMecs.map((v) => [v.mec, v])); + // 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 + ).credentials.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 index 73d9d595..50a219a6 100644 --- a/server/api/v1/user/mfa/totp/finish.post.ts +++ b/server/api/v1/user/mfa/totp/finish.post.ts @@ -4,8 +4,8 @@ import { hmac, randomBytes } from "otp-io/crypto"; import prisma from "~/server/internal/db/database"; import { MFAMec } from "~/prisma/client/client"; import { - dropDecodeArray, - dropEncodeArray, + dropDecodeArrayBase64, + dropEncodeArrayBase64, TOTPv1Credentials, } from "~/server/internal/auth/totp"; import { createError } from "h3"; @@ -39,7 +39,7 @@ export default defineEventHandler(async (h3) => { throw createError({ statusCode: 400, message: "TOTP not started" }); const secret = (existing.credentials as unknown as TOTPv1Credentials).secret; - const secretKeyBuffer = dropDecodeArray(secret); + const secretKeyBuffer = dropDecodeArrayBase64(secret); const secretKey = new SecretKey(secretKeyBuffer); const code = await totp(hmac, { secret: secretKey }); diff --git a/server/api/v1/user/mfa/totp/start.post.ts b/server/api/v1/user/mfa/totp/start.post.ts index 8ae1ba04..8e428183 100644 --- a/server/api/v1/user/mfa/totp/start.post.ts +++ b/server/api/v1/user/mfa/totp/start.post.ts @@ -5,8 +5,9 @@ import prisma from "~/server/internal/db/database"; import { MFAMec } from "~/prisma/client/client"; import { TOTPv1Credentials, - dropEncodeArray, + 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 @@ -24,11 +25,19 @@ export default defineEventHandler(async (h3) => { }, }, }); - if (existing) - throw createError({ - statusCode: 400, - message: "Cannot add TOTP authentication if already exists.", - }); + + if (existing) { + if (!existing.enabled) { + 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({ @@ -44,11 +53,11 @@ export default defineEventHandler(async (h3) => { mec: MFAMec.TOTP, version: 1, credentials: { - secret: dropEncodeArray(secret.bytes), + secret: dropEncodeArrayBase64(secret.bytes), } satisfies TOTPv1Credentials, enabled: false, }, }); - return { url, secret }; + 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 00000000..4c6b1dea --- /dev/null +++ b/server/api/v1/user/mfa/webauthn/finish.post.ts @@ -0,0 +1,143 @@ +import { ArkErrors, type } from "arktype"; +import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; +import aclManager from "~/server/internal/acls"; +import { decode } from "cbor2"; +import { dropDecodeArrayBase64 } from "~/server/internal/auth/totp"; +import { + getRpId, + WebAuthNv1Credentials, +} from "~/server/internal/auth/webauthn"; +import { createHash } from "node:crypto"; +import prisma from "~/server/internal/db/database"; +import { MFAMec } from "~/prisma/client/enums"; +import cosekey from "parse-cosekey"; + +const CreatePasskey = type({ + name: "string", + clientData: "string", + attestationObject: "string", +}).configure(throwingArktype); + +const ClientData = type({ + type: "'webauthn.create'", + challenge: "string", + origin: "string", +}); + +const AuthData = type({ + fmt: "string", + authData: "TypedArray.Uint8", +}); + +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 clientData = dropDecodeArrayBase64(body.clientData); + const attestationObject = dropDecodeArrayBase64(body.attestationObject); + + 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 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); + + console.log(credentialIdStr, jwk); + + const webauthnMec = + (await prisma.linkedMFAMec.findUnique({ + where: { userId_mec: { userId, mec: MFAMec.WebAuthn } }, + })) ?? + (await prisma.linkedMFAMec.create({ + data: { + userId, + mec: MFAMec.WebAuthn, + credentials: { credentials: [] } satisfies WebAuthNv1Credentials, + version: 1, + }, + })); + + ( + webauthnMec.credentials as unknown as WebAuthNv1Credentials + ).credentials.push({ + id: credentialIdStr, + jwk, + name: body.name, + created: Date.now(), + }); + + 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 00000000..f4bf20e0 --- /dev/null +++ b/server/api/v1/user/mfa/webauthn/start.post.ts @@ -0,0 +1,36 @@ +import aclManager from "~/server/internal/acls"; +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 userId = await aclManager.allowUserSuperlevel(h3); // No ACLs only allows session authentication + if (!userId) + throw createError({ + statusCode: 403, + message: "Not signed in or superlevelled.", + }); + + 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 challenge = crypto.randomUUID().replaceAll("-", ""); + + await sessionHandler.setSessionDataKey(h3, "webauthn/challenge", challenge); + + const rpId = await getRpId(); + + return { + challenge, + rp: { name: "Drop", id: rpId }, + user: { userId, ...user }, + }; +}); diff --git a/server/api/v1/user/superlevel.ts b/server/api/v1/user/superlevel.ts new file mode 100644 index 00000000..802d742b --- /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/internal/acls/index.ts b/server/internal/acls/index.ts index 70367b38..1e426db3 100644 --- a/server/internal/acls/index.ts +++ b/server/internal/acls/index.ts @@ -171,6 +171,7 @@ class ACLManager { if (!session) return undefined; if (session.level < session.requiredLevel) return undefined; if (session.superleveledExpiry === undefined) return undefined; + if (session.superleveledExpiry < Date.now()) return undefined; return session.userId; } @@ -188,7 +189,7 @@ class ACLManager { if (user) { if (!user) return false; if (!user.admin) return false; - if (userSession.level >= userSession.requiredLevel) return false; + if (userSession.level < userSession.requiredLevel) return false; return true; } } diff --git a/server/internal/auth/base32/index.d.ts b/server/internal/auth/base32/index.d.ts new file mode 100644 index 00000000..06355976 --- /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; \ No newline at end of file diff --git a/server/internal/auth/base32/index.js b/server/internal/auth/base32/index.js new file mode 100644 index 00000000..733db8c2 --- /dev/null +++ b/server/internal/auth/base32/index.js @@ -0,0 +1,50 @@ + +// 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); +} \ No newline at end of file diff --git a/server/internal/auth/totp.ts b/server/internal/auth/totp.ts index 6419f31f..5701a4f4 100644 --- a/server/internal/auth/totp.ts +++ b/server/internal/auth/totp.ts @@ -1,12 +1,22 @@ -export function dropEncodeArray(secret: Uint8Array): string { - const decoder = new TextDecoder('utf8'); - return btoa(decoder.decode(secret)); +export function dropEncodeArrayBase64(secret: Uint8Array): string { + return encode(secret); } -export function dropDecodeArray(secret: string): Uint8Array { - const encoder = new TextEncoder(); - return encoder.encode(atob(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, -} \ No newline at end of file + secret: string; +} diff --git a/server/internal/auth/webauthn.ts b/server/internal/auth/webauthn.ts new file mode 100644 index 00000000..b4ab49b8 --- /dev/null +++ b/server/internal/auth/webauthn.ts @@ -0,0 +1,13 @@ +import { systemConfig } from "../config/sys-conf"; + +export async function getRpId() { + const externalUrl = + process.env.WEBAUTHN_DOMAIN ?? (await systemConfig.getExternalUrl()); + const externalUrlParsed = new URL(externalUrl); + + return externalUrlParsed.hostname; +} + +export interface WebAuthNv1Credentials { + credentials: Array<{ id: string; jwk: JsonWebKey, name: string, created: number }>; +} diff --git a/server/internal/session/db.ts b/server/internal/session/db.ts index de23a4c3..02493daa 100644 --- a/server/internal/session/db.ts +++ b/server/internal/session/db.ts @@ -16,9 +16,19 @@ export default function createDBSessionHandler(): SessionProvider { }, create: { token, - ...session, + user: { + connect: { + id: session.userId, + }, + }, + expiresAt: session.expiresAt, + data: session, + }, + + update: { + expiresAt: session.expiresAt, + data: session, }, - update: session, }); return true; }, @@ -39,7 +49,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 8f4c2bf8..3680268f 100644 --- a/server/internal/session/index.ts +++ b/server/internal/session/index.ts @@ -52,7 +52,8 @@ export class SessionHandler { requiredLevel: mfaCount > 0 ? 20 : 10, superleveledExpiry: undefined, }; - session.superleveledExpiry = new Date(Date.now() + SUPERLEVEL_LENGTH); + if (session.level >= session.requiredLevel) + session.superleveledExpiry = Date.now() + SUPERLEVEL_LENGTH; return await this.sessionProvider.setSession(token, session); } @@ -81,6 +82,30 @@ export class SessionHandler { return data; } + async getSessionDataKey(request: MinimumRequestObject, key: string) { + 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: MinimumRequestObject, + key: string, + value: T, + ) { + const token = this.getSessionToken(request); + if (!token) return false; + + const session = await this.sessionProvider.getSession(token); + if (!session) return false; + session.data[key] = value; + 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 1b66b977..40ade23d 100644 --- a/server/internal/session/types.d.ts +++ b/server/internal/session/types.d.ts @@ -7,7 +7,7 @@ export type Session = { }; level: number, requiredLevel: number, - superleveledExpiry: Date | undefined, + superleveledExpiry: number | undefined, }; export interface SessionProvider { From 0671611ac448151c779aa0b016b0ee754b13077a Mon Sep 17 00:00:00 2001 From: DecDuck Date: Wed, 31 Dec 2025 20:44:11 +1000 Subject: [PATCH 32/47] feat: totp signin --- components/Auth/Simple.vue | 10 +-- components/CodeInput.vue | 6 +- components/NotificationItem.vue | 1 - pages/account/security.vue | 1 - pages/auth/mfa.vue | 40 ++++++++++++ pages/auth/mfa/index.vue | 66 +++++++++++++++++++ pages/auth/mfa/totp.vue | 83 ++++++++++++++++++++++++ pages/auth/mfa/webauthn.vue | 1 + pages/library/collection/[id]/index.vue | 15 +---- pages/library/index.vue | 10 --- pages/mfa/totp.vue | 7 -- pages/news/[id]/index.vue | 13 ---- server/api/v1/auth/signin/simple.post.ts | 23 +++++-- server/internal/session/index.ts | 14 +++- server/routes/auth/callback/oidc.get.ts | 11 +++- 15 files changed, 240 insertions(+), 61 deletions(-) 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 delete mode 100644 pages/mfa/totp.vue diff --git a/components/Auth/Simple.vue b/components/Auth/Simple.vue index 7324467a..f40c3121 100644 --- a/components/Auth/Simple.vue +++ b/components/Auth/Simple.vue @@ -102,9 +102,6 @@ const { t } = useI18n(); function signin_wrapper() { loading.value = true; signin() - .then(() => { - router.push(route.query.redirect?.toString() ?? "/"); - }) .catch((response) => { const message = response.statusMessage || t("errors.unknown"); error.value = message; @@ -115,7 +112,7 @@ function signin_wrapper() { } async function signin() { - await $dropFetch("/api/v1/auth/signin/simple", { + const { result } = await $dropFetch("/api/v1/auth/signin/simple", { method: "POST", body: { username: username.value, @@ -123,7 +120,12 @@ async function signin() { rememberMe: rememberMe.value, }, }); + if (result == "2fa") { + router.push({ query: route.query, path: "/auth/mfa" }); + return; + } const user = useUser(); user.value = await $dropFetch("/api/v1/user"); + router.push(route.query.redirect?.toString() ?? "/"); } diff --git a/components/CodeInput.vue b/components/CodeInput.vue index 53c2e9ea..4fbec977 100644 --- a/components/CodeInput.vue +++ b/components/CodeInput.vue @@ -73,14 +73,14 @@ function paste(index: number, event: ClipboardEvent) { for (let i = 0; i < newCode.length && i < length; i++) { code.value[i] = newCode[i]; codeElements.value![i].focus(); - if(i + 1 == length) { + if (i + 1 == length) { complete(code.value.join("")); } } event.preventDefault(); } -async function complete(code: string) { - emit("complete", code); +async function complete(completedCode: string) { + emit("complete", completedCode); } diff --git a/components/NotificationItem.vue b/components/NotificationItem.vue index b81ee30a..ba9cee3d 100644 --- a/components/NotificationItem.vue +++ b/components/NotificationItem.vue @@ -24,7 +24,6 @@ > {{ name }} -
diff --git a/pages/account/security.vue b/pages/account/security.vue index d6ecc318..ef6da7ed 100644 --- a/pages/account/security.vue +++ b/pages/account/security.vue @@ -224,7 +224,6 @@ import { CheckCircleIcon, } from "@heroicons/vue/20/solid"; import { CheckIcon, ClockIcon, KeyIcon } from "@heroicons/vue/24/outline"; -import type { Component } from "vue"; import { MFAMec } from "~/prisma/client/enums"; const superlevel = await $dropFetch("/api/v1/user/superlevel"); const auth = await $dropFetch("/api/v1/user/auth"); diff --git a/pages/auth/mfa.vue b/pages/auth/mfa.vue new file mode 100644 index 00000000..e435a7cc --- /dev/null +++ b/pages/auth/mfa.vue @@ -0,0 +1,40 @@ + + + \ No newline at end of file diff --git a/pages/auth/mfa/index.vue b/pages/auth/mfa/index.vue new file mode 100644 index 00000000..6d87cee8 --- /dev/null +++ b/pages/auth/mfa/index.vue @@ -0,0 +1,66 @@ + + + diff --git a/pages/auth/mfa/totp.vue b/pages/auth/mfa/totp.vue new file mode 100644 index 00000000..54ec7dc4 --- /dev/null +++ b/pages/auth/mfa/totp.vue @@ -0,0 +1,83 @@ + + + diff --git a/pages/auth/mfa/webauthn.vue b/pages/auth/mfa/webauthn.vue new file mode 100644 index 00000000..cc340bc4 --- /dev/null +++ b/pages/auth/mfa/webauthn.vue @@ -0,0 +1 @@ + diff --git a/pages/library/collection/[id]/index.vue b/pages/library/collection/[id]/index.vue index 9176d99a..f5a61190 100644 --- a/pages/library/collection/[id]/index.vue +++ b/pages/library/collection/[id]/index.vue @@ -51,17 +51,4 @@ if (collection.value === undefined) { useHead({ title: collection.value?.name || t("library.collection.title"), }); - - - + \ No newline at end of file diff --git a/pages/library/index.vue b/pages/library/index.vue index 8d87bd7a..2890565c 100644 --- a/pages/library/index.vue +++ b/pages/library/index.vue @@ -113,16 +113,6 @@ useHead({ - - diff --git a/server/api/v1/auth/signin/simple.post.ts b/server/api/v1/auth/signin/simple.post.ts index 3e4ca03e..083fdccb 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/internal/session/index.ts b/server/internal/session/index.ts index 3680268f..4fd3cabb 100644 --- a/server/internal/session/index.ts +++ b/server/internal/session/index.ts @@ -25,6 +25,9 @@ const extendedSessionLength: DurationLike = { year: 1, }; +const signinResult = ["signin", "2fa", "fail"] as const; +type SigninResult = (typeof signinResult)[number]; + export class SessionHandler { private sessionProvider: SessionProvider; @@ -35,7 +38,7 @@ 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 }, }); @@ -54,7 +57,11 @@ export class SessionHandler { }; if (session.level >= session.requiredLevel) session.superleveledExpiry = Date.now() + SUPERLEVEL_LENGTH; - return await this.sessionProvider.setSession(token, session); + const success = await this.sessionProvider.setSession(token, session); + if(!success) return "fail"; + + if(session.level < session.requiredLevel) return "2fa"; + return "signin"; } async mfa(h3: H3Event, amount: number) { @@ -76,9 +83,10 @@ 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; } diff --git a/server/routes/auth/callback/oidc.get.ts b/server/routes/auth/callback/oidc.get.ts index 5aba8fd9..ac2bb7d3 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); From a7ddd5b39b36cf7ca907b25906df8a1fe2de2af3 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Sun, 4 Jan 2026 11:36:31 +0700 Subject: [PATCH 33/47] feat: webauthn mfa/signin --- components/Auth/Simple.vue | 47 +++- composables/user.ts | 11 + nuxt.config.ts | 2 +- package.json | 2 + pages/auth/mfa/totp.vue | 8 +- pages/auth/mfa/webauthn.vue | 94 ++++++- pages/mfa/setup/successful.vue | 32 +++ pages/mfa/setup/totp.vue | 6 +- pages/mfa/setup/webauthn.vue | 60 +---- pnpm-lock.yaml | 237 ++++++++++++++++-- .../migration.sql | 8 + prisma/models/auth.prisma | 2 +- server/api/v1/auth/mfa/index.get.ts | 4 +- server/api/v1/auth/mfa/totp.post.ts | 4 +- .../api/v1/auth/mfa/webauthn/finish.post.ts | 108 ++++++++ server/api/v1/auth/mfa/webauthn/start.post.ts | 43 ++++ server/api/v1/auth/passkey/finish.post.ts | 106 ++++++++ server/api/v1/auth/passkey/start.post.ts | 33 +++ .../api/v1/client/auth/callback/index.post.ts | 6 +- 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 | 8 +- server/api/v1/user/mfa/index.get.ts | 2 +- .../api/v1/user/mfa/webauthn/finish.post.ts | 147 +++++------ server/api/v1/user/mfa/webauthn/start.post.ts | 44 +++- server/internal/acls/index.ts | 24 +- server/internal/auth/webauthn.ts | 117 ++++++++- server/internal/session/db.ts | 12 +- server/internal/session/index.ts | 62 +++-- server/internal/session/types.d.ts | 13 +- 30 files changed, 1016 insertions(+), 232 deletions(-) create mode 100644 pages/mfa/setup/successful.vue create mode 100644 prisma/migrations/20260104040733_make_session_userids_optional/migration.sql 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 diff --git a/components/Auth/Simple.vue b/components/Auth/Simple.vue index f40c3121..48250ea9 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,6 +86,11 @@ diff --git a/composables/user.ts b/composables/user.ts index 68513980..63ab7018 100644 --- a/composables/user.ts +++ b/composables/user.ts @@ -11,3 +11,14 @@ export const updateUser = async () => { user.value = await $dropFetch("/api/v1/user"); }; + + +export async function completeSignin() { + const route = useRoute(); + const router = useRouter(); + + const user = useUser(); + user.value = await $dropFetch("/api/v1/user"); + router.push(route.query.redirect?.toString() ?? "/"); + +} \ No newline at end of file diff --git a/nuxt.config.ts b/nuxt.config.ts index 0807d36f..4d68ed35 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -64,7 +64,7 @@ export default defineNuxtConfig({ experimental: { buildCache: true, - viewTransition: true, + viewTransition: false, componentIslands: true, }, diff --git a/package.json b/package.json index 13cad33f..ab342b78 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ "@nuxt/image": "^1.10.0", "@nuxtjs/i18n": "^9.5.5", "@prisma/client": "^6.11.1", + "@simplewebauthn/browser": "^13.2.2", + "@simplewebauthn/server": "^13.2.2", "@tailwindcss/vite": "^4.0.6", "@vueuse/nuxt": "13.6.0", "argon2": "^0.43.0", diff --git a/pages/auth/mfa/totp.vue b/pages/auth/mfa/totp.vue index 54ec7dc4..0a64d316 100644 --- a/pages/auth/mfa/totp.vue +++ b/pages/auth/mfa/totp.vue @@ -59,9 +59,6 @@ const loading = ref(false); const success = ref(false); const error = ref(undefined); -const route = useRoute(); -const router = useRouter(); - async function signin(code: string) { loading.value = true; error.value = undefined; @@ -76,8 +73,7 @@ async function signin(code: string) { return; } success.value = true; - const user = useUser(); - user.value = await $dropFetch("/api/v1/user"); - router.push(route.query.redirect?.toString() ?? "/"); + + await completeSignin(); } diff --git a/pages/auth/mfa/webauthn.vue b/pages/auth/mfa/webauthn.vue index cc340bc4..a6a186ea 100644 --- a/pages/auth/mfa/webauthn.vue +++ b/pages/auth/mfa/webauthn.vue @@ -1 +1,93 @@ - + + + diff --git a/pages/mfa/setup/successful.vue b/pages/mfa/setup/successful.vue new file mode 100644 index 00000000..68e214a6 --- /dev/null +++ b/pages/mfa/setup/successful.vue @@ -0,0 +1,32 @@ + + + diff --git a/pages/mfa/setup/totp.vue b/pages/mfa/setup/totp.vue index 8d540a99..3498881a 100644 --- a/pages/mfa/setup/totp.vue +++ b/pages/mfa/setup/totp.vue @@ -60,8 +60,8 @@ import { FetchError } from "ofetch"; useHead({ - title: "Set up TOTP" -}) + title: "Set up TOTP", +}); const totpSecrets = await $dropFetch("/api/v1/user/mfa/totp/start", { method: "POST", @@ -82,7 +82,7 @@ async function complete(code: string) { method: "POST", body: { code }, }); - router.push("/account/security"); + router.push("/mfa/setup/successful"); } catch (e) { error.value = (e as FetchError).data?.message ?? (e as FetchError).statusMessage; diff --git a/pages/mfa/setup/webauthn.vue b/pages/mfa/setup/webauthn.vue index 547b00c8..29966142 100644 --- a/pages/mfa/setup/webauthn.vue +++ b/pages/mfa/setup/webauthn.vue @@ -66,15 +66,9 @@ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9c1bb47..b48e42d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)) @@ -1052,6 +1058,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'} @@ -1210,6 +1219,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 @@ -1855,6 +1867,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'} @@ -2248,6 +2297,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'} @@ -2938,6 +2994,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'} @@ -3018,8 +3078,8 @@ packages: resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==} engines: {node: '>=6.0.0'} - baseline-browser-mapping@2.8.29: - resolution: {integrity: sha512-sXdt2elaVnhpDNRDz+1BDx1JQoJRuNk7oVlAlbGiFkLikHCAQiccexF/9e91zVi6RCgqspl04aP+6Cnl9zRLrA==} + baseline-browser-mapping@2.9.11: + resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} hasBin: true basic-auth@2.0.1: @@ -3144,11 +3204,8 @@ 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==} @@ -5601,6 +5658,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'} @@ -5687,6 +5751,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} @@ -6203,9 +6270,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==} @@ -7575,6 +7649,8 @@ snapshots: dependencies: vue: 3.5.22(typescript@5.8.3) + '@hexagon/base64@1.1.28': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -7756,6 +7832,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 @@ -8706,6 +8784,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': @@ -9039,6 +9213,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': {} @@ -9872,6 +10059,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 @@ -9903,7 +10096,7 @@ snapshots: 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 @@ -9943,7 +10136,7 @@ snapshots: base64url@3.0.1: {} - baseline-browser-mapping@2.8.29: {} + baseline-browser-mapping@2.9.11: {} basic-auth@2.0.1: dependencies: @@ -9989,15 +10182,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) @@ -10102,13 +10295,11 @@ 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: @@ -13079,6 +13270,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 @@ -13178,6 +13375,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 @@ -13762,8 +13961,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/prisma/migrations/20260104040733_make_session_userids_optional/migration.sql b/prisma/migrations/20260104040733_make_session_userids_optional/migration.sql new file mode 100644 index 00000000..33de907c --- /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/models/auth.prisma b/prisma/models/auth.prisma index 1226bc05..e8661e38 100644 --- a/prisma/models/auth.prisma +++ b/prisma/models/auth.prisma @@ -81,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/server/api/v1/auth/mfa/index.get.ts b/server/api/v1/auth/mfa/index.get.ts index a6f7e652..d3429eba 100644 --- a/server/api/v1/auth/mfa/index.get.ts +++ b/server/api/v1/auth/mfa/index.get.ts @@ -5,7 +5,7 @@ import { MFAMec } from "~/prisma/client/client"; export default defineEventHandler(async (h3) => { const session = await sessionHandler.getSession(h3); - if (!session || session.level == 0) + if (!session || !session.authenticated || session.authenticated.level == 0) throw createError({ statusCode: 403, message: "Sign in before completing MFA", @@ -13,7 +13,7 @@ export default defineEventHandler(async (h3) => { const linkedMFAMec = await prisma.linkedMFAMec.findMany({ where: { - userId: session.userId, + userId: session.authenticated.userId, }, select: { mec: true, diff --git a/server/api/v1/auth/mfa/totp.post.ts b/server/api/v1/auth/mfa/totp.post.ts index 4ee67017..b86a27b7 100644 --- a/server/api/v1/auth/mfa/totp.post.ts +++ b/server/api/v1/auth/mfa/totp.post.ts @@ -16,7 +16,7 @@ const TOTPBody = type({ export default defineEventHandler(async (h3) => { const session = await sessionHandler.getSession(h3); - if (!session || session.level == 0) + if (!session || !session.authenticated || session.authenticated.level == 0) throw createError({ statusCode: 403, message: "Sign in before completing MFA", @@ -27,7 +27,7 @@ export default defineEventHandler(async (h3) => { const linkedMFAMec = await prisma.linkedMFAMec.findUnique({ where: { userId_mec: { - userId: session.userId, + userId: session.authenticated.userId, mec: MFAMec.TOTP, }, }, 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 00000000..d3329653 --- /dev/null +++ b/server/api/v1/auth/mfa/webauthn/finish.post.ts @@ -0,0 +1,108 @@ +import { verifyAuthenticationResponse } from "@simplewebauthn/server"; +import { type } from "arktype"; +import crypto, { createHash } from "crypto"; +import { createRouteRulesHandler } from "nitropack/runtime/internal/route-rules"; +import { MFAMec } from "~/prisma/client/enums"; +import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; +import { dropDecodeArrayBase64 } from "~/server/internal/auth/totp"; +import { + getRpId, + parseAndValidatePasskeyCreation, + WebAuthNv1Credentials, +} 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; + + 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 00000000..baa67027 --- /dev/null +++ b/server/api/v1/auth/mfa/webauthn/start.post.ts @@ -0,0 +1,43 @@ +import { generateAuthenticationOptions } from "@simplewebauthn/server"; +import { MFAMec } from "~/prisma/client/enums"; +import aclManager from "~/server/internal/acls"; +import { + getRpId, + WebAuthNv1Credentials, +} 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 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 00000000..b54840f5 --- /dev/null +++ b/server/api/v1/auth/passkey/finish.post.ts @@ -0,0 +1,106 @@ +import { verifyAuthenticationResponse } from "@simplewebauthn/server"; +import { type } from "arktype"; +import { MFAMec } from "~/prisma/client/enums"; +import { dropDecodeArrayBase64 } from "~/server/internal/auth/totp"; +import { + getRpId, + WebAuthNv1Credentials, +} 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; + + 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 00000000..58ffcd02 --- /dev/null +++ b/server/api/v1/auth/passkey/start.post.ts @@ -0,0 +1,33 @@ +import { generateAuthenticationOptions } from "@simplewebauthn/server"; +import { MFAMec } from "~/prisma/client/enums"; +import aclManager from "~/server/internal/acls"; +import { + getRpId, + WebAuthNv1Credentials, +} 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 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/client/auth/callback/index.post.ts b/server/api/v1/client/auth/callback/index.post.ts index bc63be70..56601b3a 100644 --- a/server/api/v1/client/auth/callback/index.post.ts +++ b/server/api/v1/client/auth/callback/index.post.ts @@ -2,8 +2,8 @@ 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 +15,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 74bc6e87..858481f7 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 593b773b..803f3171 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 92cf16c5..42764e8f 100644 --- a/server/api/v1/client/auth/index.get.ts +++ b/server/api/v1/client/auth/index.get.ts @@ -2,8 +2,8 @@ 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 +20,13 @@ 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/user/mfa/index.get.ts b/server/api/v1/user/mfa/index.get.ts index e814c42a..3a283370 100644 --- a/server/api/v1/user/mfa/index.get.ts +++ b/server/api/v1/user/mfa/index.get.ts @@ -22,7 +22,7 @@ export default defineEventHandler(async (h3) => { case MFAMec.WebAuthn: const newCredentials = ( v.credentials as unknown as WebAuthNv1Credentials - ).credentials.map((v) => ({ name: v.name, id: v.id, created: v.created })); + ).passkeys.map((v) => ({ name: v.name, id: v.id, created: v.created })); v.credentials = newCredentials; break; } diff --git a/server/api/v1/user/mfa/webauthn/finish.post.ts b/server/api/v1/user/mfa/webauthn/finish.post.ts index 4c6b1dea..4fe951e2 100644 --- a/server/api/v1/user/mfa/webauthn/finish.post.ts +++ b/server/api/v1/user/mfa/webauthn/finish.post.ts @@ -2,32 +2,24 @@ import { ArkErrors, type } from "arktype"; import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; import aclManager from "~/server/internal/acls"; import { decode } from "cbor2"; -import { dropDecodeArrayBase64 } from "~/server/internal/auth/totp"; +import { + dropDecodeArrayBase64, + dropEncodeArrayBase64, +} from "~/server/internal/auth/totp"; import { getRpId, + parseAndValidatePasskeyCreation, WebAuthNv1Credentials, } from "~/server/internal/auth/webauthn"; import { createHash } from "node:crypto"; import prisma from "~/server/internal/db/database"; import { MFAMec } from "~/prisma/client/enums"; -import cosekey from "parse-cosekey"; - -const CreatePasskey = type({ - name: "string", - clientData: "string", - attestationObject: "string", -}).configure(throwingArktype); - -const ClientData = type({ - type: "'webauthn.create'", - challenge: "string", - origin: "string", -}); - -const AuthData = type({ - fmt: "string", - authData: "TypedArray.Uint8", -}); +import sessionHandler from "~/server/internal/session"; +import { + PublicKeyCredentialCreationOptionsJSON, + 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 @@ -37,73 +29,40 @@ export default defineEventHandler(async (h3) => { message: "Not signed in or superlevelled.", }); - const body = await readDropValidatedBody(h3, CreatePasskey); - - const clientData = dropDecodeArrayBase64(body.clientData); - const attestationObject = dropDecodeArrayBase64(body.attestationObject); - - 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 tmp = decode(attestationObject); - const decodedAttestationObject = AuthData(tmp); - if (decodedAttestationObject instanceof ArkErrors) - throw createError({ - statusCode: 400, - message: `Invalid attestation object: ${decodedAttestationObject.summary}`, - }); + const body = await readBody(h3); - 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, + const optionsRaw = await sessionHandler.getSessionDataKey( + h3, + "webauthn/options", ); - if (attestedCredentialData.length < 18 + credentialIdLength) + if (!optionsRaw) throw createError({ statusCode: 400, - message: "Missing credential data of length: " + credentialIdLength, + message: "WebAuthn not started for this session.", }); - const credentialId = attestedCredentialData.slice( - 18, - 18 + credentialIdLength, - ); - const credentialPublicKey: Map = decode( - attestedCredentialData.slice(18 + credentialIdLength), - ); - if (!(credentialPublicKey instanceof Map)) + 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: "Could not decode public key from attestion credential data", + message: (error as string)?.toString(), }); - - const credentialIdStr = Buffer.from(credentialId).toString("hex"); - const jwk = cosekey.KeyParser.cose2jwk(credentialPublicKey); - - console.log(credentialIdStr, jwk); + } const webauthnMec = (await prisma.linkedMFAMec.findUnique({ @@ -113,18 +72,36 @@ export default defineEventHandler(async (h3) => { data: { userId, mec: MFAMec.WebAuthn, - credentials: { credentials: [] } satisfies WebAuthNv1Credentials, + credentials: { passkeys: [] } satisfies WebAuthNv1Credentials, version: 1, }, })); - ( - webauthnMec.credentials as unknown as WebAuthNv1Credentials - ).credentials.push({ - id: credentialIdStr, - jwk, - name: body.name, + 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, }); await prisma.linkedMFAMec.update({ diff --git a/server/api/v1/user/mfa/webauthn/start.post.ts b/server/api/v1/user/mfa/webauthn/start.post.ts index f4bf20e0..1cc13aab 100644 --- a/server/api/v1/user/mfa/webauthn/start.post.ts +++ b/server/api/v1/user/mfa/webauthn/start.post.ts @@ -1,8 +1,18 @@ import aclManager from "~/server/internal/acls"; -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"; +import { + generateRegistrationOptions, + verifyRegistrationResponse, +} from "@simplewebauthn/server"; +import { getRpId } from "~/server/internal/auth/webauthn"; +import { MFAMec } from "~/prisma/client/enums"; +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 @@ -12,6 +22,8 @@ export default defineEventHandler(async (h3) => { 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 }, @@ -22,15 +34,27 @@ export default defineEventHandler(async (h3) => { message: "Session refers to non-existed user.", }); - const challenge = crypto.randomUUID().replaceAll("-", ""); + 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/challenge", challenge); + await sessionHandler.setSessionDataKey( + h3, + "webauthn/options", + JSON.stringify(registrationOptions), + ); - const rpId = await getRpId(); + await sessionHandler.setSessionDataKey(h3, "webauthn/passkeyname", body.name); - return { - challenge, - rp: { name: "Drop", id: rpId }, - user: { userId, ...user }, - }; + return registrationOptions; }); diff --git a/server/internal/acls/index.ts b/server/internal/acls/index.ts index 1e426db3..9a5097d0 100644 --- a/server/internal/acls/index.ts +++ b/server/internal/acls/index.ts @@ -127,8 +127,8 @@ class ACLManager { throw new Error("Native web requests not available - weird deployment?"); // Sessions automatically have all ACLs const session = await sessionHandler.getSession(request); - if (session) { - if (session.level >= session.requiredLevel) return session.userId; + if (session && session.authenticated) { + if (session.authenticated.level >= session.authenticated.requiredLevel) return session.authenticated.userId; return undefined; } @@ -168,11 +168,11 @@ class ACLManager { if (!request) throw new Error("Native web requests not available - weird deployment?"); const session = await sessionHandler.getSession(request); - if (!session) return undefined; - if (session.level < session.requiredLevel) return undefined; - if (session.superleveledExpiry === undefined) return undefined; - if (session.superleveledExpiry < Date.now()) return undefined; - return session.userId; + 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( @@ -182,14 +182,14 @@ 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 false; - if (userSession.level < userSession.requiredLevel) return false; + if (userSession.authenticated.level < userSession.authenticated.requiredLevel) return false; return true; } } @@ -239,7 +239,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({ @@ -250,7 +250,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/webauthn.ts b/server/internal/auth/webauthn.ts index b4ab49b8..2c6a3b40 100644 --- a/server/internal/auth/webauthn.ts +++ b/server/internal/auth/webauthn.ts @@ -1,4 +1,10 @@ +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 { AuthenticatorTransportFuture } from "@simplewebauthn/server"; export async function getRpId() { const externalUrl = @@ -8,6 +14,115 @@ export async function getRpId() { 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 { - credentials: Array<{ id: string; jwk: JsonWebKey, name: string, created: number }>; + 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/session/db.ts b/server/internal/session/db.ts index 02493daa..7ddc2920 100644 --- a/server/internal/session/db.ts +++ b/server/internal/session/db.ts @@ -16,18 +16,16 @@ export default function createDBSessionHandler(): SessionProvider { }, create: { token, - user: { - connect: { - id: session.userId, - }, - }, + ...(session.authenticated?.userId + ? { userId: session.authenticated?.userId } + : undefined), expiresAt: session.expiresAt, - data: session, + data: session as object, }, update: { expiresAt: session.expiresAt, - data: session, + data: session as object, }, }); return true; diff --git a/server/internal/session/index.ts b/server/internal/session/index.ts index 4fd3cabb..fd61a49a 100644 --- a/server/internal/session/index.ts +++ b/server/internal/session/index.ts @@ -38,7 +38,11 @@ export class SessionHandler { // this.sessionProvider = createMemorySessionProvider(); } - async signin(h3: H3Event, userId: string, rememberMe: boolean = false): Promise { + async signin( + h3: H3Event, + userId: string, + rememberMe: boolean = false, + ): Promise { const mfaCount = await prisma.linkedMFAMec.count({ where: { userId, enabled: true }, }); @@ -48,19 +52,26 @@ export class SessionHandler { const token = this.getSessionToken(h3) ?? this.createSessionCookie(h3, expiresAt); const session = (await this.sessionProvider.getSession(token)) ?? { - userId, expiresAt, data: {}, - level: 10, + }; + const wasAuthenticated = !!session.authenticated; + session.authenticated = { + userId, + level: session.authenticated?.level ?? 10, requiredLevel: mfaCount > 0 ? 20 : 10, superleveledExpiry: undefined, }; - if (session.level >= session.requiredLevel) - session.superleveledExpiry = Date.now() + SUPERLEVEL_LENGTH; + 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 (!success) return "fail"; - if(session.level < session.requiredLevel) return "2fa"; + if (session.authenticated.level < session.authenticated.requiredLevel) + return "2fa"; return "signin"; } @@ -69,10 +80,10 @@ export class SessionHandler { if (!token) throw createError({ statusCode: 403, message: "User not signed in" }); const session = await this.sessionProvider.getSession(token); - if (!session) + if (!session || !session.authenticated) throw createError({ statusCode: 403, message: "User not signed in" }); - session.level += amount; + session.authenticated.level += amount; await this.sessionProvider.setSession(token, session); } @@ -85,12 +96,15 @@ export class SessionHandler { if (!token) return undefined; const data = await this.sessionProvider.getSession(token); - if(!data) return undefined; - if(new Date(data.expiresAt).getTime() < Date.now()) return undefined; // Expired + if (!data) return undefined; + if (new Date(data.expiresAt).getTime() < Date.now()) return undefined; // Expired return data; } - async getSessionDataKey(request: MinimumRequestObject, key: string) { + async getSessionDataKey( + request: MinimumRequestObject, + key: string, + ): Promise { const token = this.getSessionToken(request); if (!token) return undefined; @@ -99,17 +113,29 @@ export class SessionHandler { return session.data[key] as T; } - async setSessionDataKey( - request: MinimumRequestObject, - key: string, - value: 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: {}, + }; + 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; - session.data[key] = value; + delete session.data[key]; await this.sessionProvider.setSession(token, session); return true; } diff --git a/server/internal/session/types.d.ts b/server/internal/session/types.d.ts index 40ade23d..3564c3f6 100644 --- a/server/internal/session/types.d.ts +++ b/server/internal/session/types.d.ts @@ -1,15 +1,20 @@ export type Session = { - userId: string; + authenticated?: AuthenticatedSession, + expiresAt: Date; data: { // eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: any; }; - level: number, - requiredLevel: number, - superleveledExpiry: number | undefined, }; +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; From 2b34f2697eeec37bff9f14344089ee8cee61b6eb Mon Sep 17 00:00:00 2001 From: DecDuck Date: Sun, 4 Jan 2026 17:26:40 +0700 Subject: [PATCH 34/47] feat: launch selecting ui --- components/GameEditor/Metadata.vue | 2 +- components/ImportVersionLaunchRow.vue | 28 ++- components/Modal/SelectLaunch.vue | 181 ++++++++++++++++++ components/Selector/Combox.vue | 91 +++++++++ components/Selector/Game.vue | 132 +++++++++++++ .../Language.vue} | 2 +- .../LanguageListbox.vue} | 0 .../MultiItem.vue} | 0 .../Platform.vue} | 0 components/StoreView.vue | 5 +- components/UserFooter.vue | 2 +- composables/frontend.d.ts | 19 ++ composables/kjua.d.ts | 2 +- composables/request.ts | 2 +- i18n/locales/en_us.json | 2 +- pages/admin/library/[id]/import.vue | 47 ++++- pages/setup.vue | 2 +- .../migration.sql | 8 + prisma/models/content.prisma | 4 +- server/api/v1/admin/game/[id]/versions.get.ts | 34 ++++ .../api/v1/admin/import/version/index.post.ts | 1 + server/api/v1/admin/search/game.get.ts | 39 ++++ server/internal/library/index.ts | 3 + 23 files changed, 579 insertions(+), 27 deletions(-) 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} (100%) rename components/{PlatformSelector.vue => Selector/Platform.vue} (100%) create mode 100644 composables/frontend.d.ts create mode 100644 prisma/migrations/20260104074505_add_trgm_index_for_game_m_name/migration.sql create mode 100644 server/api/v1/admin/game/[id]/versions.get.ts create mode 100644 server/api/v1/admin/search/game.get.ts diff --git a/components/GameEditor/Metadata.vue b/components/GameEditor/Metadata.vue index 54d2fb7a..d36f88d4 100644 --- a/components/GameEditor/Metadata.vue +++ b/components/GameEditor/Metadata.vue @@ -30,7 +30,7 @@
- +