From 36e6c929383e421cb5fed85568b8a453132b7d80 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Tue, 1 Apr 2025 21:08:57 +1100 Subject: [PATCH] feat: add cloud save backend --- nuxt.config.ts | 4 +- package.json | 2 + .../migration.sql | 24 ++++ .../migration.sql | 3 + .../migration.sql | 14 ++ .../migration.sql | 2 + .../migration.sql | 2 + .../migration.sql | 10 ++ prisma/schema/app.prisma | 4 + prisma/schema/client.prisma | 3 + prisma/schema/content.prisma | 29 ++++- prisma/schema/user.prisma | 2 + server/api/v1/client/capability/index.post.ts | 6 +- .../[gameid]/[slotindex]/index.delete.ts | 53 ++++++++ .../saves/[gameid]/[slotindex]/index.get.ts | 55 ++++++++ .../saves/[gameid]/[slotindex]/push.post.ts | 46 +++++++ .../api/v1/client/saves/[gameid]/index.get.ts | 37 ++++++ .../v1/client/saves/[gameid]/index.post.ts | 62 +++++++++ server/api/v1/client/saves/index.get.ts | 23 ++++ server/api/v1/client/saves/settings.get.ts | 20 +++ server/internal/clients/capabilities.ts | 41 +++++- server/internal/clients/event-handler.ts | 14 +- server/internal/objects/fsBackend.ts | 31 ++++- server/internal/objects/objectHandler.ts | 56 +++++--- server/internal/saves/index.ts | 122 ++++++++++++++++++ yarn.lock | 12 ++ 26 files changed, 642 insertions(+), 35 deletions(-) create mode 100644 prisma/migrations/20250401082200_add_save_slots/migration.sql create mode 100644 prisma/migrations/20250401082605_add_save_slot_limits_to_application_settings/migration.sql create mode 100644 prisma/migrations/20250401083942_rename_save_to_cloud_saves/migration.sql create mode 100644 prisma/migrations/20250401084907_add_history_limit/migration.sql create mode 100644 prisma/migrations/20250401085406_add_default_to_playtime/migration.sql create mode 100644 prisma/migrations/20250401091937_add_history_and_hashes/migration.sql create mode 100644 server/api/v1/client/saves/[gameid]/[slotindex]/index.delete.ts create mode 100644 server/api/v1/client/saves/[gameid]/[slotindex]/index.get.ts create mode 100644 server/api/v1/client/saves/[gameid]/[slotindex]/push.post.ts create mode 100644 server/api/v1/client/saves/[gameid]/index.get.ts create mode 100644 server/api/v1/client/saves/[gameid]/index.post.ts create mode 100644 server/api/v1/client/saves/index.get.ts create mode 100644 server/api/v1/client/saves/settings.get.ts create mode 100644 server/internal/saves/index.ts diff --git a/nuxt.config.ts b/nuxt.config.ts index 7a9eb21..6194218 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -50,7 +50,7 @@ export default defineNuxtConfig({ // Module config from here down modules: [ "vue3-carousel-nuxt", - "nuxt-security", + // "nuxt-security", "@nuxt/image", "@nuxt/fonts", ], @@ -59,6 +59,7 @@ export default defineNuxtConfig({ prefix: "Vue", }, + /* security: { headers: { contentSecurityPolicy: { @@ -76,4 +77,5 @@ export default defineNuxtConfig({ }, rateLimiter: false }, + */ }); diff --git a/package.json b/package.json index 4f3196e..276320f 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "axios": "^1.7.7", "bcryptjs": "^2.4.3", "cookie-es": "^1.2.2", + "crypto": "^1.0.1", "fast-fuzzy": "^1.12.0", "file-type-mime": "^0.4.3", "jdenticon": "^3.3.0", @@ -48,6 +49,7 @@ "@tailwindcss/forms": "^0.5.9", "@tailwindcss/typography": "^0.5.15", "@types/bcryptjs": "^2.4.6", + "@types/node": "^22.13.16", "@types/turndown": "^5.0.5", "@types/uuid": "^10.0.0", "autoprefixer": "^10.4.20", diff --git a/prisma/migrations/20250401082200_add_save_slots/migration.sql b/prisma/migrations/20250401082200_add_save_slots/migration.sql new file mode 100644 index 0000000..d55a36c --- /dev/null +++ b/prisma/migrations/20250401082200_add_save_slots/migration.sql @@ -0,0 +1,24 @@ +-- AlterEnum +ALTER TYPE "ClientCapabilities" ADD VALUE 'save'; + +-- CreateTable +CREATE TABLE "SaveSlot" ( + "gameId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "index" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "playtime" DOUBLE PRECISION NOT NULL, + "lastUsedClientId" TEXT NOT NULL, + "data" TEXT[], + + CONSTRAINT "SaveSlot_pkey" PRIMARY KEY ("gameId","userId","index") +); + +-- AddForeignKey +ALTER TABLE "SaveSlot" ADD CONSTRAINT "SaveSlot_gameId_fkey" FOREIGN KEY ("gameId") REFERENCES "Game"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SaveSlot" ADD CONSTRAINT "SaveSlot_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SaveSlot" ADD CONSTRAINT "SaveSlot_lastUsedClientId_fkey" FOREIGN KEY ("lastUsedClientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20250401082605_add_save_slot_limits_to_application_settings/migration.sql b/prisma/migrations/20250401082605_add_save_slot_limits_to_application_settings/migration.sql new file mode 100644 index 0000000..158a843 --- /dev/null +++ b/prisma/migrations/20250401082605_add_save_slot_limits_to_application_settings/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "ApplicationSettings" ADD COLUMN "saveSlotCountLimit" INTEGER NOT NULL DEFAULT 5, +ADD COLUMN "saveSlotSizeLimit" DOUBLE PRECISION NOT NULL DEFAULT 10; diff --git a/prisma/migrations/20250401083942_rename_save_to_cloud_saves/migration.sql b/prisma/migrations/20250401083942_rename_save_to_cloud_saves/migration.sql new file mode 100644 index 0000000..50afe85 --- /dev/null +++ b/prisma/migrations/20250401083942_rename_save_to_cloud_saves/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - The values [save] on the enum `ClientCapabilities` will be removed. If these variants are still used in the database, this will fail. + +*/ +-- AlterEnum +BEGIN; +CREATE TYPE "ClientCapabilities_new" AS ENUM ('peerAPI', 'userStatus', 'cloudSaves'); +ALTER TABLE "Client" ALTER COLUMN "capabilities" TYPE "ClientCapabilities_new"[] USING ("capabilities"::text::"ClientCapabilities_new"[]); +ALTER TYPE "ClientCapabilities" RENAME TO "ClientCapabilities_old"; +ALTER TYPE "ClientCapabilities_new" RENAME TO "ClientCapabilities"; +DROP TYPE "ClientCapabilities_old"; +COMMIT; diff --git a/prisma/migrations/20250401084907_add_history_limit/migration.sql b/prisma/migrations/20250401084907_add_history_limit/migration.sql new file mode 100644 index 0000000..fca349d --- /dev/null +++ b/prisma/migrations/20250401084907_add_history_limit/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ApplicationSettings" ADD COLUMN "saveSlotHistoryLimit" INTEGER NOT NULL DEFAULT 3; diff --git a/prisma/migrations/20250401085406_add_default_to_playtime/migration.sql b/prisma/migrations/20250401085406_add_default_to_playtime/migration.sql new file mode 100644 index 0000000..2e48923 --- /dev/null +++ b/prisma/migrations/20250401085406_add_default_to_playtime/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "SaveSlot" ALTER COLUMN "playtime" SET DEFAULT 0; diff --git a/prisma/migrations/20250401091937_add_history_and_hashes/migration.sql b/prisma/migrations/20250401091937_add_history_and_hashes/migration.sql new file mode 100644 index 0000000..1c39120 --- /dev/null +++ b/prisma/migrations/20250401091937_add_history_and_hashes/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the column `data` on the `SaveSlot` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "SaveSlot" DROP COLUMN "data", +ADD COLUMN "history" TEXT[], +ADD COLUMN "historyChecksums" TEXT[]; diff --git a/prisma/schema/app.prisma b/prisma/schema/app.prisma index aff80ce..f87bf8a 100644 --- a/prisma/schema/app.prisma +++ b/prisma/schema/app.prisma @@ -3,6 +3,10 @@ model ApplicationSettings { enabledAuthencationMechanisms AuthMec[] metadataProviders String[] + + saveSlotCountLimit Int @default(5) + saveSlotSizeLimit Float @default(10) // MB + saveSlotHistoryLimit Int @default(3) } enum Platform { diff --git a/prisma/schema/client.prisma b/prisma/schema/client.prisma index 236545f..c6cb06d 100644 --- a/prisma/schema/client.prisma +++ b/prisma/schema/client.prisma @@ -1,6 +1,7 @@ enum ClientCapabilities { PeerAPI @map("peerAPI") // other clients can use the HTTP API to P2P with this client UserStatus @map("userStatus") // this client can report this user's status (playing, online, etc etc) + CloudSaves @map("cloudSaves") // ability to save to save slots } // References a device @@ -16,6 +17,8 @@ model Client { lastConnected DateTime peerAPI ClientPeerAPIConfiguration? + + lastAccessedSaves SaveSlot[] } model ClientPeerAPIConfiguration { diff --git a/prisma/schema/content.prisma b/prisma/schema/content.prisma index 386a3b0..166f4ab 100644 --- a/prisma/schema/content.prisma +++ b/prisma/schema/content.prisma @@ -31,9 +31,10 @@ model Game { mImageLibrary String[] // linked to objects in s3 versions GameVersion[] - libraryBasePath String @unique // Base dir for all the game versions - + libraryBasePath String @unique // Base dir for all the game versions + collections CollectionEntry[] + saves SaveSlot[] @@unique([metadataSource, metadataId], name: "metadataKey") } @@ -48,9 +49,9 @@ model GameVersion { platform Platform - launchCommand String @default("") // Command to run to start. Platform-specific. Windows games on Linux will wrap this command in Proton/Wine + 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) + setupCommand String @default("") // Command to setup game (dependencies and such) setupArgs String[] onlySetup Boolean @default(false) @@ -64,6 +65,26 @@ model GameVersion { @@id([gameId, versionName]) } +// A save slot for a game +model SaveSlot { + gameId String + game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + index Int + + createdAt DateTime @default(now()) + playtime Float @default(0) // hours + + lastUsedClientId String + lastUsedClient Client @relation(fields: [lastUsedClientId], references: [id]) + + history String[] // list of objects + historyChecksums String[] // list of hashes + + @@id([gameId, userId, index], name: "id") +} + model Developer { id String @id @default(uuid()) diff --git a/prisma/schema/user.prisma b/prisma/schema/user.prisma index b450163..7f45c11 100644 --- a/prisma/schema/user.prisma +++ b/prisma/schema/user.prisma @@ -15,6 +15,8 @@ model User { articles Article[] tokens APIToken[] + + saves SaveSlot[] } model Notification { diff --git a/server/api/v1/client/capability/index.post.ts b/server/api/v1/client/capability/index.post.ts index 038f302..8ddaddf 100644 --- a/server/api/v1/client/capability/index.post.ts +++ b/server/api/v1/client/capability/index.post.ts @@ -21,14 +21,14 @@ export default defineClientEventHandler(async (h3, { clientId }) => { statusMessage: "configuration must be an object", }); - if (!(rawCapability in validCapabilities)) + const capability = rawCapability as InternalClientCapability; + + if (!validCapabilities.includes(capability)) throw createError({ statusCode: 400, statusMessage: "Invalid capability.", }); - const capability = rawCapability as InternalClientCapability; - const isValid = await capabilityManager.validateCapabilityConfiguration( capability, configuration diff --git a/server/api/v1/client/saves/[gameid]/[slotindex]/index.delete.ts b/server/api/v1/client/saves/[gameid]/[slotindex]/index.delete.ts new file mode 100644 index 0000000..14ae41e --- /dev/null +++ b/server/api/v1/client/saves/[gameid]/[slotindex]/index.delete.ts @@ -0,0 +1,53 @@ +import { ClientCapabilities } from "@prisma/client"; +import { defineClientEventHandler } from "~/server/internal/clients/event-handler"; +import prisma from "~/server/internal/db/database"; + +export default defineClientEventHandler( + async (h3, { fetchClient, fetchUser }) => { + const client = await fetchClient(); + if (!client.capabilities.includes(ClientCapabilities.CloudSaves)) + throw createError({ + statusCode: 403, + statusMessage: "Capability not allowed.", + }); + const user = await fetchUser(); + const gameId = getRouterParam(h3, "gameid"); + if (!gameId) + throw createError({ + statusCode: 400, + statusMessage: "No gameID in route params", + }); + + const slotIndexString = getRouterParam(h3, "slotindex"); + if (!slotIndexString) + throw createError({ + statusCode: 400, + statusMessage: "No slotIndex in route params", + }); + const slotIndex = parseInt(slotIndexString); + if (Number.isNaN(slotIndex)) + throw createError({ + statusCode: 400, + statusMessage: "Invalid slotIndex", + }); + + const game = await prisma.game.findUnique({ + where: { id: gameId }, + select: { id: true }, + }); + if (!game) + throw createError({ statusCode: 400, statusMessage: "Invalid game ID" }); + + const save = await prisma.saveSlot.delete({ + where: { + id: { + userId: user.id, + gameId: gameId, + index: slotIndex, + }, + }, + }); + if (!save) + throw createError({ statusCode: 404, statusMessage: "Save not found" }); + } +); diff --git a/server/api/v1/client/saves/[gameid]/[slotindex]/index.get.ts b/server/api/v1/client/saves/[gameid]/[slotindex]/index.get.ts new file mode 100644 index 0000000..33634ba --- /dev/null +++ b/server/api/v1/client/saves/[gameid]/[slotindex]/index.get.ts @@ -0,0 +1,55 @@ +import { ClientCapabilities } from "@prisma/client"; +import { defineClientEventHandler } from "~/server/internal/clients/event-handler"; +import prisma from "~/server/internal/db/database"; + +export default defineClientEventHandler( + async (h3, { fetchClient, fetchUser }) => { + const client = await fetchClient(); + if (!client.capabilities.includes(ClientCapabilities.CloudSaves)) + throw createError({ + statusCode: 403, + statusMessage: "Capability not allowed.", + }); + const user = await fetchUser(); + const gameId = getRouterParam(h3, "gameid"); + if (!gameId) + throw createError({ + statusCode: 400, + statusMessage: "No gameID in route params", + }); + + const slotIndexString = getRouterParam(h3, "slotindex"); + if (!slotIndexString) + throw createError({ + statusCode: 400, + statusMessage: "No slotIndex in route params", + }); + const slotIndex = parseInt(slotIndexString); + if (Number.isNaN(slotIndex)) + throw createError({ + statusCode: 400, + statusMessage: "Invalid slotIndex", + }); + + const game = await prisma.game.findUnique({ + where: { id: gameId }, + select: { id: true }, + }); + if (!game) + throw createError({ statusCode: 400, statusMessage: "Invalid game ID" }); + + const save = await prisma.saveSlot.findUnique({ + where: { + id: { + userId: user.id, + gameId: gameId, + index: slotIndex, + }, + }, + }); + if (!save) + throw createError({ statusCode: 404, statusMessage: "Save not found" }); + + return save; + } +); diff --git a/server/api/v1/client/saves/[gameid]/[slotindex]/push.post.ts b/server/api/v1/client/saves/[gameid]/[slotindex]/push.post.ts new file mode 100644 index 0000000..5e04881 --- /dev/null +++ b/server/api/v1/client/saves/[gameid]/[slotindex]/push.post.ts @@ -0,0 +1,46 @@ +import { ClientCapabilities } from "@prisma/client"; +import { defineClientEventHandler } from "~/server/internal/clients/event-handler"; +import prisma from "~/server/internal/db/database"; +import saveManager from "~/server/internal/saves"; + +export default defineClientEventHandler( + async (h3, { fetchClient, fetchUser }) => { + const client = await fetchClient(); + if (!client.capabilities.includes(ClientCapabilities.CloudSaves)) + throw createError({ + statusCode: 403, + statusMessage: "Capability not allowed.", + }); + const user = await fetchUser(); + const gameId = getRouterParam(h3, "gameid"); + if (!gameId) + throw createError({ + statusCode: 400, + statusMessage: "No gameID in route params", + }); + + const slotIndexString = getRouterParam(h3, "slotindex"); + if (!slotIndexString) + throw createError({ + statusCode: 400, + statusMessage: "No slotIndex in route params", + }); + const slotIndex = parseInt(slotIndexString); + if (Number.isNaN(slotIndex)) + throw createError({ + statusCode: 400, + statusMessage: "Invalid slotIndex", + }); + + const game = await prisma.game.findUnique({ + where: { id: gameId }, + select: { id: true }, + }); + if (!game) + throw createError({ statusCode: 400, statusMessage: "Invalid game ID" }); + + await saveManager.pushSave(gameId, user.id, slotIndex, h3.node.req); + + return; + } +); diff --git a/server/api/v1/client/saves/[gameid]/index.get.ts b/server/api/v1/client/saves/[gameid]/index.get.ts new file mode 100644 index 0000000..717cc36 --- /dev/null +++ b/server/api/v1/client/saves/[gameid]/index.get.ts @@ -0,0 +1,37 @@ +import { ClientCapabilities } from "@prisma/client"; +import { defineClientEventHandler } from "~/server/internal/clients/event-handler"; +import prisma from "~/server/internal/db/database"; + +export default defineClientEventHandler( + async (h3, { fetchClient, fetchUser }) => { + const client = await fetchClient(); + if (!client.capabilities.includes(ClientCapabilities.CloudSaves)) + throw createError({ + statusCode: 403, + statusMessage: "Capability not allowed.", + }); + const user = await fetchUser(); + const gameId = getRouterParam(h3, "gameid"); + if (!gameId) + throw createError({ + statusCode: 400, + statusMessage: "No gameID in route params", + }); + + const game = await prisma.game.findUnique({ + where: { id: gameId }, + select: { id: true }, + }); + if (!game) + throw createError({ statusCode: 400, statusMessage: "Invalid game ID" }); + + const saves = await prisma.saveSlot.findMany({ + where: { + userId: user.id, + gameId: gameId, + }, + }); + + return saves; + } +); diff --git a/server/api/v1/client/saves/[gameid]/index.post.ts b/server/api/v1/client/saves/[gameid]/index.post.ts new file mode 100644 index 0000000..8bbe175 --- /dev/null +++ b/server/api/v1/client/saves/[gameid]/index.post.ts @@ -0,0 +1,62 @@ +import { ClientCapabilities } from "@prisma/client"; +import { defineClientEventHandler } from "~/server/internal/clients/event-handler"; +import { applicationSettings } from "~/server/internal/config/application-configuration"; +import prisma from "~/server/internal/db/database"; + +export default defineClientEventHandler( + async (h3, { fetchClient, fetchUser }) => { + const client = await fetchClient(); + if (!client.capabilities.includes(ClientCapabilities.CloudSaves)) + throw createError({ + statusCode: 403, + statusMessage: "Capability not allowed.", + }); + const user = await fetchUser(); + const gameId = getRouterParam(h3, "gameid"); + if (!gameId) + throw createError({ + statusCode: 400, + statusMessage: "No gameID in route params", + }); + + const game = await prisma.game.findUnique({ + where: { id: gameId }, + select: { id: true }, + }); + if (!game) + throw createError({ statusCode: 400, statusMessage: "Invalid game ID" }); + + const saves = await prisma.saveSlot.findMany({ + where: { + userId: user.id, + gameId: gameId, + }, + orderBy: { + index: "asc", + }, + }); + + const limit = await applicationSettings.get("saveSlotCountLimit"); + if (saves.length + 1 > limit) + throw createError({ + statusCode: 400, + statusMessage: "Out of save slots", + }); + + let firstIndex = 0; + for (const save of saves) { + if (firstIndex == save.index) firstIndex++; + } + + const newSlot = await prisma.saveSlot.create({ + data: { + userId: user.id, + gameId: gameId, + index: firstIndex, + lastUsedClientId: client.id, + }, + }); + + return newSlot; + } +); diff --git a/server/api/v1/client/saves/index.get.ts b/server/api/v1/client/saves/index.get.ts new file mode 100644 index 0000000..fb47584 --- /dev/null +++ b/server/api/v1/client/saves/index.get.ts @@ -0,0 +1,23 @@ +import { ClientCapabilities } from "@prisma/client"; +import { defineClientEventHandler } from "~/server/internal/clients/event-handler"; +import prisma from "~/server/internal/db/database"; + +export default defineClientEventHandler( + async (h3, { fetchClient, fetchUser }) => { + const client = await fetchClient(); + if (!client.capabilities.includes(ClientCapabilities.CloudSaves)) + throw createError({ + statusCode: 403, + statusMessage: "Capability not allowed.", + }); + const user = await fetchUser(); + + const saves = await prisma.saveSlot.findMany({ + where: { + userId: user.id, + }, + }); + + return saves; + } +); diff --git a/server/api/v1/client/saves/settings.get.ts b/server/api/v1/client/saves/settings.get.ts new file mode 100644 index 0000000..a8e4bfd --- /dev/null +++ b/server/api/v1/client/saves/settings.get.ts @@ -0,0 +1,20 @@ +import { ClientCapabilities } from "@prisma/client"; +import { defineClientEventHandler } from "~/server/internal/clients/event-handler"; +import { applicationSettings } from "~/server/internal/config/application-configuration"; +import prisma from "~/server/internal/db/database"; + +export default defineClientEventHandler( + async (h3, { fetchClient, fetchUser }) => { + const client = await fetchClient(); + if (!client.capabilities.includes(ClientCapabilities.CloudSaves)) + throw createError({ + statusCode: 403, + statusMessage: "Capability not allowed.", + }); + + const slotLimit = await applicationSettings.get("saveSlotCountLimit"); + const sizeLimit = await applicationSettings.get("saveSlotSizeLimit"); + const history = await applicationSettings.get("saveSlotHistoryLimit"); + return { slotLimit, sizeLimit, history }; + } +); diff --git a/server/internal/clients/capabilities.ts b/server/internal/clients/capabilities.ts index 472f516..e1ebf46 100644 --- a/server/internal/clients/capabilities.ts +++ b/server/internal/clients/capabilities.ts @@ -4,7 +4,6 @@ import { useCertificateAuthority } from "~/server/plugins/ca"; import prisma from "../db/database"; import { ClientCapabilities } from "@prisma/client"; - // These values are technically mapped to the database, // but Typescript/Prisma doesn't let me link them // They are also what are required by clients in the API @@ -12,6 +11,7 @@ import { ClientCapabilities } from "@prisma/client"; export enum InternalClientCapability { PeerAPI = "peerAPI", UserStatus = "userStatus", + CloudSaves = "cloudSaves", } export const validCapabilities = Object.values(InternalClientCapability); @@ -19,6 +19,7 @@ export const validCapabilities = Object.values(InternalClientCapability); export type CapabilityConfiguration = { [InternalClientCapability.PeerAPI]: { endpoints: string[] }; [InternalClientCapability.UserStatus]: {}; + [InternalClientCapability.CloudSaves]: {}; }; class CapabilityManager { @@ -75,6 +76,7 @@ class CapabilityManager { return valid; }, [InternalClientCapability.UserStatus]: async () => true, // No requirements for user status + [InternalClientCapability.CloudSaves]: async () => true, // No requirements for cloud saves }; async validateCapabilityConfiguration( @@ -82,6 +84,7 @@ class CapabilityManager { configuration: object ) { const validationFunction = this.validationFunctions[capability]; + if (!validationFunction) return false; return validationFunction(configuration); } @@ -90,8 +93,11 @@ class CapabilityManager { rawCapability: object, clientId: string ) { - switch (capability) { - case InternalClientCapability.PeerAPI: + const upsertFunctions: EnumDictionary< + InternalClientCapability, + () => Promise | void + > = { + [InternalClientCapability.PeerAPI]: async function () { const configuration = rawCapability as CapabilityConfiguration[InternalClientCapability.PeerAPI]; @@ -127,9 +133,32 @@ class CapabilityManager { }, }, }); - return; - } - throw new Error("Cannot upsert client capability for: " + capability); + }, + [InternalClientCapability.UserStatus]: function (): Promise | void { + throw new Error("Function not implemented."); + }, + [InternalClientCapability.CloudSaves]: async function () { + const currentClient = await prisma.client.findUnique({ + where: { id: clientId }, + select: { + capabilities: true, + }, + }); + if (!currentClient) throw new Error("Invalid client ID"); + if (currentClient.capabilities.includes(ClientCapabilities.CloudSaves)) + return; + + await prisma.client.update({ + where: { id: clientId }, + data: { + capabilities: { + push: ClientCapabilities.CloudSaves, + }, + }, + }); + }, + }; + await upsertFunctions[capability](); } } diff --git a/server/internal/clients/event-handler.ts b/server/internal/clients/event-handler.ts index 3b803ba..4be0310 100644 --- a/server/internal/clients/event-handler.ts +++ b/server/internal/clients/event-handler.ts @@ -25,6 +25,16 @@ export function defineClientEventHandler(handler: EventHandlerFunction) { let clientId: string; switch (method) { + case "Debug": + if (!process.dev) throw createError({ statusCode: 403 }); + const client = await prisma.client.findFirst({ select: { id: true } }); + if (!client) + throw createError({ + statusCode: 400, + statusMessage: "No clients created.", + }); + clientId = client.id; + break; case "Nonce": clientId = parts[0]; const nonce = parts[1]; @@ -49,7 +59,9 @@ export function defineClientEventHandler(handler: EventHandlerFunction) { } const certificateAuthority = useCertificateAuthority(); - const certBundle = await certificateAuthority.fetchClientCertificate(clientId); + const certBundle = await certificateAuthority.fetchClientCertificate( + clientId + ); // This does the blacklist check already if (!certBundle) throw createError({ diff --git a/server/internal/objects/fsBackend.ts b/server/internal/objects/fsBackend.ts index 3d7f000..321acf0 100644 --- a/server/internal/objects/fsBackend.ts +++ b/server/internal/objects/fsBackend.ts @@ -1,4 +1,10 @@ -import { Object, ObjectBackend, ObjectMetadata, ObjectReference, Source } from "./objectHandler"; +import { + Object, + ObjectBackend, + ObjectMetadata, + ObjectReference, + Source, +} from "./objectHandler"; import sanitize from "sanitize-filename"; @@ -44,6 +50,12 @@ export class FsObjectBackend extends ObjectBackend { return false; } + async startWriteStream(id: ObjectReference) { + const objectPath = path.join(this.baseObjectPath, sanitize(id)); + if (!fs.existsSync(objectPath)) return undefined; + + return fs.createWriteStream(objectPath); + } async create( id: string, source: Source, @@ -68,6 +80,23 @@ export class FsObjectBackend extends ObjectBackend { return id; } + async createWithWriteStream(id: string, metadata: ObjectMetadata) { + const objectPath = path.join(this.baseObjectPath, sanitize(id)); + const metadataPath = path.join( + this.baseMetadataPath, + `${sanitize(id)}.json` + ); + if (fs.existsSync(objectPath) || fs.existsSync(metadataPath)) + return undefined; + + // Write metadata + fs.writeFileSync(metadataPath, JSON.stringify(metadata)); + + // Create file so write passes + fs.writeFileSync(objectPath, ""); + + return this.startWriteStream(id); + } async delete(id: ObjectReference): Promise { const objectPath = path.join(this.baseObjectPath, sanitize(id)); if (!fs.existsSync(objectPath)) return true; diff --git a/server/internal/objects/objectHandler.ts b/server/internal/objects/objectHandler.ts index 9d67185..818f3d1 100644 --- a/server/internal/objects/objectHandler.ts +++ b/server/internal/objects/objectHandler.ts @@ -15,7 +15,7 @@ */ import { parse as getMimeTypeBuffer } from "file-type-mime"; -import { Readable } from "stream"; +import Stream, { Readable, Writable } from "stream"; import { getMimeType as getMimeTypeStream } from "stream-mime-type"; import { v4 as uuidv4 } from "uuid"; @@ -46,11 +46,16 @@ export abstract class ObjectBackend { // They don't check permissions to provide any utilities abstract fetch(id: ObjectReference): Promise; abstract write(id: ObjectReference, source: Source): Promise; + abstract startWriteStream(id: ObjectReference): Promise; abstract create( id: string, source: Source, metadata: ObjectMetadata ): Promise; + abstract createWithWriteStream( + id: string, + metadata: ObjectMetadata + ): Promise; abstract delete(id: ObjectReference): Promise; abstract fetchMetadata( id: ObjectReference @@ -60,30 +65,31 @@ export abstract class ObjectBackend { metadata: ObjectMetadata ): Promise; + private async fetchMimeType(source: Source) { + if (source instanceof ReadableStream) { + source = Readable.from(source); + } + if (source instanceof Readable) { + const { stream, mime } = await getMimeTypeStream(source); + return { source: Readable.from(stream), mime: mime }; + } + if (source instanceof Buffer) { + const mime = + getMimeTypeBuffer(new Uint8Array(source).buffer)?.mime ?? + "application/octet-stream"; + return { source: source, mime }; + } + + return { source: undefined, mime: undefined }; + } + async createFromSource( id: string, sourceFetcher: () => Promise, metadata: { [key: string]: string }, permissions: Array ) { - async function fetchMimeType(source: Source) { - if (source instanceof ReadableStream) { - source = Readable.from(source); - } - if (source instanceof Readable) { - const { stream, mime } = await getMimeTypeStream(source); - return { source: Readable.from(stream), mime: mime }; - } - if (source instanceof Buffer) { - const mime = - getMimeTypeBuffer(new Uint8Array(source).buffer)?.mime ?? - "application/octet-stream"; - return { source: source, mime }; - } - - return { source: undefined, mime: undefined }; - } - const { source, mime } = await fetchMimeType(await sourceFetcher()); + const { source, mime } = await this.fetchMimeType(await sourceFetcher()); if (!mime) throw new Error("Unable to calculate MIME type - is the source empty?"); @@ -94,6 +100,18 @@ export abstract class ObjectBackend { }); } + async createWithStream( + id: string, + metadata: { [key: string]: string }, + permissions: Array + ) { + return this.createWithWriteStream(id, { + permissions, + userMetadata: metadata, + mime: "application/octet-stream", + }); + } + async fetchWithPermissions(id: ObjectReference, userId?: string) { const metadata = await this.fetchMetadata(id); if (!metadata) return; diff --git a/server/internal/saves/index.ts b/server/internal/saves/index.ts new file mode 100644 index 0000000..a70a291 --- /dev/null +++ b/server/internal/saves/index.ts @@ -0,0 +1,122 @@ +import Stream, { Readable } from "stream"; +import prisma from "../db/database"; +import { applicationSettings } from "../config/application-configuration"; +import objectHandler from "../objects"; +import { v4 as uuidv4 } from "uuid"; +import crypto from "crypto"; +import { IncomingMessage } from "http"; + +class SaveManager { + async deleteObjectFromSave( + gameId: string, + userId: string, + index: number, + objectId: string + ) { + await objectHandler.delete(objectId); + } + + async pushSave( + gameId: string, + userId: string, + index: number, + stream: IncomingMessage + ) { + const save = await prisma.saveSlot.findUnique({ + where: { + id: { + userId, + gameId, + index, + }, + }, + }); + if (!save) + throw createError({ statusCode: 404, statusMessage: "Save not found" }); + + const newSaveObjectId = uuidv4(); + const newSaveStream = await objectHandler.createWithStream( + newSaveObjectId, + { saveSlot: JSON.stringify({ userId, gameId, index }) }, + [] + ); + if (!newSaveStream) + throw createError({ + statusCode: 500, + statusMessage: "Failed to create writing stream to storage backend.", + }); + + let hash: string | undefined; + const hashPromise = Stream.promises.pipeline( + stream, + crypto.createHash("sha256").setEncoding("hex"), + async function (source) { + // Not sure how to get this to be typed + // @ts-expect-error + hash = (await source.toArray())[0]; + } + ); + + const uploadStream = Stream.promises.pipeline(stream, newSaveStream); + + await Promise.all([hashPromise, uploadStream]); + + if (!hash) { + await objectHandler.delete(newSaveObjectId); + throw createError({ + statusCode: 500, + statusMessage: "Hash failed to generate", + }); + } + + const newSave = await prisma.saveSlot.update({ + where: { + id: { + userId, + gameId, + index, + }, + }, + data: { + history: { + push: newSaveObjectId, + }, + historyChecksums: { + push: hash, + }, + }, + }); + + const historyLimit = await applicationSettings.get("saveSlotHistoryLimit"); + if (newSave.history.length > historyLimit) { + // Delete previous + const safeFromIndex = newSave.history.length - historyLimit; + + const toDelete = newSave.history.slice(0, safeFromIndex); + const toKeepObjects = newSave.history.slice(safeFromIndex); + const toKeepHashes = newSave.historyChecksums.slice(safeFromIndex); + + // Delete objects first, so if we error out, we don't lose track of objects in backend + for (const objectId of toDelete) { + await this.deleteObjectFromSave(gameId, userId, index, objectId); + } + + await prisma.saveSlot.update({ + where: { + id: { + userId, + gameId, + index, + }, + }, + data: { + history: toKeepObjects, + historyChecksums: toKeepHashes, + }, + }); + } + } +} + +export const saveManager = new SaveManager(); +export default saveManager; diff --git a/yarn.lock b/yarn.lock index f421e8d..4a6a090 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1754,6 +1754,13 @@ dependencies: undici-types "~6.20.0" +"@types/node@^22.13.16": + version "22.13.16" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.16.tgz#802cff8e4c3b3fc7461c2adcc92d73d89779edad" + integrity sha512-15tM+qA4Ypml/N7kyRdvfRjBQT2RL461uF1Bldn06K0Nzn1lY3nAPgHlsVrJxdZ9WhZiW0Fmc1lOYMtDsAuB3w== + dependencies: + undici-types "~6.20.0" + "@types/parse-path@^7.0.0": version "7.0.3" resolved "https://registry.yarnpkg.com/@types/parse-path/-/parse-path-7.0.3.tgz#cec2da2834ab58eb2eb579122d9a1fc13bd7ef36" @@ -2767,6 +2774,11 @@ cross-spawn@^7.0.3, cross-spawn@^7.0.6: dependencies: uncrypto "^0.1.3" +crypto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/crypto/-/crypto-1.0.1.tgz#2af1b7cad8175d24c8a1b0778255794a21803037" + integrity sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig== + css-declaration-sorter@^7.2.0: version "7.2.0" resolved "https://registry.yarnpkg.com/css-declaration-sorter/-/css-declaration-sorter-7.2.0.tgz#6dec1c9523bc4a643e088aab8f09e67a54961024"