Depot API & v4 (#298)

* feat: nginx + torrential basics & services system

* fix: lint + i18n

* fix: update torrential to remove openssl

* feat: add torrential to Docker build

* feat: move to self hosted runner

* fix: move off self-hosted runner

* fix: update nginx.conf

* feat: torrential cache invalidation

* fix: update torrential for cache invalidation

* feat: integrity check task

* fix: lint

* feat: move to version ids

* fix: client fixes and client-side checks

* feat: new depot apis and version id fixes

* feat: update torrential

* feat: droplet bump and remove unsafe update functions

* fix: lint

* feat: v4 featureset: emulators, multi-launch commands

* fix: lint

* fix: mobile ui for game editor

* feat: launch options

* fix: lint

* fix: remove axios, use $fetch

* feat: metadata and task api improvements

* feat: task actions

* fix: slight styling issue

* feat: fix style and lints

* feat: totp backend routes

* feat: oidc groups

* fix: update drop-base

* feat: creation of passkeys & totp

* feat: totp signin

* feat: webauthn mfa/signin

* feat: launch selecting ui

* fix: manually running tasks

* feat: update add company game modal to use new SelectorGame

* feat: executor selector

* fix(docker): update rust to rust nightly for torrential build (#305)

* feat: new version ui

* feat: move package lookup to build time to allow for deno dev

* fix: lint

* feat: localisation cleanup

* feat: apply localisation cleanup

* feat: potential i18n refactor logic

* feat: remove args from commands

* fix: lint

* fix: lockfile

---------

Co-authored-by: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com>
This commit is contained in:
DecDuck
2026-01-13 15:32:39 +11:00
committed by GitHub
parent 8ef983304c
commit 63ac2b8ffc
190 changed files with 5848 additions and 2309 deletions

View File

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

View File

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

View File

@@ -20,6 +20,11 @@ export default defineEventHandler(async (h3) => {
const action = body.action === "developed" ? "developers" : "publishers";
const actionType = body.enabled ? "connect" : "disconnect";
const game = await prisma.game.findUnique({ where: { id: body.id } });
if (!game) throw createError({ statusCode: 404, message: "Game not found" });
// Safe because we query the game above
// eslint-disable-next-line drop/no-prisma-delete
await prisma.game.update({
where: {
id: body.id,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,67 @@
import type { GameVersion } from "~/prisma/client/client";
import type { GameVersion, Prisma } from "~/prisma/client/client";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library";
export default defineEventHandler(async (h3) => {
async function getGameVersionSize<
T extends Omit<GameVersion, "dropletManifest">,
>(gameId: string, version: T) {
const size = await libraryManager.getGameVersionSize(
gameId,
version.versionId,
);
return { ...version, size };
}
export type AdminFetchGameType = Prisma.GameGetPayload<{
include: {
versions: {
include: {
setups: true;
launches: {
include: {
executor: {
include: {
gameVersion: {
select: {
versionId: true;
displayName: true;
versionPath: true;
game: {
select: {
id: true;
mName: true;
mIconObjectId: true;
};
};
};
};
};
};
executions: {
select: {
launchId: true;
};
};
};
};
};
omit: {
dropletManifest: true;
};
};
tags: true;
};
}>;
// Types in the route ensure we actually return the value as defined above
export default defineEventHandler<
{ body: never },
Promise<{
game: AdminFetchGameType;
unimportedVersions: string[] | undefined;
}>
>(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["game:read"]);
if (!allowed) throw createError({ statusCode: 403 });
@@ -15,12 +73,42 @@ export default defineEventHandler(async (h3) => {
},
include: {
versions: {
orderBy: {
versionIndex: "asc",
include: {
setups: true,
launches: {
include: {
executor: {
include: {
gameVersion: {
select: {
versionId: true,
displayName: true,
versionPath: true,
game: {
select: {
id: true,
mName: true,
mIconObjectId: true,
},
},
},
},
},
},
executions: {
select: {
launchId: true,
},
},
},
},
},
omit: {
dropletManifest: true,
},
orderBy: {
versionIndex: "asc",
},
},
tags: true,
},
@@ -29,16 +117,11 @@ export default defineEventHandler(async (h3) => {
if (!game || !game.libraryId)
throw createError({ statusCode: 404, statusMessage: "Game ID not found" });
const getGameVersionSize = async (version: GameVersion) => {
const size = await libraryManager.getGameVersionSize(
gameId,
version.versionName,
);
return { ...version, size };
};
const gameWithVersionSize = {
...game,
versions: await Promise.all(game.versions.map(getGameVersionSize)),
versions: await Promise.all(
game.versions.map((v) => getGameVersionSize(gameId, v)),
),
};
const unimportedVersions = await libraryManager.fetchUnimportedGameVersions(

View File

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

View File

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

View File

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

View File

@@ -4,8 +4,7 @@ import aclManager from "~/server/internal/acls";
import libraryManager from "~/server/internal/library";
const DeleteVersion = type({
id: "string",
versionName: "string",
version: "string",
}).configure(throwingArktype);
export default defineEventHandler<{ body: typeof DeleteVersion }>(
@@ -17,8 +16,8 @@ export default defineEventHandler<{ body: typeof DeleteVersion }>(
const body = await readDropValidatedBody(h3, DeleteVersion);
const gameId = body.id.toString();
const version = body.versionName.toString();
const gameId = getRouterParam(h3, "id")!;
const version = body.version.toString();
await libraryManager.deleteGameVersion(gameId, version);
return {};

View File

@@ -0,0 +1,35 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["game:read"]);
if (!allowed) throw createError({ statusCode: 403 });
const id = getRouterParam(h3, "id")!;
const game = await prisma.game.findUnique({
where: {
id,
},
select: {
versions: {
select: {
versionId: true,
displayName: true,
versionPath: true,
launches: {
select: {
launchId: true,
command: true,
name: true,
platform: true,
},
},
},
},
},
});
if (!game) throw createError({ statusCode: 404, message: "Game not found" });
return game.versions;
});

View File

@@ -0,0 +1,67 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
const UpdateVersionOrder = type({
versions: "string[]",
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["game:version:update"]);
if (!allowed) throw createError({ statusCode: 403 });
const body = await readDropValidatedBody(h3, UpdateVersionOrder);
const gameId = getRouterParam(h3, "id")!;
// We expect an array of the version names for this game
const unsortedVersions = await prisma.gameVersion.findMany({
where: {
versionId: { in: body.versions },
},
select: {
versionId: true,
versionIndex: true,
delta: true,
launches: { select: { platform: true } },
},
});
const versions = body.versions
.map((e) => unsortedVersions.find((v) => v.versionId === e))
.filter((e) => e !== undefined);
if (versions.length !== unsortedVersions.length)
throw createError({
statusCode: 500,
statusMessage: "Sorting versions yielded less results, somehow.",
});
// Validate the new order
const has: { [key: string]: boolean } = {};
for (const version of versions) {
for (const versionPlatform of version.launches.map((v) => v.platform)) {
if (version.delta && !has[versionPlatform])
throw createError({
statusCode: 400,
statusMessage: `"${version.versionId}" requires a base version to apply the delta to for platform ${versionPlatform}.`,
});
has[versionPlatform] = true;
}
}
await prisma.$transaction(
versions.map((version, versionIndex) =>
prisma.gameVersion.updateMany({
where: {
gameId: gameId,
versionId: version.versionId,
},
data: {
versionIndex: versionIndex,
},
}),
),
);
return versions.map((v) => v.versionId);
});

View File

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

View File

@@ -42,16 +42,18 @@ export default defineEventHandler(async (h3) => {
throw createError({ statusCode: 400, statusMessage: "Invalid game ID" });
}
const result = await prisma.game.update({
where: {
id: gameId,
},
data: {
mImageLibraryObjectIds: {
push: ids,
const result = (
await prisma.game.updateManyAndReturn({
where: {
id: gameId,
},
},
});
data: {
mImageLibraryObjectIds: {
push: ids,
},
},
})
).at(0);
await pull();
return result;

View File

@@ -1,72 +0,0 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
const UpdateVersionOrder = type({
id: "string",
versions: "string[]",
}).configure(throwingArktype);
export default defineEventHandler<{ body: typeof UpdateVersionOrder }>(
async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, [
"game:version:update",
]);
if (!allowed) throw createError({ statusCode: 403 });
const body = await readDropValidatedBody(h3, UpdateVersionOrder);
const gameId = body.id;
// We expect an array of the version names for this game
const unsortedVersions = await prisma.gameVersion.findMany({
where: {
versionName: { in: body.versions },
},
select: {
versionName: true,
versionIndex: true,
delta: true,
platform: true,
},
});
const versions = body.versions
.map((e) => unsortedVersions.find((v) => v.versionName === e))
.filter((e) => e !== undefined);
if (versions.length !== unsortedVersions.length)
throw createError({
statusCode: 500,
statusMessage: "Sorting versions yielded less results, somehow.",
});
// Validate the new order
const has: { [key: string]: boolean } = {};
for (const version of versions) {
if (version.delta && !has[version.platform])
throw createError({
statusCode: 400,
statusMessage: `"${version.versionName}" requires a base version to apply the delta to.`,
});
has[version.platform] = true;
}
await prisma.$transaction(
versions.map((version, versionIndex) =>
prisma.gameVersion.update({
where: {
gameId_versionName: {
gameId: gameId,
versionName: version.versionName,
},
},
data: {
versionIndex: versionIndex,
},
}),
),
);
return versions;
},
);

View File

@@ -1,84 +1,77 @@
import { type } from "arktype";
import { Platform } from "~/prisma/client/enums";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library";
import { parsePlatform } from "~/server/internal/utils/parseplatform";
const ImportVersion = type({
export const ImportVersion = type({
id: "string",
version: "string",
displayName: "string?",
launches: type({
platform: type.valueOf(Platform),
name: "string",
launch: "string",
umuId: "string?",
executorId: "string?",
}).array(),
setups: type({
platform: type.valueOf(Platform),
launch: "string",
}).array(),
platform: "string",
launch: "string = ''",
launchArgs: "string = ''",
setup: "string = ''",
setupArgs: "string = ''",
onlySetup: "boolean = false",
delta: "boolean = false",
umuId: "string = ''",
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["import:version:new"]);
if (!allowed) throw createError({ statusCode: 403 });
const {
id,
version,
platform,
launch,
launchArgs,
setup,
setupArgs,
onlySetup,
delta,
umuId,
} = await readDropValidatedBody(h3, ImportVersion);
const body = await readDropValidatedBody(h3, ImportVersion);
const platformParsed = parsePlatform(platform);
if (!platformParsed)
throw createError({ statusCode: 400, statusMessage: "Invalid platform." });
if (delta) {
const validOverlayVersions = await prisma.gameVersion.count({
where: { gameId: id, platform: platformParsed, delta: false },
});
if (validOverlayVersions == 0)
throw createError({
statusCode: 400,
statusMessage:
"Update mode requires a pre-existing version for this platform.",
if (body.delta) {
for (const platformObject of [...body.launches, ...body.setups].filter(
(v, i, a) => a.findIndex((k) => k.platform === v.platform) == i,
)) {
const validOverlayVersions = await prisma.gameVersion.count({
where: {
gameId: body.id,
delta: false,
launches: { some: { platform: platformObject.platform } },
},
});
if (validOverlayVersions == 0)
throw createError({
statusCode: 400,
statusMessage: "Update mode requires a pre-existing version.",
});
}
}
if (onlySetup) {
if (!setup)
if (body.onlySetup) {
if (body.setups.length == 0)
throw createError({
statusCode: 400,
statusMessage: 'Setup required in "setup mode".',
});
} else {
if (!delta && !launch)
if (body.launches.length == 0)
throw createError({
statusCode: 400,
statusMessage: "Launch executable is required for non-update versions",
statusMessage: "Launch executable is required.",
});
}
// startup & delta require more complex checking logic
const taskId = await libraryManager.importVersion(id, version, {
platform,
onlySetup,
launch,
launchArgs,
setup,
setupArgs,
umuId,
delta,
});
const taskId = await libraryManager.importVersion(
body.id,
body.version,
body,
);
if (!taskId)
throw createError({
statusCode: 400,

View File

@@ -14,15 +14,22 @@ export default defineEventHandler(async (h3) => {
statusMessage: "Missing id or version in request params",
});
const preload = await libraryManager.fetchUnimportedVersionInformation(
gameId,
versionName,
);
if (!preload)
throw createError({
statusCode: 400,
statusMessage: "Invalid game or version id/name",
});
try {
const preload = await libraryManager.fetchUnimportedVersionInformation(
gameId,
versionName,
);
if (!preload)
throw createError({
statusCode: 400,
statusMessage: "Invalid game or version id/name",
});
return preload;
return preload;
} catch (e) {
throw createError({
statusCode: 500,
message: `Failed to fetch preload information for ${gameId}: ${e}`,
});
}
});

View File

@@ -31,15 +31,15 @@ export default defineEventHandler<{ body: typeof UpdateLibrarySource.infer }>(
const constructor = libraryConstructors[source.backend];
try {
const newLibrary = constructor(body.options, source.id);
const newLibrary = constructor(body.options, source.id);
// Test we can actually use it
if ((await newLibrary.listGames()) === undefined) {
throw "Library failed to fetch games.";
}
// Test we can actually use it
if ((await newLibrary.listGames()) === undefined) {
throw "Library failed to fetch games.";
}
const updatedSource = await prisma.library.update({
const updatedSource = (
await prisma.library.updateManyAndReturn({
where: {
id: source.id,
},
@@ -47,22 +47,22 @@ export default defineEventHandler<{ body: typeof UpdateLibrarySource.infer }>(
name: body.name,
options: body.options,
},
});
libraryManager.removeLibrary(source.id);
libraryManager.addLibrary(newLibrary);
const workingSource: WorkingLibrarySource = {
...updatedSource,
working: true,
};
return workingSource;
} catch (e) {
})
).at(0);
if (!updatedSource)
throw createError({
statusCode: 400,
statusMessage: `Failed to create source: ${e}`,
statusCode: 404,
message: "Library source not found",
});
}
libraryManager.removeLibrary(source.id);
libraryManager.addLibrary(newLibrary);
const workingSource: WorkingLibrarySource = {
...updatedSource,
working: true,
};
return workingSource;
},
);

View File

@@ -0,0 +1,39 @@
import { ArkErrors, type } from "arktype";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
const Query = type({
q: "string",
});
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["game:read"]);
if (!allowed) throw createError({ statusCode: 403 });
const query = Query(getQuery(h3));
if (query instanceof ArkErrors)
throw createError({ statusCode: 400, message: query.summary });
const results: {
id: string;
mName: string;
mIconObjectId: string;
mShortDescription: string;
mReleased: string;
}[] =
await prisma.$queryRaw`SELECT id, "mName", "mIconObjectId", "mShortDescription", "mReleased" FROM "Game" WHERE SIMILARITY("mName", ${query.q}) > 0.2 ORDER BY SIMILARITY("mName", ${query.q}) DESC;`;
const resultsMapped = results.map(
(v) =>
({
id: v.id,
name: v.mName,
icon: v.mIconObjectId,
description: v.mShortDescription,
year: new Date(v.mReleased).getFullYear(),
}) satisfies GameMetadataSearchResult,
);
return resultsMapped;
});

View File

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

View File

@@ -0,0 +1,22 @@
import sessionHandler from "~/server/internal/session";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const session = await sessionHandler.getSession(h3);
if (!session || !session.authenticated || session.authenticated.level == 0)
throw createError({
statusCode: 403,
message: "Sign in before completing MFA",
});
const linkedMFAMec = await prisma.linkedMFAMec.findMany({
where: {
userId: session.authenticated.userId,
},
select: {
mec: true,
},
});
return linkedMFAMec.map((v) => v.mec);
});

View File

@@ -0,0 +1,48 @@
import sessionHandler from "~/server/internal/session";
import { type } from "arktype";
import prisma from "~/server/internal/db/database";
import { MFAMec } from "~/prisma/client/client";
import type { TOTPv1Credentials } from "~/server/internal/auth/totp";
import { dropDecodeArrayBase64 } from "~/server/internal/auth/totp";
import { SecretKey, totp } from "otp-io";
import { hmac } from "otp-io/crypto-web";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
const TOTPBody = type({
code: "string",
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const session = await sessionHandler.getSession(h3);
if (!session || !session.authenticated || session.authenticated.level == 0)
throw createError({
statusCode: 403,
message: "Sign in before completing MFA",
});
const body = await readDropValidatedBody(h3, TOTPBody);
const linkedMFAMec = await prisma.linkedMFAMec.findUnique({
where: {
userId_mec: {
userId: session.authenticated.userId,
mec: MFAMec.TOTP,
},
},
});
if (!linkedMFAMec)
throw createError({ statusCode: 400, message: "TOTP not enabled" });
const secret = (linkedMFAMec.credentials as unknown as TOTPv1Credentials)
.secret;
const secretKeyBuffer = dropDecodeArrayBase64(secret);
const secretKey = new SecretKey(secretKeyBuffer);
const code = await totp(hmac, { secret: secretKey });
if (code !== body.code)
throw createError({ statusCode: 403, message: "Invalid TOTP code." });
await sessionHandler.mfa(h3, 10);
return {};
});

View File

@@ -0,0 +1,108 @@
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
import { MFAMec } from "~/prisma/client/enums";
import { dropDecodeArrayBase64 } from "~/server/internal/auth/totp";
import type { WebAuthNv1Credentials } from "~/server/internal/auth/webauthn";
import { getRpId } from "~/server/internal/auth/webauthn";
import { systemConfig } from "~/server/internal/config/sys-conf";
import prisma from "~/server/internal/db/database";
import sessionHandler from "~/server/internal/session";
export default defineEventHandler(async (h3) => {
const session = await sessionHandler.getSession(h3);
if (!session || !session.authenticated || session.authenticated.level == 0)
throw createError({
statusCode: 403,
message: "Sign in before completing MFA",
});
const body = await readBody(h3);
const credentialId = body?.id;
if (!credentialId || typeof credentialId !== "string")
throw createError({
statusCode: 400,
message: "Missing credential id in body.",
});
const optionsRaw = await sessionHandler.getSessionDataKey<string>(
h3,
"webauthn/options",
);
if (!optionsRaw)
throw createError({
statusCode: 400,
message: "WebAuthn setup not started for this session.",
});
const options = JSON.parse(optionsRaw);
await sessionHandler.deleteSessionDataKey(h3, "webauthn/challenge");
const mfaMec = await prisma.linkedMFAMec.findUnique({
where: {
userId_mec: {
userId: session.authenticated.userId,
mec: MFAMec.WebAuthn,
},
},
});
if (!mfaMec)
throw createError({ statusCode: 400, message: "WebAuthn not enabled" });
const rpID = await getRpId();
const passkeys = (mfaMec.credentials as unknown as WebAuthNv1Credentials)
.passkeys;
const passkeyIndex = passkeys.findIndex((v) => v.id === body.id);
if (passkeyIndex == -1)
throw createError({ statusCode: 400, message: "Invalid credential ID." });
const passkey = passkeys[passkeyIndex];
const externalUrl = await systemConfig.getExternalUrl();
const url = new URL(externalUrl);
let verification;
try {
verification = await verifyAuthenticationResponse({
response: body,
expectedChallenge: options.challenge,
expectedOrigin: url.origin,
expectedRPID: rpID,
credential: {
id: passkey.id,
publicKey: Buffer.from(dropDecodeArrayBase64(passkey.publicKey)),
counter: passkey.counter,
transports: passkey.transports ?? [],
},
});
} catch (error) {
throw createError({
statusCode: 400,
message: (error as string)?.toString(),
});
}
const { verified } = verification;
if (!verified)
throw createError({ statusCode: 403, message: "Invalid passkey." });
const { authenticationInfo } = verification;
const { newCounter } = authenticationInfo;
passkeys[passkeyIndex].counter = newCounter;
(mfaMec.credentials as unknown as WebAuthNv1Credentials).passkeys = passkeys;
// Safe because we query it at the start of the route
// eslint-disable-next-line drop/no-prisma-delete
await prisma.linkedMFAMec.update({
where: {
userId_mec: {
userId: session.authenticated.userId,
mec: MFAMec.WebAuthn,
},
},
data: {
credentials: mfaMec.credentials!,
},
});
await sessionHandler.mfa(h3, 10);
return {};
});

View File

@@ -0,0 +1,49 @@
import { generateAuthenticationOptions } from "@simplewebauthn/server";
import { MFAMec } from "~/prisma/client/enums";
import type { WebAuthNv1Credentials } from "~/server/internal/auth/webauthn";
import { getRpId } from "~/server/internal/auth/webauthn";
import prisma from "~/server/internal/db/database";
import sessionHandler from "~/server/internal/session";
export default defineEventHandler(async (h3) => {
const session = await sessionHandler.getSession(h3);
if (!session || !session.authenticated || session.authenticated.level == 0)
throw createError({
statusCode: 403,
message: "Sign in before completing MFA",
});
const mec = await prisma.linkedMFAMec.findUnique({
where: {
userId_mec: {
userId: session.authenticated.userId,
mec: MFAMec.WebAuthn,
},
},
});
if (!mec)
throw createError({
statusCode: 400,
message: "WebAuthn not enabled on account.",
});
const rpID = await getRpId();
const passkeys = (mec.credentials as unknown as WebAuthNv1Credentials)
.passkeys;
const options = await generateAuthenticationOptions({
rpID,
allowCredentials: passkeys.map((v) => ({
id: v.id,
transports: v.transports ?? [],
})),
});
await sessionHandler.setSessionDataKey(
h3,
"webauthn/options",
JSON.stringify(options),
);
return options;
});

View File

@@ -0,0 +1,105 @@
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
import { MFAMec } from "~/prisma/client/enums";
import { dropDecodeArrayBase64 } from "~/server/internal/auth/totp";
import type { WebAuthNv1Credentials } from "~/server/internal/auth/webauthn";
import { getRpId } from "~/server/internal/auth/webauthn";
import { systemConfig } from "~/server/internal/config/sys-conf";
import prisma from "~/server/internal/db/database";
import sessionHandler from "~/server/internal/session";
export default defineEventHandler(async (h3) => {
const body = await readBody(h3);
const credentialId = body?.id;
if (!credentialId || typeof credentialId !== "string")
throw createError({
statusCode: 400,
message: "Missing credential id in body.",
});
const optionsRaw = await sessionHandler.getSessionDataKey<string>(
h3,
"webauthn/options",
);
if (!optionsRaw)
throw createError({
statusCode: 400,
message: "WebAuthn setup not started for this session.",
});
const options = JSON.parse(optionsRaw);
await sessionHandler.deleteSessionDataKey(h3, "webauthn/challenge");
// See WebAuthNv1Credentials for schema
const mfaMec = await prisma.linkedMFAMec.findFirst({
where: {
credentials: {
path: ["passkeys"],
array_contains: [
{
id: credentialId,
},
],
},
},
});
if (!mfaMec)
throw createError({ statusCode: 404, message: "Passkey not found" });
const passkeys = (mfaMec.credentials as unknown as WebAuthNv1Credentials)
.passkeys;
const passkeyIndex = passkeys.findIndex((v) => v.id === credentialId);
const passkey = passkeys[passkeyIndex]; // Exists guarantee by database
const rpID = await getRpId();
const externalUrl = await systemConfig.getExternalUrl();
const url = new URL(externalUrl);
let verification;
try {
verification = await verifyAuthenticationResponse({
response: body,
expectedChallenge: options.challenge,
expectedOrigin: url.origin,
expectedRPID: rpID,
credential: {
id: passkey.id,
publicKey: Buffer.from(dropDecodeArrayBase64(passkey.publicKey)),
counter: passkey.counter,
transports: passkey.transports ?? [],
},
});
} catch (error) {
throw createError({
statusCode: 400,
message: (error as string)?.toString(),
});
}
const { verified } = verification;
if (!verified)
throw createError({ statusCode: 403, message: "Invalid passkey." });
const { authenticationInfo } = verification;
const { newCounter } = authenticationInfo;
passkeys[passkeyIndex].counter = newCounter;
(mfaMec.credentials as unknown as WebAuthNv1Credentials).passkeys = passkeys;
// Safe because we query it before
// eslint-disable-next-line drop/no-prisma-delete
await prisma.linkedMFAMec.update({
where: {
userId_mec: {
userId: mfaMec.userId,
mec: MFAMec.WebAuthn,
},
},
data: {
credentials: mfaMec.credentials!,
},
});
await sessionHandler.signin(h3, mfaMec.userId, true);
await sessionHandler.mfa(h3, 10);
return {};
});

View File

@@ -0,0 +1,26 @@
import { generateAuthenticationOptions } from "@simplewebauthn/server";
import { getRpId } from "~/server/internal/auth/webauthn";
import sessionHandler from "~/server/internal/session";
export default defineEventHandler(async (h3) => {
const rpID = await getRpId();
const options = await generateAuthenticationOptions({
rpID,
allowCredentials: [],
});
if (
!(await sessionHandler.setSessionDataKey(
h3,
"webauthn/options",
JSON.stringify(options),
))
)
throw createError({
statusCode: 500,
message: "Failed to set session data key",
});
return options;
});

View File

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

View File

@@ -2,8 +2,9 @@ import clientHandler from "~/server/internal/clients/handler";
import sessionHandler from "~/server/internal/session";
export default defineEventHandler(async (h3) => {
const user = await sessionHandler.getSession(h3);
if (!user) throw createError({ statusCode: 403 });
const session = await sessionHandler.getSession(h3);
if (!session || !session.authenticated)
throw createError({ statusCode: 403 });
const body = await readBody(h3);
const clientId = await body.id;
@@ -15,7 +16,7 @@ export default defineEventHandler(async (h3) => {
statusMessage: "Invalid or expired client ID.",
});
if (client.userId != user.userId)
if (client.userId != session.authenticated.userId)
throw createError({
statusCode: 403,
statusMessage: "Not allowed to authorize this client.",

View File

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

View File

@@ -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.",

View File

@@ -2,8 +2,9 @@ import clientHandler from "~/server/internal/clients/handler";
import sessionHandler from "~/server/internal/session";
export default defineEventHandler(async (h3) => {
const user = await sessionHandler.getSession(h3);
if (!user) throw createError({ statusCode: 403 });
const session = await sessionHandler.getSession(h3);
if (!session || !session.authenticated)
throw createError({ statusCode: 403 });
const query = getQuery(h3);
const providedClientId = query.id?.toString();
@@ -20,13 +21,16 @@ export default defineEventHandler(async (h3) => {
statusMessage: "Request not found.",
});
if (client.userId && user.userId !== client.userId)
if (client.userId && session.authenticated.userId !== client.userId)
throw createError({
statusCode: 400,
statusMessage: "Client already claimed.",
});
await clientHandler.attachUserId(providedClientId, user.userId);
await clientHandler.attachUserId(
providedClientId,
session.authenticated.userId,
);
return client.data;
});

View File

@@ -31,19 +31,20 @@ export default defineEventHandler(async (h3) => {
statusMessage: "Invalid or unsupported platform",
});
const capabilityIterable = Object.entries(capabilities) as Array<
[InternalClientCapability, object]
>;
if (
capabilityIterable.length > 0 &&
capabilityIterable
.map(([capability]) => validCapabilities.find((v) => capability == v))
.filter((e) => e).length == 0
)
throw createError({
statusCode: 400,
message: "Invalid capabilities.",
});
const capabilityIterableRaw = Object.entries(capabilities);
const capabilityIterable = capabilityIterableRaw.map(
([capability, value]) => {
const actualCapability = validCapabilities.find(
(v) => capability.toLowerCase() == v.toLowerCase(),
);
if (!actualCapability)
throw createError({
statusCode: 400,
message: "Invalid capabilities.",
});
return [actualCapability, value];
},
) as Array<[InternalClientCapability, object]>;
if (
capabilityIterable.length > 0 &&
@@ -63,7 +64,7 @@ export default defineEventHandler(async (h3) => {
const result = await clientHandler.initiate({
name: body.name,
platform,
capabilities,
capabilities: Object.fromEntries(capabilityIterable),
mode: body.mode,
});

View File

@@ -1,4 +1,3 @@
import type { InternalClientCapability } from "~/server/internal/clients/capabilities";
import capabilityManager, {
validCapabilities,
} from "~/server/internal/clients/capabilities";
@@ -23,9 +22,11 @@ export default defineClientEventHandler(
statusMessage: "configuration must be an object",
});
const capability = rawCapability as InternalClientCapability;
const capability = validCapabilities.find(
(v) => v.toLowerCase() === rawCapability.toLowerCase(),
);
if (!validCapabilities.includes(capability))
if (!capability)
throw createError({
statusCode: 400,
statusMessage: "Invalid capability.",

View File

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

View File

@@ -3,22 +3,29 @@ import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library";
export default defineClientEventHandler(async (h3) => {
const query = getQuery(h3);
const id = query.id?.toString();
const version = query.version?.toString();
const id = getRouterParam(h3, "id");
const version = getRouterParam(h3, "versionid");
if (!id || !version)
throw createError({
statusCode: 400,
statusMessage: "Missing id or version in query",
statusMessage: "Missing id or version in route params",
});
const gameVersion = await prisma.gameVersion.findUnique({
where: {
gameId_versionName: {
gameId_versionId: {
gameId: id,
versionName: version,
versionId: version,
},
},
include: {
launches: {
include: {
executor: true,
},
},
setups: true,
},
});
if (!gameVersion)

View File

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

View File

@@ -20,6 +20,10 @@ export default defineClientEventHandler(async (h3) => {
omit: {
dropletManifest: true,
},
include: {
launches: true,
setups: true,
},
});
return versions;

View File

@@ -14,6 +14,7 @@ export default defineClientEventHandler(
"store:read",
"collections:read",
"object:read",
"settings:read",
];
const token = await prisma.aPIToken.create({

View File

@@ -0,0 +1,3 @@
# Don't add anything here
This route is overriden by the reverse proxy, and forwarded to the Rust depot.

View File

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

View File

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

View File

@@ -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),
},
},
},
},
},

View File

@@ -0,0 +1,19 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import { AuthMec } from "~/prisma/client/enums";
export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, []); // No ACLs only allows session authentication
if (!userId) throw createError({ statusCode: 403 });
const authMecs = await prisma.linkedAuthMec.findMany({
where: {
userId,
},
omit: {
credentials: true,
},
});
const authMecMap = Object.fromEntries(authMecs.map((v) => [v.mec, v]));
return { mecs: authMecMap, available: Object.keys(AuthMec) };
});

View File

@@ -0,0 +1,38 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import { MFAMec } from "~/prisma/client/enums";
import type { WebAuthNv1Credentials } from "~/server/internal/auth/webauthn";
export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, []); // No ACLs only allows session authentication
if (!userId) throw createError({ statusCode: 403 });
const mfaMecs = await prisma.linkedMFAMec.findMany({
where: {
userId,
},
});
// Sanitise and convert to map
const mfaMecMap = Object.fromEntries(
mfaMecs.map((v) => {
switch (v.mec) {
case MFAMec.TOTP:
v.credentials = {};
break;
case MFAMec.WebAuthn: {
const newCredentials = (
v.credentials as unknown as WebAuthNv1Credentials
).passkeys.map((v) => ({
name: v.name,
id: v.id,
created: v.created,
}));
v.credentials = newCredentials;
break;
}
}
return [v.mec, v];
}),
);
return { mecs: mfaMecMap, available: Object.keys(MFAMec) };
});

View File

@@ -0,0 +1,61 @@
import aclManager from "~/server/internal/acls";
import { totp, SecretKey } from "otp-io";
import { hmac } from "otp-io/crypto";
import prisma from "~/server/internal/db/database";
import { MFAMec } from "~/prisma/client/client";
import type { TOTPv1Credentials } from "~/server/internal/auth/totp";
import { dropDecodeArrayBase64 } from "~/server/internal/auth/totp";
import { createError } from "h3";
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
const TOTPEnableBody = type({
code: "string",
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const userId = await aclManager.allowUserSuperlevel(h3); // No ACLs only allows session authentication
if (!userId)
throw createError({
statusCode: 403,
message: "Not signed in or superlevelled.",
});
const body = await readDropValidatedBody(h3, TOTPEnableBody);
const existing = await prisma.linkedMFAMec.findUnique({
where: {
userId_mec: {
userId,
mec: MFAMec.TOTP,
},
enabled: false,
},
});
if (!existing)
throw createError({ statusCode: 400, message: "TOTP not started" });
const secret = (existing.credentials as unknown as TOTPv1Credentials).secret;
const secretKeyBuffer = dropDecodeArrayBase64(secret);
const secretKey = new SecretKey(secretKeyBuffer);
const code = await totp(hmac, { secret: secretKey });
if (body.code !== code)
throw createError({ statusCode: 400, message: "Invalid TOTP code." });
// Safe because we're updating something we just queried
// eslint-disable-next-line drop/no-prisma-delete
await prisma.linkedMFAMec.update({
where: {
userId_mec: {
userId,
mec: MFAMec.TOTP,
},
},
data: {
enabled: true,
},
});
return;
});

View File

@@ -0,0 +1,63 @@
import aclManager from "~/server/internal/acls";
import { generateKey, getKeyUri } from "otp-io";
import { randomBytes } from "otp-io/crypto";
import prisma from "~/server/internal/db/database";
import { MFAMec } from "~/prisma/client/client";
import type { TOTPv1Credentials } from "~/server/internal/auth/totp";
import { dropEncodeArrayBase64 } from "~/server/internal/auth/totp";
import { b32e } from "~/server/internal/auth/base32";
export default defineEventHandler(async (h3) => {
const userId = await aclManager.allowUserSuperlevel(h3); // No ACLs only allows session authentication
if (!userId)
throw createError({
statusCode: 403,
message: "Not signed in or superlevelled.",
});
const existing = await prisma.linkedMFAMec.findUnique({
where: {
userId_mec: {
userId,
mec: MFAMec.TOTP,
},
},
});
if (existing) {
if (!existing.enabled) {
// Safe because we're updating something we just queried
// eslint-disable-next-line drop/no-prisma-delete
await prisma.linkedMFAMec.delete({
where: { userId_mec: { userId: existing.userId, mec: existing.mec } },
});
} else {
throw createError({
statusCode: 400,
message: "Cannot set up TOTP authentication if already exists.",
});
}
}
const secret = generateKey(randomBytes, /* bytes: */ 20); // 5-20 good for Google Authenticator
const url = getKeyUri({
type: "totp",
secret,
name: userId,
issuer: "Drop",
});
await prisma.linkedMFAMec.create({
data: {
userId,
mec: MFAMec.TOTP,
version: 1,
credentials: {
secret: dropEncodeArrayBase64(secret.bytes),
} satisfies TOTPv1Credentials,
enabled: false,
},
});
return { url, secret: b32e(secret.bytes) };
});

View File

@@ -0,0 +1,110 @@
import aclManager from "~/server/internal/acls";
import { dropEncodeArrayBase64 } from "~/server/internal/auth/totp";
import type { WebAuthNv1Credentials } from "~/server/internal/auth/webauthn";
import { getRpId } from "~/server/internal/auth/webauthn";
import prisma from "~/server/internal/db/database";
import { MFAMec } from "~/prisma/client/enums";
import sessionHandler from "~/server/internal/session";
import type { PublicKeyCredentialCreationOptionsJSON } from "@simplewebauthn/server";
import { verifyRegistrationResponse } from "@simplewebauthn/server";
import { systemConfig } from "~/server/internal/config/sys-conf";
export default defineEventHandler(async (h3) => {
const userId = await aclManager.allowUserSuperlevel(h3); // No ACLs only allows session authentication
if (!userId)
throw createError({
statusCode: 403,
message: "Not signed in or superlevelled.",
});
const body = await readBody(h3);
const optionsRaw = await sessionHandler.getSessionDataKey<string>(
h3,
"webauthn/options",
);
if (!optionsRaw)
throw createError({
statusCode: 400,
message: "WebAuthn not started for this session.",
});
const options: PublicKeyCredentialCreationOptionsJSON =
JSON.parse(optionsRaw);
await sessionHandler.deleteSessionDataKey(h3, "webauthn/options");
const rpID = await getRpId();
const externalUrl = await systemConfig.getExternalUrl();
const url = new URL(externalUrl);
let verification;
try {
verification = await verifyRegistrationResponse({
response: body,
expectedChallenge: options.challenge,
expectedOrigin: url.origin,
expectedRPID: rpID,
});
} catch (error) {
console.error(error);
throw createError({
statusCode: 400,
message: (error as string)?.toString(),
});
}
const webauthnMec =
(await prisma.linkedMFAMec.findUnique({
where: { userId_mec: { userId, mec: MFAMec.WebAuthn } },
})) ??
(await prisma.linkedMFAMec.create({
data: {
userId,
mec: MFAMec.WebAuthn,
credentials: { passkeys: [] } satisfies WebAuthNv1Credentials,
version: 1,
},
}));
const { verified, registrationInfo } = verification;
if (!verified)
throw createError({
statusCode: 400,
message: "Failed to verify passkey.",
});
const { credential, credentialDeviceType, credentialBackedUp } =
registrationInfo!;
const name = await sessionHandler.getSessionDataKey<string>(
h3,
"webauthn/passkeyname",
);
(webauthnMec.credentials as unknown as WebAuthNv1Credentials).passkeys.push({
name: name ?? "My New Passkey",
created: Date.now(),
userId,
webAuthnUserId: options.user.id,
id: credential.id,
publicKey: dropEncodeArrayBase64(credential.publicKey),
counter: credential.counter,
transports: credential.transports,
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
});
// Safe because we're updating something we just queried
// eslint-disable-next-line drop/no-prisma-delete
await prisma.linkedMFAMec.update({
where: {
userId_mec: {
userId: webauthnMec.userId,
mec: webauthnMec.mec,
},
},
data: {
credentials: webauthnMec.credentials!,
},
});
return;
});

View File

@@ -0,0 +1,56 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import sessionHandler from "~/server/internal/session";
import { generateRegistrationOptions } from "@simplewebauthn/server";
import { getRpId } from "~/server/internal/auth/webauthn";
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
const CreatePasskey = type({
name: "string",
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const userId = await aclManager.allowUserSuperlevel(h3); // No ACLs only allows session authentication
if (!userId)
throw createError({
statusCode: 403,
message: "Not signed in or superlevelled.",
});
const body = await readDropValidatedBody(h3, CreatePasskey);
const user = await prisma.user.findUnique({
where: { id: userId },
select: { displayName: true, username: true },
});
if (!user)
throw createError({
statusCode: 500,
message: "Session refers to non-existed user.",
});
const rpID = await getRpId();
const registrationOptions = await generateRegistrationOptions({
rpID,
rpName: "Drop",
userName: user.username,
attestationType: "none",
authenticatorSelection: {
requireResidentKey: true,
residentKey: "required",
userVerification: "preferred",
},
});
await sessionHandler.setSessionDataKey(
h3,
"webauthn/options",
JSON.stringify(registrationOptions),
);
await sessionHandler.setSessionDataKey(h3, "webauthn/passkeyname", body.name);
return registrationOptions;
});

View File

@@ -0,0 +1,6 @@
import aclManager from "~/server/internal/acls";
export default defineEventHandler(async (h3) => {
const userId = await aclManager.allowUserSuperlevel(h3);
return userId !== undefined;
});

View File

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

View File

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

View File

@@ -103,4 +103,7 @@ export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = {
"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",
};

View File

@@ -43,6 +43,9 @@ export type UserACL = Array<(typeof userACLs)[number]>;
export const systemACLs = [
"setup",
"depot:new",
"depot:delete",
"auth:read",
"auth:simple:invitation:read",
"auth:simple:invitation:new",
@@ -123,8 +126,12 @@ class ACLManager {
if (!request)
throw new Error("Native web requests not available - weird deployment?");
// Sessions automatically have all ACLs
const user = await sessionHandler.getSession(request);
if (user) return user.userId;
const session = await sessionHandler.getSession(request);
if (session && session.authenticated) {
if (session.authenticated.level >= session.authenticated.requiredLevel)
return session.authenticated.userId;
return undefined;
}
const authorizationToken = this.getAuthorizationToken(request);
if (!authorizationToken) return undefined;
@@ -158,6 +165,19 @@ class ACLManager {
return undefined;
}
async allowUserSuperlevel(request: MinimumRequestObject | undefined) {
if (!request)
throw new Error("Native web requests not available - weird deployment?");
const session = await sessionHandler.getSession(request);
if (!session || !session.authenticated) return undefined;
if (session.authenticated.level < session.authenticated.requiredLevel)
return undefined;
if (session.authenticated.superleveledExpiry === undefined)
return undefined;
if (session.authenticated.superleveledExpiry < Date.now()) return undefined;
return session.authenticated.userId;
}
async allowSystemACL(
request: MinimumRequestObject | undefined,
acls: SystemACL,
@@ -165,14 +185,19 @@ class ACLManager {
if (!request)
throw new Error("Native web requests not available - weird deployment?");
const userSession = await sessionHandler.getSession(request);
if (userSession) {
if (userSession && userSession.authenticated) {
const user = await prisma.user.findUnique({
where: { id: userSession.userId },
where: { id: userSession.authenticated.userId },
});
if (user) {
if (!user) return false;
if (user.admin) return true;
return false;
if (!user.admin) return false;
if (
userSession.authenticated.level <
userSession.authenticated.requiredLevel
)
return false;
return true;
}
}
@@ -221,7 +246,7 @@ class ACLManager {
request: MinimumRequestObject,
): Promise<GlobalACL[] | undefined> {
const userSession = await sessionHandler.getSession(request);
if (!userSession) {
if (!userSession || !userSession.authenticated) {
const authorizationToken = this.getAuthorizationToken(request);
if (!authorizationToken) return undefined;
const token = await prisma.aPIToken.findUnique({
@@ -232,7 +257,7 @@ class ACLManager {
}
const user = await prisma.user.findUnique({
where: { id: userSession.userId },
where: { id: userSession.authenticated.userId },
select: {
admin: true,
},

View File

@@ -0,0 +1,2 @@
export function b32e(array: Uint8Array): string;
export function b32d(str: string): Uint8Array;

View File

@@ -0,0 +1,69 @@
// base32 elements
//RFC4648: why include 2? Z and 2 looks similar than 8 and O
const b32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
console.assert(b32.length === 32, b32.length);
const b32r = new Map(Array.from(b32, (ch, i) => [ch, i])).set("=", 0);
//[constants derived from character table size]
//cbit = 5 (as 32 == 2 ** 5), ubit = 8 (as byte)
//ccount = 8 (= cbit / gcd(cbit, ubit)), ucount = 5 (= ubit / gcd(cbit, ubit))
//cmask = 0x1f (= 2 ** cbit - 1), umask = 0xff (= 2 ** ubit - 1)
//const b32pad = [0, 6, 4, 3, 1];
const b32pad = Array.from(Array(5), (_, i) => ((8 - (i * 8) / 5) | 0) % 8);
function b32e5(u1, u2 = 0, u3 = 0, u4 = 0, u5 = 0) {
const u40 = u1 * 2 ** 32 + u2 * 2 ** 24 + u3 * 2 ** 16 + u4 * 2 ** 8 + u5;
return [
b32[(u40 / 2 ** 35) & 0x1f],
b32[(u40 / 2 ** 30) & 0x1f],
b32[(u40 / 2 ** 25) & 0x1f],
b32[(u40 / 2 ** 20) & 0x1f],
b32[(u40 / 2 ** 15) & 0x1f],
b32[(u40 / 2 ** 10) & 0x1f],
b32[(u40 / 2 ** 5) & 0x1f],
b32[u40 & 0x1f],
];
}
function b32d8(b1, b2, b3, b4, b5, b6, b7, b8) {
const u40 =
b32r.get(b1) * 2 ** 35 +
b32r.get(b2) * 2 ** 30 +
b32r.get(b3) * 2 ** 25 +
b32r.get(b4) * 2 ** 20 +
b32r.get(b5) * 2 ** 15 +
b32r.get(b6) * 2 ** 10 +
b32r.get(b7) * 2 ** 5 +
b32r.get(b8);
return [
(u40 / 2 ** 32) & 0xff,
(u40 / 2 ** 24) & 0xff,
(u40 / 2 ** 16) & 0xff,
(u40 / 2 ** 8) & 0xff,
u40 & 0xff,
];
}
// base32 encode/decode: Uint8Array <=> string
export function b32e(u8a) {
console.assert(u8a instanceof Uint8Array, u8a.constructor);
const len = u8a.length,
rem = len % 5;
const u5s = Array.from(Array((len - rem) / 5), (_, i) =>
u8a.subarray(i * 5, i * 5 + 5),
);
const pad = b32pad[rem];
const br = rem === 0 ? [] : b32e5(...u8a.subarray(-rem)).slice(0, 8 - pad);
return []
.concat(...u5s.map((u5) => b32e5(...u5)), br, ["=".repeat(pad)])
.join("");
}
export function b32d(bs) {
const len = bs.length;
if (len === 0) return new Uint8Array([]);
console.assert(len % 8 === 0, len);
const pad = len - bs.indexOf("="),
rem = b32pad.indexOf(pad);
console.assert(rem >= 0, pad);
console.assert(/^[A-Z2-7+/]*$/.test(bs.slice(0, len - pad)), bs);
const u8s = [].concat(...bs.match(/.{8}/g).map((b8) => b32d8(...b8)));
return new Uint8Array(rem > 0 ? u8s.slice(0, rem - 5) : u8s);
}

View File

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

View File

@@ -45,6 +45,7 @@ export class OIDCManager {
private clientSecret: string;
private externalUrl: string;
private userGroup?: string = process.env.OIDC_USER_GROUP;
private adminGroup?: string = process.env.OIDC_ADMIN_GROUP;
private usernameClaim: keyof OIDCUserInfo =
(process.env.OIDC_USERNAME_CLAIM as keyof OIDCUserInfo) ??
@@ -204,11 +205,11 @@ export class OIDCManager {
},
});
const user = await this.fetchOrCreateUser(userinfo);
const userOrError = await this.fetchOrCreateUser(userinfo);
if (typeof user === "string") return user;
if (typeof userOrError === "string") return userOrError;
return { user, options: session.options };
return { user: userOrError, options: session.options };
} catch (e) {
logger.error(e);
return `Request to identity provider failed: ${e}`;
@@ -236,6 +237,19 @@ export class OIDCManager {
if (!username)
return "Invalid username claim in OIDC response: " + this.usernameClaim;
const isAdmin =
userinfo.groups !== undefined &&
this.adminGroup !== undefined &&
userinfo.groups.includes(this.adminGroup);
const isUser = this.userGroup
? userinfo.groups !== undefined &&
userinfo.groups.includes(this.userGroup)
: true;
if (!(isAdmin || isUser))
return "Not authorized to access this application.";
/*
const takenUsername = await prisma.user.count({
where: {
@@ -274,11 +288,6 @@ export class OIDCManager {
);
}
const isAdmin =
userinfo.groups !== undefined &&
this.adminGroup !== undefined &&
userinfo.groups.includes(this.adminGroup);
const created = await prisma.linkedAuthMec.create({
data: {
mec: AuthMec.OpenID,

View File

@@ -0,0 +1,22 @@
export function dropEncodeArrayBase64(secret: Uint8Array): string {
return encode(secret);
}
export function dropDecodeArrayBase64(secret: string): Uint8Array {
return decode(secret);
}
const { fromCharCode } = String;
const encode = (uint8array: Uint8Array) => {
const output = [];
for (let i = 0, { length } = uint8array; i < length; i++)
output.push(fromCharCode(uint8array[i]));
return btoa(output.join(""));
};
const asCharCode = (c: string) => c.charCodeAt(0);
const decode = (chars: string) => Uint8Array.from(atob(chars), asCharCode);
export interface TOTPv1Credentials {
secret: string;
}

View File

@@ -0,0 +1,128 @@
import { ArkErrors, type } from "arktype";
import { systemConfig } from "../config/sys-conf";
import { dropDecodeArrayBase64 } from "./totp";
import { decode } from "cbor2";
import { createHash } from "node:crypto";
import cosekey from "parse-cosekey";
import type { AuthenticatorTransportFuture } from "@simplewebauthn/server";
export async function getRpId() {
const externalUrl =
process.env.WEBAUTHN_DOMAIN ?? (await systemConfig.getExternalUrl());
const externalUrlParsed = new URL(externalUrl);
return externalUrlParsed.hostname;
}
export interface Passkey {
name: string;
created: number;
userId: string;
webAuthnUserId: string;
id: string;
publicKey: string;
counter: number;
transports: Array<AuthenticatorTransportFuture> | undefined;
deviceType: string;
backedUp: boolean;
}
export interface WebAuthNv1Credentials {
passkeys: Array<Passkey>;
}
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<number, number> = 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,
};
}

View File

@@ -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<boolean> {
const result = await prisma.certificate.findUnique({

View File

@@ -102,8 +102,6 @@ class CapabilityManager {
() => Promise<void> | 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: {

View File

@@ -124,7 +124,8 @@ export function defineClientEventHandler<T>(handler: EventHandlerFunction<T>) {
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() },
});

View File

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

View File

@@ -1,117 +0,0 @@
import type { GameVersionModel } from "~/prisma/client/models";
import prisma from "../db/database";
import { sum } from "~/utils/array";
export type DropChunk = {
permissions: number;
ids: string[];
checksums: string[];
lengths: number[];
};
export type DropManifest = {
[key: string]: DropChunk;
};
export type DropManifestMetadata = {
manifest: DropManifest;
versionName: string;
};
export type DropGeneratedManifest = DropManifest & {
[key: string]: { versionName: string };
};
class ManifestGenerator {
private static generateManifestFromMetadata(
rootManifest: DropManifestMetadata,
...overlays: DropManifestMetadata[]
): DropGeneratedManifest {
if (overlays.length == 0) {
return Object.fromEntries(
Object.entries(rootManifest.manifest).map(([key, value]) => {
return [
key,
Object.assign({}, value, { versionName: rootManifest.versionName }),
];
}),
);
}
// Recurse in verse order through versions, skipping files that already exist.
const versions = [...overlays.reverse(), rootManifest];
const manifest: DropGeneratedManifest = {};
for (const version of versions) {
for (const [filename, chunk] of Object.entries(version.manifest)) {
if (manifest[filename]) continue;
manifest[filename] = Object.assign({}, chunk, {
versionName: version.versionName,
});
}
}
return manifest;
}
// Local function because eventual caching
async generateManifest(gameId: string, versionName: string) {
const versions: GameVersionModel[] = [];
const baseVersion = await prisma.gameVersion.findUnique({
where: {
gameId_versionName: {
gameId: gameId,
versionName: versionName,
},
},
});
if (!baseVersion) return undefined;
versions.push(baseVersion);
// Collect other versions if this is a delta
if (baseVersion.delta) {
// Start at the same index minus one, and keep grabbing them
// until we run out or we hit something that isn't a delta
// eslint-disable-next-line no-constant-condition
for (let i = baseVersion.versionIndex - 1; true; i--) {
const currentVersion = await prisma.gameVersion.findFirst({
where: {
gameId: gameId,
versionIndex: i,
platform: baseVersion.platform,
},
});
if (!currentVersion) return undefined;
versions.push(currentVersion);
if (!currentVersion.delta) break;
}
}
const leastToMost = versions.reverse();
const metadata: DropManifestMetadata[] = leastToMost.map((e) => {
return {
manifest: JSON.parse(
e.dropletManifest?.toString() ?? "{}",
) as DropManifest,
versionName: e.versionName,
};
});
const manifest = ManifestGenerator.generateManifestFromMetadata(
metadata[0],
...metadata.slice(1),
);
return manifest;
}
calculateManifestSize(manifest: DropManifest) {
return sum(
Object.values(manifest)
.map((chunk) => chunk.lengths)
.flat(),
);
}
}
export const manifestGenerator = new ManifestGenerator();
export default manifestGenerator;

View File

@@ -1,8 +1,8 @@
import cacheHandler from "../cache";
import prisma from "../db/database";
import manifestGenerator from "../downloads/manifest";
import { sum } from "../../../utils/array";
import type { Game, GameVersion } from "~/prisma/client/client";
import { castManifest } from "../library/manifest";
export type GameSize = {
gameName: string;
@@ -46,20 +46,16 @@ class GameSizeManager {
where: { gameId },
});
const sizes = await Promise.all(
versions.map((version) =>
manifestGenerator.calculateManifestSize(
JSON.parse(version.dropletManifest as string),
),
),
versions.map((version) => castManifest(version.dropletManifest).size),
);
return sum(sizes);
}
async getGameVersionSize(
gameId: string,
versionName?: string,
versionId?: string,
): Promise<number | null> {
if (!versionName) {
if (!versionId) {
const version = await prisma.gameVersion.findFirst({
where: { gameId },
orderBy: {
@@ -69,18 +65,14 @@ class GameSizeManager {
if (!version) {
return null;
}
versionName = version.versionName;
versionId = version.versionId;
}
const manifest = await manifestGenerator.generateManifest(
gameId,
versionName,
);
if (!manifest) {
return null;
}
const { dropletManifest } = (await prisma.gameVersion.findUnique({
where: { gameId_versionId: { versionId, gameId } },
}))!;
return manifestGenerator.calculateManifestSize(manifest);
return castManifest(dropletManifest).size;
}
private async isLatestVersion(
@@ -88,7 +80,7 @@ class GameSizeManager {
version: GameVersion,
): Promise<boolean> {
return gameVersions.length > 0
? gameVersions[0].versionName === version.versionName
? gameVersions[0].versionId === version.versionId
: false;
}
@@ -162,16 +154,16 @@ class GameSizeManager {
async cacheGameVersion(
game: Game & { versions: GameVersion[] },
versionName?: string,
versionId?: string,
) {
const cacheVersion = async (version: GameVersion) => {
const size = await this.getGameVersionSize(game.id, version.versionName);
if (!version.versionName || !size) {
const size = await this.getGameVersionSize(game.id, version.versionId);
if (!version.versionId || !size) {
return;
}
const versionsSizes = {
[version.versionName]: {
[version.versionId]: {
size,
gameName: game.mName,
gameId: game.id,
@@ -186,9 +178,9 @@ class GameSizeManager {
});
};
if (versionName) {
if (versionId) {
const version = await prisma.gameVersion.findFirst({
where: { gameId: game.id, versionName },
where: { gameId: game.id, versionId },
});
if (!version) {
return;

View File

@@ -9,7 +9,6 @@ import path from "path";
import prisma from "../db/database";
import { fuzzy } from "fast-fuzzy";
import taskHandler from "../tasks";
import { parsePlatform } from "../utils/parseplatform";
import notificationSystem from "../notifications";
import { GameNotFoundError, type LibraryProvider } from "./provider";
import { logger } from "../logging";
@@ -17,6 +16,8 @@ import type { GameModel } from "~/prisma/client/models";
import { createHash } from "node:crypto";
import type { WorkingLibrarySource } from "~/server/api/v1/admin/library/sources/index.get";
import gameSizeManager from "~/server/internal/gamesize";
import { TORRENTIAL_SERVICE } from "../services/services/torrential";
import type { ImportVersion } from "~/server/api/v1/admin/import/version/index.post";
export function createGameImportTaskId(libraryId: string, libraryPath: string) {
return createHash("md5")
@@ -24,7 +25,10 @@ export function createGameImportTaskId(libraryId: string, libraryPath: string) {
.digest("hex");
}
export function createVersionImportTaskId(gameId: string, versionName: string) {
export function createVersionImportTaskKey(
gameId: string,
versionName: string,
) {
return createHash("md5")
.update(`import:${gameId}:${versionName}`)
.digest("hex");
@@ -41,6 +45,10 @@ class LibraryManager {
this.libraries.delete(id);
}
getLibrary(libraryId: string): LibraryProvider<unknown> | undefined {
return this.libraries.get(libraryId);
}
async fetchLibraries(): Promise<WorkingLibrarySource[]> {
const libraries = await prisma.library.findMany({});
@@ -79,7 +87,7 @@ class LibraryManager {
const providerUnimportedGames = providerGames.filter(
(libraryPath) =>
!instanceGames[id]?.[libraryPath] &&
!taskHandler.hasTask(createGameImportTaskId(id, libraryPath)),
!taskHandler.hasTaskKey(createGameImportTaskId(id, libraryPath)),
);
unimportedGames[id] = providerUnimportedGames;
}
@@ -107,12 +115,12 @@ class LibraryManager {
try {
const versions = await provider.listVersions(
libraryPath,
game.versions.map((v) => v.versionName),
game.versions.map((v) => v.versionPath),
);
const unimportedVersions = versions.filter(
(e) =>
game.versions.findIndex((v) => v.versionName == e) == -1 &&
!taskHandler.hasTask(createVersionImportTaskId(game.id, e)),
game.versions.findIndex((v) => v.versionPath == e) == -1 &&
!taskHandler.hasTaskKey(createVersionImportTaskKey(game.id, e)),
);
return unimportedVersions;
} catch (e) {
@@ -127,12 +135,8 @@ class LibraryManager {
async fetchGamesWithStatus() {
const games = await prisma.game.findMany({
include: {
versions: {
select: {
versionName: true,
},
},
library: true,
versions: true,
},
orderBy: {
mName: "asc",
@@ -209,7 +213,7 @@ class LibraryManager {
if (checkExt != ext) continue;
const fuzzyValue = fuzzy(basename, game.mName);
options.push({
filename,
filename: filename.replaceAll(" ", "\\ "),
platform,
match: fuzzyValue,
});
@@ -243,24 +247,10 @@ class LibraryManager {
async importVersion(
gameId: string,
versionName: string,
metadata: {
platform: string;
onlySetup: boolean;
setup: string;
setupArgs: string;
launch: string;
launchArgs: string;
delta: boolean;
umuId: string;
},
versionPath: string,
metadata: typeof ImportVersion.infer,
) {
const taskId = createVersionImportTaskId(gameId, versionName);
const platform = parsePlatform(metadata.platform);
if (!platform) return undefined;
const taskKey = createVersionImportTaskKey(gameId, versionPath);
const game = await prisma.game.findUnique({
where: { id: gameId },
@@ -271,17 +261,17 @@ class LibraryManager {
const library = this.libraries.get(game.libraryId);
if (!library) return undefined;
taskHandler.create({
id: taskId,
return await taskHandler.create({
key: taskKey,
taskGroup: "import:game",
name: `Importing version ${versionName} for ${game.mName}`,
name: `Importing version ${versionPath} for ${game.mName}`,
acls: ["system:import:version:read"],
async run({ progress, logger }) {
// First, create the manifest via droplet.
// This takes up 90% of our progress, so we wrap it in a *0.9
const manifest = await library.generateDropletManifest(
game.libraryPath,
versionName,
versionPath,
(err, value) => {
if (err) throw err;
progress(value * 0.9);
@@ -299,59 +289,64 @@ class LibraryManager {
});
// Then, create the database object
if (metadata.onlySetup) {
await prisma.gameVersion.create({
data: {
gameId: gameId,
versionName: versionName,
dropletManifest: manifest,
versionIndex: currentIndex,
delta: metadata.delta,
umuIdOverride: metadata.umuId,
platform: platform,
onlySetup: true,
setupCommand: metadata.setup,
setupArgs: metadata.setupArgs.split(" "),
await prisma.gameVersion.create({
data: {
game: {
connect: {
id: gameId,
},
},
});
} else {
await prisma.gameVersion.create({
data: {
gameId: gameId,
versionName: versionName,
dropletManifest: manifest,
versionIndex: currentIndex,
delta: metadata.delta,
umuIdOverride: metadata.umuId,
platform: platform,
onlySetup: false,
setupCommand: metadata.setup,
setupArgs: metadata.setupArgs.split(" "),
launchCommand: metadata.launch,
launchArgs: metadata.launchArgs.split(" "),
displayName: metadata.displayName ?? null,
versionPath,
dropletManifest: manifest,
versionIndex: currentIndex,
delta: metadata.delta,
onlySetup: metadata.onlySetup,
setups: {
createMany: {
data: metadata.setups.map((v) => ({
command: v.launch,
platform: v.platform,
})),
},
},
});
}
launches: {
createMany: !metadata.onlySetup
? {
data: metadata.launches.map((v) => ({
name: v.name,
command: v.launch,
platform: v.platform,
...(v.executorId
? { executorId: v.executorId }
: undefined),
})),
}
: { data: [] },
},
},
});
logger.info("Successfully created version!");
notificationSystem.systemPush({
nonce: `version-create-${gameId}-${versionName}`,
title: `'${game.mName}' ('${versionName}') finished importing.`,
description: `Drop finished importing version ${versionName} for ${game.mName}.`,
nonce: `version-create-${gameId}-${versionPath}`,
title: `'${game.mName}' ('${versionPath}') finished importing.`,
description: `Drop finished importing version ${versionPath} for ${game.mName}.`,
actions: [`View|/admin/library/${gameId}`],
acls: ["system:import:version:read"],
});
await libraryManager.cacheCombinedGameSize(gameId);
await libraryManager.cacheGameVersionSize(gameId, versionName);
await libraryManager.cacheGameVersionSize(gameId, versionPath);
await TORRENTIAL_SERVICE.utils().invalidate(gameId, versionPath);
progress(100);
},
});
return taskId;
}
async peekFile(
@@ -381,7 +376,7 @@ class LibraryManager {
await prisma.gameVersion.deleteMany({
where: {
gameId: gameId,
versionName: version,
versionId: version,
},
});

View File

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

View File

@@ -7,15 +7,18 @@ import {
import { LibraryBackend } from "~/prisma/client/enums";
import fs from "fs";
import path from "path";
import droplet, { DropletHandler } from "@drop-oss/droplet";
import droplet, {
hasBackendForPath,
listFiles,
peekFile,
readFile,
} from "@drop-oss/droplet";
import { fsStats } from "~/server/internal/utils/files";
export const FilesystemProviderConfig = type({
baseDir: "string",
});
export const DROPLET_HANDLER = new DropletHandler();
export class FilesystemProvider
implements LibraryProvider<typeof FilesystemProviderConfig.infer>
{
@@ -64,7 +67,7 @@ export class FilesystemProvider
const validVersionDirs = versionDirs.filter((e) => {
if (ignoredVersions && ignoredVersions.includes(e)) return false;
const fullDir = path.join(this.config.baseDir, game, e);
return DROPLET_HANDLER.hasBackendForPath(fullDir);
return hasBackendForPath(fullDir);
});
return validVersionDirs;
}
@@ -72,7 +75,7 @@ export class FilesystemProvider
async versionReaddir(game: string, version: string): Promise<string[]> {
const versionDir = path.join(this.config.baseDir, game, version);
if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
return DROPLET_HANDLER.listFiles(versionDir);
return await listFiles(versionDir);
}
async generateDropletManifest(
@@ -83,25 +86,14 @@ export class FilesystemProvider
): Promise<string> {
const versionDir = path.join(this.config.baseDir, game, version);
if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
const manifest = await new Promise<string>((r, j) =>
droplet.generateManifest(
DROPLET_HANDLER,
versionDir,
progress,
log,
(err, result) => {
if (err) return j(err);
r(result);
},
),
);
const manifest = await droplet.generateManifest(versionDir, progress, log);
return manifest;
}
async peekFile(game: string, version: string, filename: string) {
const filepath = path.join(this.config.baseDir, game, version);
if (!fs.existsSync(filepath)) return undefined;
const stat = DROPLET_HANDLER.peekFile(filepath, filename);
const stat = await peekFile(filepath, filename);
return { size: Number(stat) };
}
@@ -113,7 +105,7 @@ export class FilesystemProvider
) {
const filepath = path.join(this.config.baseDir, game, version);
if (!fs.existsSync(filepath)) return undefined;
const stream = DROPLET_HANDLER.readFile(
const stream = await readFile(
filepath,
filename,
options?.start ? BigInt(options.start) : undefined,

View File

@@ -4,8 +4,12 @@ import { VersionNotFoundError } from "../provider";
import { LibraryBackend } from "~/prisma/client/enums";
import fs from "fs";
import path from "path";
import droplet from "@drop-oss/droplet";
import { DROPLET_HANDLER } from "./filesystem";
import droplet, {
hasBackendForPath,
listFiles,
peekFile,
readFile,
} from "@drop-oss/droplet";
import { fsStats } from "~/server/internal/utils/files";
export const FlatFilesystemProviderConfig = type({
@@ -48,7 +52,7 @@ export class FlatFilesystemProvider
const versionDirs = fs.readdirSync(this.config.baseDir);
const validVersionDirs = versionDirs.filter((e) => {
const fullDir = path.join(this.config.baseDir, e);
return DROPLET_HANDLER.hasBackendForPath(fullDir);
return hasBackendForPath(fullDir);
});
return validVersionDirs;
}
@@ -65,7 +69,7 @@ export class FlatFilesystemProvider
async versionReaddir(game: string, _version: string) {
const versionDir = path.join(this.config.baseDir, game);
if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
return DROPLET_HANDLER.listFiles(versionDir);
return await listFiles(versionDir);
}
async generateDropletManifest(
@@ -76,24 +80,13 @@ export class FlatFilesystemProvider
) {
const versionDir = path.join(this.config.baseDir, game);
if (!fs.existsSync(versionDir)) throw new VersionNotFoundError();
const manifest = await new Promise<string>((r, j) =>
droplet.generateManifest(
DROPLET_HANDLER,
versionDir,
progress,
log,
(err, result) => {
if (err) return j(err);
r(result);
},
),
);
const manifest = await droplet.generateManifest(versionDir, progress, log);
return manifest;
}
async peekFile(game: string, _version: string, filename: string) {
const filepath = path.join(this.config.baseDir, game);
if (!fs.existsSync(filepath)) return undefined;
const stat = DROPLET_HANDLER.peekFile(filepath, filename);
const stat = await peekFile(filepath, filename);
return { size: Number(stat) };
}
async readFile(
@@ -104,7 +97,7 @@ export class FlatFilesystemProvider
) {
const filepath = path.join(this.config.baseDir, game);
if (!fs.existsSync(filepath)) return undefined;
const stream = DROPLET_HANDLER.readFile(
const stream = await readFile(
filepath,
filename,
options?.start ? BigInt(options.start) : undefined,

View File

@@ -10,10 +10,10 @@ import type {
CompanyMetadata,
GameMetadataRating,
} from "./types";
import axios, { type AxiosRequestConfig } from "axios";
import TurndownService from "turndown";
import { DateTime } from "luxon";
import type { TaskRunContext } from "../tasks";
import type { NitroFetchOptions, NitroFetchRequest } from "nitropack";
interface GiantBombResponseType<T> {
error: "OK" | string;
@@ -120,7 +120,7 @@ export class GiantBombProvider implements MetadataProvider {
resource: string,
url: string,
query: { [key: string]: string },
options?: AxiosRequestConfig,
options?: NitroFetchOptions<NitroFetchRequest, "post">,
) {
const queryString = new URLSearchParams({
...query,
@@ -130,13 +130,7 @@ export class GiantBombProvider implements MetadataProvider {
const finalURL = `https://www.giantbomb.com/api/${resource}/${url}?${queryString}`;
const overlay: AxiosRequestConfig = {
url: finalURL,
baseURL: "",
};
const response = await axios.request<GiantBombResponseType<T>>(
Object.assign({}, options, overlay),
);
const response = await $fetch<GiantBombResponseType<T>>(finalURL, options);
return response;
}
@@ -152,7 +146,7 @@ export class GiantBombProvider implements MetadataProvider {
query: query,
resources: ["game"].join(","),
});
const mapped = results.data.results.map((result) => {
const mapped = results.results.map((result) => {
const date =
(result.original_release_date
? DateTime.fromISO(result.original_release_date).year
@@ -172,13 +166,13 @@ export class GiantBombProvider implements MetadataProvider {
return mapped;
}
async fetchGame(
{ id, publisher, developer, createObject }: _FetchGameMetadataParams,
{ id, company, createObject }: _FetchGameMetadataParams,
context?: TaskRunContext,
): Promise<GameMetadata> {
context?.logger.info("Using GiantBomb provider");
const result = await this.request<GameResult>("game", id, {});
const gameData = result.data.results;
const gameData = result.results;
const longDescription = gameData.description
? this.turndown.turndown(gameData.description)
@@ -189,7 +183,7 @@ export class GiantBombProvider implements MetadataProvider {
for (const pub of gameData.publishers) {
context?.logger.info(`Importing publisher "${pub.name}"`);
const res = await publisher(pub.name);
const res = await company(pub.name);
if (res === undefined) {
context?.logger.warn(`Failed to import publisher "${pub.name}"`);
continue;
@@ -206,7 +200,7 @@ export class GiantBombProvider implements MetadataProvider {
for (const dev of gameData.developers) {
context?.logger.info(`Importing developer "${dev.name}"`);
const res = await developer(dev.name);
const res = await company(dev.name);
if (res === undefined) {
context?.logger.warn(`Failed to import developer "${dev.name}"`);
continue;
@@ -244,8 +238,8 @@ export class GiantBombProvider implements MetadataProvider {
metadataSource: MetadataSource.GiantBomb,
metadataId: reviewId,
mReviewCount: 1,
mReviewRating: review.data.results.score / 5,
mReviewHref: review.data.results.site_detail_url,
mReviewRating: review.results.score / 5,
mReviewHref: review.results.site_detail_url,
});
}
}
@@ -289,8 +283,7 @@ export class GiantBombProvider implements MetadataProvider {
// Find the right entry
const company =
results.data.results.find((e) => e.name == query) ??
results.data.results.at(0);
results.results.find((e) => e.name == query) ?? results.results.at(0);
if (!company) return undefined;
const longDescription = company.description

View File

@@ -9,12 +9,11 @@ import type {
_FetchCompanyMetadataParams,
CompanyMetadata,
} from "./types";
import type { AxiosRequestConfig } from "axios";
import axios from "axios";
import { DateTime } from "luxon";
import * as jdenticon from "jdenticon";
import type { TaskRunContext } from "../tasks";
import { logger } from "~/server/internal/logging";
import type { NitroFetchOptions, NitroFetchRequest } from "nitropack";
type IGDBID = number;
@@ -171,20 +170,16 @@ export class IGDBProvider implements MetadataProvider {
grant_type: "client_credentials",
});
const response = await axios.request<TwitchAuthResponse>({
url: `https://id.twitch.tv/oauth2/token?${params.toString()}`,
baseURL: "",
method: "POST",
});
const response = await $fetch<TwitchAuthResponse>(
`https://id.twitch.tv/oauth2/token?${params.toString()}`,
{
method: "POST",
},
);
if (response.status !== 200)
throw new Error(
`Error in IGDB \nStatus Code: ${response.status}\n${response.data}`,
);
this.accessToken = response.data.access_token;
this.accessToken = response.access_token;
this.accessTokenExpiry = DateTime.now().plus({
seconds: response.data.expires_in,
seconds: response.expires_in,
});
logger.info("IGDB done authorizing with twitch");
@@ -202,7 +197,7 @@ export class IGDBProvider implements MetadataProvider {
private async request<T extends object>(
resource: string,
body: string,
options?: AxiosRequestConfig,
options?: NitroFetchOptions<NitroFetchRequest, "post">,
) {
await this.refreshCredentials();
@@ -214,11 +209,10 @@ export class IGDBProvider implements MetadataProvider {
const finalURL = `https://api.igdb.com/v4/${resource}`;
const overlay: AxiosRequestConfig = {
url: finalURL,
const overlay: NitroFetchOptions<NitroFetchRequest, "post"> = {
baseURL: "",
method: "POST",
data: body,
body,
headers: {
Accept: "application/json",
"Client-ID": this.clientId,
@@ -226,24 +220,13 @@ export class IGDBProvider implements MetadataProvider {
"content-type": "text/plain",
},
};
const response = await axios.request<T[] | IGDBErrorResponse[]>(
const response = await $fetch<T[] | IGDBErrorResponse[]>(
finalURL,
Object.assign({}, options, overlay),
);
if (response.status !== 200) {
let cause = "";
response.data.forEach((item) => {
if ("cause" in item) cause = item.cause;
});
throw new Error(
`Error in igdb \nStatus Code: ${response.status} \nCause: ${cause}`,
);
}
// should not have an error object if the status code is 200
return <T[]>response.data;
return <T[]>response;
}
private async _getMediaInternal(
@@ -356,7 +339,7 @@ export class IGDBProvider implements MetadataProvider {
return results;
}
async fetchGame(
{ id, publisher, developer, createObject }: _FetchGameMetadataParams,
{ id, company, createObject }: _FetchGameMetadataParams,
context?: TaskRunContext,
): Promise<GameMetadata> {
const body = `where id = ${id}; fields *;`;
@@ -416,34 +399,28 @@ export class IGDBProvider implements MetadataProvider {
{ name: string } & IGDBItem
>("companies", `where id = ${foundInvolved.company}; fields name;`);
for (const company of findCompanyResponse) {
for (const companyData of findCompanyResponse) {
context?.logger.info(
`Found involved company "${company.name}" as: ${foundInvolved.developer ? "developer, " : ""}${foundInvolved.publisher ? "publisher" : ""}`,
);
const res = await company(companyData.name);
if (res === undefined) {
context?.logger.warn(
`Failed to import company "${companyData.name}"`,
);
continue;
}
// if company was a dev or publisher
// CANNOT use else since a company can be both
if (foundInvolved.developer) {
const res = await developer(company.name);
if (res === undefined) {
context?.logger.warn(
`Failed to import developer "${company.name}"`,
);
continue;
}
context?.logger.info(`Imported developer "${company.name}"`);
context?.logger.info(`Imported developer "${companyData.name}"`);
developers.push(res);
}
if (foundInvolved.publisher) {
const res = await publisher(company.name);
if (res === undefined) {
context?.logger.warn(
`Failed to import publisher "${company.name}"`,
);
continue;
}
context?.logger.info(`Imported publisher "${company.name}"`);
context?.logger.info(`Imported publisher "${companyData.name}"`);
publishers.push(res);
}
}

View File

@@ -191,10 +191,10 @@ export class MetadataHandler {
const gameId = randomUUID();
const taskId = createGameImportTaskId(libraryId, libraryPath);
await taskHandler.create({
const key = createGameImportTaskId(libraryId, libraryPath);
return await taskHandler.create({
name: `Import game "${result.name}" (${libraryPath})`,
id: taskId,
key,
taskGroup: "import:game",
acls: ["system:import:game:read"],
async run(context) {
@@ -213,6 +213,11 @@ export class MetadataHandler {
}),
);
const companyLookupCache: {
[key: string]: Awaited<
ReturnType<typeof metadataHandler.fetchCompany>
>;
} = {};
let metadata: GameMetadata | undefined = undefined;
try {
metadata = await provider.fetchGame(
@@ -220,8 +225,13 @@ export class MetadataHandler {
id: result.id,
name: result.name,
// wrap in anonymous functions to keep references to this
publisher: (name: string) => metadataHandler.fetchCompany(name),
developer: (name: string) => metadataHandler.fetchCompany(name),
company: async (name: string) => {
if (companyLookupCache[name]) return companyLookupCache[name];
const companyData = await metadataHandler.fetchCompany(name);
companyLookupCache[name] = companyData;
return companyData;
},
createObject,
},
wrapTaskContext(context, {
@@ -281,10 +291,10 @@ export class MetadataHandler {
logger.info(`Finished game import.`);
progress(100);
context.addAction(`View Game:/admin/library/${gameId}`);
},
});
return taskId;
}
// Careful with this function, it has no typechecking

View File

@@ -9,14 +9,13 @@ import type {
CompanyMetadata,
GameMetadataRating,
} from "./types";
import type { AxiosRequestConfig } from "axios";
import axios from "axios";
import * as jdenticon from "jdenticon";
import { DateTime } from "luxon";
import * as cheerio from "cheerio";
import { type } from "arktype";
import type { TaskRunContext } from "../tasks";
import { logger } from "~/server/internal/logging";
import type { NitroFetchOptions, NitroFetchRequest } from "nitropack";
interface PCGamingWikiParseRawPage {
parse: {
@@ -104,35 +103,24 @@ export class PCGamingWikiProvider implements MetadataProvider {
private async request<T>(
query: URLSearchParams,
options?: AxiosRequestConfig,
options?: NitroFetchOptions<NitroFetchRequest, "get" | "post">,
) {
const finalURL = `https://www.pcgamingwiki.com/w/api.php?${query.toString()}`;
const overlay: AxiosRequestConfig = {
url: finalURL,
baseURL: "",
};
const response = await axios.request<T>(
Object.assign({}, options, overlay),
);
if (response.status !== 200)
throw new Error(
`Error in pcgamingwiki \nStatus Code: ${response.status}\n${response.data}`,
);
const response = await $fetch<T>(finalURL, options);
return response;
}
private async cargoQuery<T>(
query: URLSearchParams,
options?: AxiosRequestConfig,
options?: NitroFetchOptions<NitroFetchRequest, "get" | "post">,
) {
const response = await this.request<PCGamingWikiCargoResult<T>>(
query,
options,
);
if (response.data.error !== undefined)
if (response.error !== undefined)
throw new Error(`Error in pcgamingwiki cargo query`);
return response;
}
@@ -150,7 +138,7 @@ export class PCGamingWikiProvider implements MetadataProvider {
pageid: pageID,
});
const res = await this.request<PCGamingWikiParseRawPage>(searchParams);
const $ = cheerio.load(res.data.parse.text["*"]);
const $ = cheerio.load(res.parse.text["*"]);
// get intro based on 'introduction' class
const introductionEle = $(".introduction").first();
// remove citations from intro
@@ -281,7 +269,7 @@ export class PCGamingWikiProvider implements MetadataProvider {
await this.cargoQuery<PCGamingWikiSearchStub>(searchParams);
const results: GameMetadataSearchResult[] = [];
for (const result of response.data.cargoquery) {
for (const result of response.cargoquery) {
const game = result.title;
const pageContent = await this.getPageContent(game.PageID);
@@ -372,7 +360,7 @@ export class PCGamingWikiProvider implements MetadataProvider {
}
async fetchGame(
{ id, name, publisher, developer, createObject }: _FetchGameMetadataParams,
{ id, name, company, createObject }: _FetchGameMetadataParams,
context?: TaskRunContext,
): Promise<GameMetadata> {
context?.logger.info("Using PCGamingWiki provider");
@@ -391,10 +379,10 @@ export class PCGamingWikiProvider implements MetadataProvider {
this.cargoQuery<PCGamingWikiGame>(searchParams),
this.getPageContent(id),
]);
if (res.data.cargoquery.length < 1)
if (res.cargoquery.length < 1)
throw new Error("Error in pcgamingwiki, no game");
const game = res.data.cargoquery[0].title;
const game = res.cargoquery[0].title;
const publishers: CompanyModel[] = [];
if (game.Publishers !== null) {
@@ -403,7 +391,7 @@ export class PCGamingWikiProvider implements MetadataProvider {
for (const pub of pubListClean) {
context?.logger.info(`Importing publisher "${pub}"...`);
const res = await publisher(pub);
const res = await company(pub);
if (res === undefined) {
context?.logger.warn(`Failed to import publisher "${pub}"`);
continue;
@@ -422,7 +410,7 @@ export class PCGamingWikiProvider implements MetadataProvider {
const devListClean = this.parseWikiStringArray(game.Developers);
for (const dev of devListClean) {
context?.logger.info(`Importing developer "${dev}"...`);
const res = await developer(dev);
const res = await company(dev);
if (res === undefined) {
context?.logger.warn(`Failed to import developer "${dev}"`);
continue;
@@ -487,8 +475,8 @@ export class PCGamingWikiProvider implements MetadataProvider {
// TODO: replace with company logo
const icon = createObject(jdenticon.toPng(query, 512));
for (let i = 0; i < res.data.cargoquery.length; i++) {
const company = res.data.cargoquery[i].title;
for (let i = 0; i < res.cargoquery.length; i++) {
const company = res.cargoquery[i].title;
const fixedCompanyName =
this.parseWikiStringArray(company.PageName)[0] ?? company.PageName;

View File

@@ -9,8 +9,8 @@ import type {
GameMetadataRating,
} from "./types";
import type { TaskRunContext } from "../tasks";
import axios from "axios";
import * as jdenticon from "jdenticon";
import { load } from "cheerio";
/**
* Note: The Steam API is largely undocumented.
@@ -188,19 +188,15 @@ export class SteamProvider implements MetadataProvider {
}
async search(query: string): Promise<GameMetadataSearchResult[]> {
const response = await axios.get<SteamSearchStub[]>(
const response = await $fetch<SteamSearchStub[]>(
`https://steamcommunity.com/actions/SearchApps/${query}`,
);
if (
response.status !== 200 ||
!response.data ||
response.data.length === 0
) {
if (!response || response.length === 0) {
return [];
}
const result: GameMetadataSearchResult[] = response.data.map((item) => ({
const result: GameMetadataSearchResult[] = response.map((item) => ({
id: item.appid,
name: item.name,
icon: item.icon || "",
@@ -208,7 +204,7 @@ export class SteamProvider implements MetadataProvider {
year: 0,
}));
const ids = response.data.map((i) => i.appid);
const ids = response.map((i) => i.appid);
const detailsResponse = await this._fetchGameDetails(ids, {
include_basic_info: true,
@@ -235,7 +231,7 @@ export class SteamProvider implements MetadataProvider {
}
async fetchGame(
{ id, publisher, developer, createObject }: _FetchGameMetadataParams,
{ id, company, createObject }: _FetchGameMetadataParams,
context?: TaskRunContext,
): Promise<GameMetadata> {
context?.logger.info(`Starting Steam metadata fetch for game ID: ${id}`);
@@ -294,38 +290,66 @@ export class SteamProvider implements MetadataProvider {
context?.progress(70);
context?.logger.info("Processing publishers and developers...");
const storePage = await $fetch<string>(
`https://store.steampowered.com/app/${id}/`,
);
const $ = load(storePage);
const companyLinks = $("a")
.toArray()
.filter(
(v) =>
v.attribs["href"]?.startsWith(
"https://store.steampowered.com/developer/",
) ||
v.attribs["href"]?.startsWith(
"https://store.steampowered.com/publisher/",
),
)
.map((v) => v.attribs.href);
const companies: {
[key: string]: {
pub: boolean;
dev: boolean;
};
} = {};
companyLinks.forEach((v) => {
const [type, name] = v
.substring("https://store.steampowered.com/".length, v.indexOf("?"))
.split("/");
companies[name] ??= { pub: false, dev: false };
switch (type) {
case "publisher":
companies[name].pub = true;
break;
case "developer":
companies[name].dev = true;
break;
}
});
const publishers = [];
const publisherNames = currentGame.basic_info.publishers || [];
context?.logger.info(
`Found ${publisherNames.length} publisher(s) to process`,
);
for (const pub of publisherNames) {
context?.logger.info(`Processing publisher: "${pub.name}"`);
const comp = await publisher(pub.name);
if (!comp) {
context?.logger.warn(`Failed to import publisher "${pub.name}"`);
continue;
}
publishers.push(comp);
context?.logger.info(`Successfully imported publisher: "${pub.name}"`);
}
const developers = [];
const developerNames = currentGame.basic_info.developers || [];
context?.logger.info(
`Found ${developerNames.length} developer(s) to process`,
);
for (const dev of developerNames) {
context?.logger.info(`Processing developer: "${dev.name}"`);
const comp = await developer(dev.name);
if (!comp) {
context?.logger.warn(`Failed to import developer "${dev.name}"`);
continue;
for (const [companyName, types] of Object.entries(companies)) {
context?.logger.info(`Processing company: "${companyName}"`);
const comp = await company(companyName);
if (types.dev) {
developers.push(comp);
context?.logger.info(
`Successfully imported developer: "${companyName}"`,
);
}
if (types.pub) {
publishers.push(comp);
context?.logger.info(
`Successfully imported publisher: "${companyName}"`,
);
}
developers.push(comp);
context?.logger.info(`Successfully imported developer: "${dev.name}"`);
}
context?.logger.info(
@@ -425,23 +449,19 @@ export class SteamProvider implements MetadataProvider {
l: "english",
});
const response = await axios.get(
`https://store.steampowered.com/developer/${query.replaceAll(" ", "")}/?${searchParams.toString()}`,
{
maxRedirects: 0,
},
);
const url = `https://store.steampowered.com/developer/${encodeURIComponent(query)}/?${searchParams.toString()}`;
const response = await $fetch<string>(url);
if (response.status !== 200 || !response.data) {
if (!response) {
return undefined;
}
const html = response.data;
const html = response;
// Extract metadata from HTML meta tags
const metadata = this._extractMetaTagsFromHtml(html);
if (!metadata.title) {
if (!metadata.title || metadata.title == "Steam Search") {
return undefined;
}
@@ -623,14 +643,12 @@ export class SteamProvider implements MetadataProvider {
}),
});
const request = await axios.get<SteamAppDetailsPackage>(
const request = await $fetch<SteamAppDetailsPackage>(
`https://api.steampowered.com/IStoreBrowseService/GetItems/v1/?${searchParams.toString()}`,
);
if (request.status !== 200) return [];
const result = [];
const storeItems = request.data?.response?.store_items ?? [];
const storeItems = request.response?.store_items ?? [];
for (const item of storeItems) {
if (item.success !== 1) continue;
@@ -723,14 +741,14 @@ export class SteamProvider implements MetadataProvider {
language,
});
const request = await axios.get<SteamTagsPackage>(
const request = await $fetch<SteamTagsPackage>(
`https://api.steampowered.com/IStoreService/GetTagList/v1/?${searchParams.toString()}`,
);
if (request.status !== 200 || !request.data.response?.tags) return [];
if (!request.response?.tags) return [];
const tagMap = new Map<number, string>();
for (const tag of request.data.response.tags) {
for (const tag of request.response.tags) {
tagMap.set(tag.tagid, tag.name);
}
@@ -756,15 +774,11 @@ export class SteamProvider implements MetadataProvider {
l: language,
});
const request = await axios.get<SteamWebAppDetailsPackage>(
const request = await $fetch<SteamWebAppDetailsPackage>(
`https://store.steampowered.com/api/appdetails?${searchParams.toString()}`,
);
if (request.status !== 200) {
return undefined;
}
const appData = request.data[appid]?.data;
const appData = request[appid]?.data;
if (!appData) {
return undefined;
}

View File

@@ -65,8 +65,7 @@ export interface _FetchGameMetadataParams {
id: string;
name: string;
publisher: (query: string) => Promise<Company | undefined>;
developer: (query: string) => Promise<Company | undefined>;
company: (query: string) => Promise<Company | undefined>;
createObject: (data: TransactionDataType) => ObjectReference;
}

View File

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

View File

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

View File

@@ -0,0 +1,162 @@
import type { ChildProcess } from "child_process";
import { logger } from "../logging";
import type { Logger } from "pino";
class ServiceManager {
private services: Map<string, Service<unknown>> = new Map();
register(name: string, service: Service<unknown>) {
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<boolean>;
export type Healthcheck = () => Promise<boolean>;
export class Service<T> {
name: string;
private executor: Executor;
private setup: Setup | undefined;
private healthcheck: Healthcheck | undefined;
private logger: Logger<never>;
private currentProcess: ChildProcess | undefined;
private runningHealthcheck: boolean = false;
private healthy: boolean = true;
private spun: boolean = false;
private uutils: T;
constructor(
name: string,
executor: Executor,
setup?: Setup,
healthcheck?: Healthcheck,
utils?: T,
) {
this.name = name;
const serviceLogger = logger.child({ name: `service-${name}` });
this.logger = serviceLogger;
this.executor = executor;
this.setup = setup;
this.healthcheck = healthcheck;
this.uutils = utils!;
}
spin() {
if (this.spun) return;
this.launch();
if (this.healthcheck) {
setInterval(this.runHealthcheck, 1000 * 60 * 5); // Every 5 minutes
}
this.spun = true;
}
kill() {
this.spun = false;
this.currentProcess?.kill();
}
register() {
serviceManager.register(this.name, this);
}
private async launch() {
if (this.currentProcess) return;
const disableEnv = `EXTERNAL_SERVICE_${this.name.toUpperCase()}`;
if (!process.env[disableEnv]) {
const serviceProcess = this.executor();
this.logger.info("service launched");
serviceProcess.on("close", async (code, signal) => {
serviceProcess.kill();
this.currentProcess = undefined;
this.logger.warn(
`service exited with code ${code} (${signal}), restarting...`,
);
await new Promise((r) => setTimeout(r, 5000));
if (this.spun) this.launch();
});
serviceProcess.stdout?.on("data", (data) =>
this.logger.info(data.toString().trim()),
);
serviceProcess.stderr?.on("data", (data) =>
this.logger.error(data.toString().trim()),
);
this.currentProcess = serviceProcess;
}
if (this.setup) {
while (true) {
try {
const hasSetup = await this.setup();
if (hasSetup) break;
throw "setup function returned false...";
} catch (e) {
this.logger.warn(`failed setup, trying again... | ${e}`);
await new Promise((r) => setTimeout(r, 7000));
}
}
this.healthy = true;
}
}
private async runHealthcheck() {
if (!this.healthcheck || !this.currentProcess || this.runningHealthcheck)
return;
this.runningHealthcheck = true;
let fails = 0;
while (true) {
try {
const successful = await this.healthcheck();
if (successful) break;
} finally {
/* empty */
}
this.healthy = false;
fails++;
if (fails >= 5) {
this.currentProcess.kill();
this.runningHealthcheck = false;
return;
}
}
this.healthy = true;
this.runningHealthcheck = false;
}
serviceHealthy() {
return this.healthy;
}
utils() {
return this.uutils;
}
}
export const serviceManager = new ServiceManager();
export default serviceManager;

View File

@@ -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/`),
);

View File

@@ -0,0 +1,62 @@
import { spawn } from "child_process";
import { Service } from "..";
import fs from "fs";
import prisma from "../../db/database";
import { logger } from "../../logging";
import { systemConfig } from "../../config/sys-conf";
const INTERNAL_DEPOT_URL = new URL(
process.env.INTERNAL_DEPOT_URL ?? "http://localhost:5000",
);
export const TORRENTIAL_SERVICE = new Service(
"torrential",
() => {
const localDir = fs.readdirSync(".");
if ("torrential" in localDir) return spawn("./torrential", [], {});
const envPath = process.env.TORRENTIAL_PATH;
if (envPath) return spawn(envPath, [], {});
return spawn("torrential", [], {});
},
async () => {
const externalUrl = systemConfig.getExternalUrl();
const depot = await prisma.depot.upsert({
where: {
id: "torrential",
},
update: {
endpoint: `${externalUrl}/api/v1/depot`,
},
create: {
id: "torrential",
endpoint: `${externalUrl}/api/v1/depot`,
},
});
await $fetch(`${INTERNAL_DEPOT_URL.toString()}key`, {
method: "POST",
body: { key: depot.key },
});
return true;
},
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
async () => await $fetch(`${INTERNAL_DEPOT_URL.toString()}healthcheck`),
{
async invalidate(gameId: string, versionId: string) {
try {
await $fetch(`${INTERNAL_DEPOT_URL.toString()}invalidate`, {
method: "POST",
body: {
game: gameId,
version: versionId,
},
});
} catch (e) {
logger.warn("invalidate torrential cache failed with error: " + e);
}
},
},
);

View File

@@ -16,9 +16,17 @@ export default function createDBSessionHandler(): SessionProvider {
},
create: {
token,
...session,
...(session.authenticated?.userId
? { userId: session.authenticated?.userId }
: undefined),
expiresAt: session.expiresAt,
data: session as object,
},
update: {
expiresAt: session.expiresAt,
data: session as object,
},
update: session,
});
return true;
},
@@ -39,7 +47,7 @@ export default function createDBSessionHandler(): SessionProvider {
// i hate casting
// need to cast to unknown since result.data can be an N deep json object technically
// ts doesn't like that be cast down to the more constraining session type
return result as unknown as T;
return result.data as unknown as T;
},
async removeSession(token) {
await cache.remove(token);

View File

@@ -6,6 +6,7 @@ import type { MinimumRequestObject } from "~/server/h3";
import type { DurationLike } from "luxon";
import { DateTime } from "luxon";
import createDBSessionHandler from "./db";
import prisma from "../db/database";
/*
This implementation may need work.
@@ -13,6 +14,9 @@ This implementation may need work.
It exposes an API that should stay static, but there are plenty of opportunities for optimisation/organisation under the hood
*/
// 10 minutes
const SUPERLEVEL_LENGTH = 10 * 60 * 1000;
const dropTokenCookieName = "drop-token";
const normalSessionLength: DurationLike = {
days: 31,
@@ -21,6 +25,8 @@ const extendedSessionLength: DurationLike = {
year: 1,
};
type SigninResult = ["signin", "2fa", "fail"][number];
export class SessionHandler {
private sessionProvider: SessionProvider;
@@ -31,14 +37,53 @@ export class SessionHandler {
// this.sessionProvider = createMemorySessionProvider();
}
async signin(h3: H3Event, userId: string, rememberMe: boolean = false) {
async signin(
h3: H3Event,
userId: string,
rememberMe: boolean = false,
): Promise<SigninResult> {
const mfaCount = await prisma.linkedMFAMec.count({
where: { userId, enabled: true },
});
const expiresAt = this.createExipreAt(rememberMe);
const token = this.createSessionCookie(h3, expiresAt);
return await this.sessionProvider.setSession(token, {
userId,
const token =
this.getSessionToken(h3) ?? this.createSessionCookie(h3, expiresAt);
const session = (await this.sessionProvider.getSession(token)) ?? {
expiresAt,
data: {},
});
};
const wasAuthenticated = !!session.authenticated;
session.authenticated = {
userId,
level: session.authenticated?.level ?? 10,
requiredLevel: mfaCount > 0 ? 20 : 10,
superleveledExpiry: undefined,
};
if (
!wasAuthenticated &&
session.authenticated.level >= session.authenticated.requiredLevel
)
session.authenticated.superleveledExpiry = Date.now() + SUPERLEVEL_LENGTH;
const success = await this.sessionProvider.setSession(token, session);
if (!success) return "fail";
if (session.authenticated.level < session.authenticated.requiredLevel)
return "2fa";
return "signin";
}
async mfa(h3: H3Event, amount: number) {
const token = this.getSessionToken(h3);
if (!token)
throw createError({ statusCode: 403, message: "User not signed in" });
const session = await this.sessionProvider.getSession(token);
if (!session || !session.authenticated)
throw createError({ statusCode: 403, message: "User not signed in" });
session.authenticated.level += amount;
await this.sessionProvider.setSession(token, session);
}
/**
@@ -48,12 +93,54 @@ export class SessionHandler {
async getSession<T extends Session>(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<T>(token);
if (!data) return undefined;
if (new Date(data.expiresAt).getTime() < Date.now()) return undefined; // Expired
return data;
}
async getSessionDataKey<T>(
request: MinimumRequestObject,
key: string,
): Promise<T | undefined> {
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<T>(request: H3Event, key: string, value: T) {
const expiresAt = this.createExipreAt(true);
const token =
this.getSessionToken(request) ??
this.createSessionCookie(request, expiresAt);
const session = (await this.sessionProvider.getSession(token)) ?? {
expiresAt,
data: {},
};
console.log(session);
session.data[key] = value;
await this.sessionProvider.setSession(token, session);
return true;
}
async deleteSessionDataKey(request: MinimumRequestObject, key: string) {
const token = this.getSessionToken(request);
if (!token) return false;
const session = await this.sessionProvider.getSession(token);
if (!session) return false;
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete session.data[key];
await this.sessionProvider.setSession(token, session);
return true;
}
/**
* Signout session associated with request and deauthenticates it
* @param request

View File

@@ -1,5 +1,6 @@
export type Session = {
userId: string;
authenticated?: AuthenticatedSession;
expiresAt: Date;
data: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -7,6 +8,13 @@ export type Session = {
};
};
export interface AuthenticatedSession {
userId: string;
level: number;
requiredLevel: number;
superleveledExpiry: number | undefined;
}
export interface SessionProvider {
getSession: <T extends Session>(token: string) => Promise<T | undefined>;
setSession: (token: string, data: Session) => Promise<boolean>;

View File

@@ -14,15 +14,19 @@ import pino from "pino";
import { logger } from "~/server/internal/logging";
import { Writable } from "node:stream";
type TaskActionLink = `${string}:${string}`;
// a task that has been run
type FinishedTask = {
success: boolean;
progress: number;
key: string | undefined;
log: string[];
error: { title: string; description: string } | undefined;
name: string;
taskGroup: TaskGroup;
acls: string[];
actions: TaskActionLink[];
// ISO timestamp of when the task started
startTime: string;
@@ -53,7 +57,6 @@ class TaskHandler {
"cleanup:invitations",
"cleanup:sessions",
"check:update",
"debug",
];
private weeklyScheduledTasks: TaskGroup[] = ["cleanup:objects"];
@@ -74,8 +77,12 @@ class TaskHandler {
this.taskCreators.set(task.taskGroup, task.build);
}
async create(task: Task) {
if (this.hasTask(task.id)) throw new Error("Task with ID already exists.");
async create(iTask: Omit<Task, "id">) {
const task: Task = { ...iTask, id: crypto.randomUUID() };
if (this.hasTaskID(task.id))
throw new Error("Task with ID already exists.");
if (task.key && this.hasTaskKey(task.key))
throw new Error("Task with key already exists");
let updateCollectTimeout: NodeJS.Timeout | undefined;
let updateCollectResolves: Array<(value: unknown) => void> = [];
@@ -115,6 +122,7 @@ class TaskHandler {
error: taskEntry.error,
log: taskEntry.log.slice(logOffset),
reset,
actions: taskEntry.actions,
};
logOffset = taskEntry.log.length;
@@ -189,6 +197,7 @@ class TaskHandler {
this.taskPool.set(task.id, {
name: task.name,
key: task.key,
taskGroup: task.taskGroup,
success: false,
progress: 0,
@@ -198,6 +207,7 @@ class TaskHandler {
acls: task.acls,
startTime: new Date().toISOString(),
endTime: undefined,
actions: task.initialActions ?? [],
});
await updateAllClients(true);
@@ -205,9 +215,13 @@ class TaskHandler {
droplet.callAltThreadFunc(async () => {
const taskEntry = this.taskPool.get(task.id);
if (!taskEntry) throw new Error("No task entry");
const addAction = (action: TaskActionLink) => {
taskEntry.actions.push(action);
updateAllClients();
};
try {
await task.run({ progress, logger: taskLogger });
await task.run({ progress, logger: taskLogger, addAction });
taskEntry.success = true;
} catch (error: unknown) {
taskEntry.success = false;
@@ -239,6 +253,7 @@ class TaskHandler {
log: taskEntry.log,
acls: taskEntry.acls,
actions: taskEntry.actions,
...(taskEntry.error ? { error: taskEntry.error } : undefined),
},
@@ -246,6 +261,8 @@ class TaskHandler {
this.taskPool.delete(task.id);
});
return task.id;
}
async connect(
@@ -290,6 +307,7 @@ class TaskHandler {
| undefined,
log: task.log,
progress: task.progress,
actions: task.actions as TaskActionLink[],
};
peer.send(JSON.stringify(catchupMessage));
}
@@ -336,10 +354,16 @@ class TaskHandler {
.toArray();
}
hasTask(id: string) {
hasTaskID(id: string) {
return this.taskPool.has(id);
}
hasTaskKey(key: string) {
return (
this.taskPool.values().find((v) => v.key && v.key == key) != undefined
);
}
dailyTasks() {
return this.dailyScheduledTasks;
}
@@ -355,8 +379,8 @@ class TaskHandler {
return;
}
const task = taskConstructor();
await this.create(task);
return task.id;
const id = await this.create(task);
return id;
}
/**
@@ -415,6 +439,7 @@ class TaskHandler {
export type TaskRunContext = {
progress: (progress: number) => void;
logger: typeof logger;
addAction: (link: TaskActionLink) => void;
};
export function wrapTaskContext(
@@ -426,6 +451,7 @@ export function wrapTaskContext(
});
return {
...context,
progress(progress) {
if (progress > 100 || progress < 0) {
logger.warn("[wrapTaskContext] progress must be between 0 and 100");
@@ -444,10 +470,12 @@ export function wrapTaskContext(
export interface Task {
id: string;
key?: string;
taskGroup: TaskGroup;
name: string;
run: (context: TaskRunContext) => Promise<void>;
acls: GlobalACL[];
initialActions?: TaskActionLink[];
}
export type TaskMessage = {
@@ -458,6 +486,7 @@ export type TaskMessage = {
error: null | undefined | { title: string; description: string };
log: string[];
reset?: boolean;
actions: TaskActionLink[];
};
export type PeerImpl = {
@@ -471,6 +500,7 @@ export interface BuildTask {
name: string;
run: (context: TaskRunContext) => Promise<void>;
acls: GlobalACL[];
initialActions?: TaskActionLink[];
}
interface DropTask {
@@ -519,6 +549,7 @@ export function defineDropTask(buildTask: BuildTask): DropTask {
name: buildTask.name,
run: buildTask.run,
acls: buildTask.acls,
initialActions: buildTask.initialActions ?? [],
}),
};
}

View File

@@ -11,7 +11,7 @@ type FieldReferenceMap = {
};
export default defineDropTask({
buildId: () => `cleanup:objects:${new Date().toISOString()}`,
buildId: () => `cleanup:objects:${Date.now()}`,
name: "Cleanup Objects",
acls: ["system:maintenance:read"],
taskGroup: "cleanup:objects",

View File

@@ -1,7 +1,6 @@
import { applicationSettings } from "../internal/config/application-configuration";
import type { MetadataProvider } from "../internal/metadata";
import metadataHandler from "../internal/metadata";
import { GiantBombProvider } from "../internal/metadata/giantbomb";
import { IGDBProvider } from "../internal/metadata/igdb";
import { ManualMetadataProvider } from "../internal/metadata/manual";
import { PCGamingWikiProvider } from "../internal/metadata/pcgamingwiki";
@@ -10,7 +9,7 @@ import { logger } from "~/server/internal/logging";
export default defineNitroPlugin(async (_nitro) => {
const metadataProviders = [
GiantBombProvider,
//GiantBombProvider, // GiantBomb changed their API
SteamProvider,
PCGamingWikiProvider,
IGDBProvider,

View File

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

View File

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

View File

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

View File

@@ -1,11 +0,0 @@
import contextManager from "../internal/downloads/coordinator";
export default defineTask({
meta: {
name: "downloadCleanup",
},
async run() {
await contextManager.cleanup();
return { result: true };
},
});

View File

@@ -1,6 +1,6 @@
{
"extends": "../.nuxt/tsconfig.server.json",
"compilerOptions": {
"exactOptionalPropertyTypes": true
}
},
"extends": "../.nuxt/tsconfig.server.json"
}