mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-10 04:22:09 +10:00
feat: add cloud save backend
This commit is contained in:
@ -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
|
||||
},
|
||||
*/
|
||||
});
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ApplicationSettings" ADD COLUMN "saveSlotHistoryLimit" INTEGER NOT NULL DEFAULT 3;
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "SaveSlot" ALTER COLUMN "playtime" SET DEFAULT 0;
|
||||
@ -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[];
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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())
|
||||
|
||||
|
||||
@ -15,6 +15,8 @@ model User {
|
||||
articles Article[]
|
||||
|
||||
tokens APIToken[]
|
||||
|
||||
saves SaveSlot[]
|
||||
}
|
||||
|
||||
model Notification {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" });
|
||||
}
|
||||
);
|
||||
55
server/api/v1/client/saves/[gameid]/[slotindex]/index.get.ts
Normal file
55
server/api/v1/client/saves/[gameid]/[slotindex]/index.get.ts
Normal file
@ -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;
|
||||
}
|
||||
);
|
||||
46
server/api/v1/client/saves/[gameid]/[slotindex]/push.post.ts
Normal file
46
server/api/v1/client/saves/[gameid]/[slotindex]/push.post.ts
Normal file
@ -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;
|
||||
}
|
||||
);
|
||||
37
server/api/v1/client/saves/[gameid]/index.get.ts
Normal file
37
server/api/v1/client/saves/[gameid]/index.get.ts
Normal file
@ -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;
|
||||
}
|
||||
);
|
||||
62
server/api/v1/client/saves/[gameid]/index.post.ts
Normal file
62
server/api/v1/client/saves/[gameid]/index.post.ts
Normal file
@ -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;
|
||||
}
|
||||
);
|
||||
23
server/api/v1/client/saves/index.get.ts
Normal file
23
server/api/v1/client/saves/index.get.ts
Normal file
@ -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;
|
||||
}
|
||||
);
|
||||
20
server/api/v1/client/saves/settings.get.ts
Normal file
20
server/api/v1/client/saves/settings.get.ts
Normal file
@ -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 };
|
||||
}
|
||||
);
|
||||
@ -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> | 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> | 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]();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -25,6 +25,16 @@ export function defineClientEventHandler<T>(handler: EventHandlerFunction<T>) {
|
||||
|
||||
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<T>(handler: EventHandlerFunction<T>) {
|
||||
}
|
||||
|
||||
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({
|
||||
|
||||
@ -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<boolean> {
|
||||
const objectPath = path.join(this.baseObjectPath, sanitize(id));
|
||||
if (!fs.existsSync(objectPath)) return true;
|
||||
|
||||
@ -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<Source | undefined>;
|
||||
abstract write(id: ObjectReference, source: Source): Promise<boolean>;
|
||||
abstract startWriteStream(id: ObjectReference): Promise<Writable | undefined>;
|
||||
abstract create(
|
||||
id: string,
|
||||
source: Source,
|
||||
metadata: ObjectMetadata
|
||||
): Promise<ObjectReference | undefined>;
|
||||
abstract createWithWriteStream(
|
||||
id: string,
|
||||
metadata: ObjectMetadata
|
||||
): Promise<Writable | undefined>;
|
||||
abstract delete(id: ObjectReference): Promise<boolean>;
|
||||
abstract fetchMetadata(
|
||||
id: ObjectReference
|
||||
@ -60,30 +65,31 @@ export abstract class ObjectBackend {
|
||||
metadata: ObjectMetadata
|
||||
): Promise<boolean>;
|
||||
|
||||
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<Source>,
|
||||
metadata: { [key: string]: string },
|
||||
permissions: Array<string>
|
||||
) {
|
||||
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<string>
|
||||
) {
|
||||
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;
|
||||
|
||||
122
server/internal/saves/index.ts
Normal file
122
server/internal/saves/index.ts
Normal file
@ -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;
|
||||
12
yarn.lock
12
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"
|
||||
|
||||
Reference in New Issue
Block a user