Merge remote-tracking branch 'origin/develop' into db-store

This commit is contained in:
Huskydog9988
2025-04-03 19:21:05 -04:00
51 changed files with 3049 additions and 3169 deletions

View File

@@ -29,7 +29,7 @@ export default defineEventHandler(async (h3) => {
const description = options.description;
const gameId = options.id;
if (!(id || name || description)) {
if (!id || !name || !description) {
dump();
throw createError({

View File

@@ -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

View File

@@ -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" });
}
);

View 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;
}
);

View File

@@ -0,0 +1,52 @@
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,
client.id
);
return;
}
);

View 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;
}
);

View 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;
}
);

View 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;
}
);

View 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 };
}
);

View File

@@ -1,6 +1,6 @@
import path from "path";
import fs from "fs";
import droplet from "@drop/droplet";
import droplet from "@drop-oss/droplet";
import { CertificateStore, fsCertificateStore } from "./ca-store";
export type CertificateBundle = {

View File

@@ -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]();
}
}

View File

@@ -1,6 +1,6 @@
import { Client, User } from "@prisma/client";
import { EventHandlerRequest, H3Event } from "h3";
import droplet from "@drop/droplet";
import droplet from "@drop-oss/droplet";
import prisma from "../db/database";
import { useCertificateAuthority } from "~/server/plugins/ca";
@@ -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({

View File

@@ -13,7 +13,7 @@ import { fuzzy } from "fast-fuzzy";
import { recursivelyReaddir } from "../utils/recursivedirs";
import taskHandler from "../tasks";
import { parsePlatform } from "../utils/parseplatform";
import droplet from "@drop/droplet";
import droplet from "@drop-oss/droplet";
import notificationSystem from "../notifications";
class LibraryManager {

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,124 @@
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,
clientId: string | undefined = undefined
) {
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,
},
...(clientId && { lastUsedClientId: clientId }),
},
});
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;

View File

@@ -1,4 +1,4 @@
import droplet from "@drop/droplet";
import droplet from "@drop-oss/droplet";
import { MinimumRequestObject } from "~/server/h3";
import aclManager from "../acls";