In-app store, torrential backend, locales (#332)

* feat: add store nav and fixes

* fix: reduce password requirement & new task error ui

* fix: client webtoken fix

* fix: delta versions and dockerfile

* fix: use setup platforms for filter & display

* fix: setup not accounted when returning valid options

* feat: tighter delta version support

* feat: dl/disk size

* feat: offload manifest generation to torrential

* fix: bump torrential

* feat: remove droplet

* feat: bump torrential

* feat: convert locales
This commit is contained in:
DecDuck
2026-02-06 00:12:24 +11:00
committed by GitHub
parent 837bc6eb1d
commit d234f8df33
82 changed files with 1737 additions and 967 deletions
@@ -1,55 +0,0 @@
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({
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: {
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,
};
});
@@ -1,24 +0,0 @@
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,
},
where: {
versionPath: {
not: null
}
}
},
},
});
return games;
});
+4 -5
View File
@@ -1,17 +1,16 @@
import type { GameVersion, Prisma } from "~/prisma/client/client";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import gameSizeManager from "~/server/internal/gamesize";
import type { UnimportedVersionInformation } from "~/server/internal/library";
import libraryManager from "~/server/internal/library";
async function getGameVersionSize<
T extends Omit<GameVersion, "dropletManifest">,
>(gameId: string, version: T) {
const size = await libraryManager.getGameVersionSize(
gameId,
version.versionId,
);
return { ...version, size };
const clientSize = await gameSizeManager.getVersionSize(version.versionId);
const diskSize = await gameSizeManager.getVersionDiskSize(version.versionId);
return { ...version, diskSize, clientSize };
}
export type AdminFetchGameType = Prisma.GameGetPayload<{
-8
View File
@@ -10,18 +10,10 @@ export default defineEventHandler(async (h3) => {
const sources = await libraryManager.fetchLibraries();
const userStats = await userStatsManager.getUserStats();
const biggestGamesCombined =
await libraryManager.getBiggestGamesCombinedVersions(5);
const biggestGamesLatest =
await libraryManager.getBiggestGamesLatestVersions(5);
return {
gameCount: await prisma.game.count(),
version: systemConfig.getDropVersion(),
userStats,
sources,
biggestGamesLatest,
biggestGamesCombined,
};
});
@@ -50,7 +50,12 @@ export default defineEventHandler(async (h3) => {
where: {
gameId: body.id,
delta: false,
launches: { some: { platform: platformObject.platform } },
OR: [
{ launches: { some: { platform: platformObject.platform } } },
{
setups: { some: { platform: platformObject.platform } },
},
],
},
});
if (validOverlayVersions == 0)
+8 -8
View File
@@ -23,7 +23,7 @@ export default defineEventHandler<{
if (!authManager.getAuthProviders().Simple)
throw createError({
statusCode: 403,
statusMessage: t("errors.auth.method.signinDisabled"),
message: t("errors.auth.method.signinDisabled"),
});
const body = signinValidator(await readBody(h3));
@@ -33,7 +33,7 @@ export default defineEventHandler<{
throw createError({
statusCode: 400,
statusMessage: body.summary,
message: body.summary,
});
}
@@ -57,13 +57,13 @@ export default defineEventHandler<{
if (!authMek)
throw createError({
statusCode: 401,
statusMessage: t("errors.auth.invalidUserOrPass"),
message: t("errors.auth.invalidUserOrPass"),
});
if (!authMek.user.enabled)
throw createError({
statusCode: 403,
statusMessage: t("errors.auth.disabled"),
message: t("errors.auth.disabled"),
});
// LEGACY bcrypt
@@ -74,13 +74,13 @@ export default defineEventHandler<{
if (!hash)
throw createError({
statusCode: 500,
statusMessage: t("errors.auth.invalidPassState"),
message: t("errors.auth.invalidPassState"),
});
if (!(await checkHashBcrypt(body.password, hash)))
throw createError({
statusCode: 401,
statusMessage: t("errors.auth.invalidUserOrPass"),
message: t("errors.auth.invalidUserOrPass"),
});
// TODO: send user to forgot password screen or something to force them to change their password to new system
@@ -101,13 +101,13 @@ export default defineEventHandler<{
if (!hash || typeof hash !== "string")
throw createError({
statusCode: 500,
statusMessage: t("errors.auth.invalidPassState"),
message: t("errors.auth.invalidPassState"),
});
if (!(await checkHashArgon2(body.password, hash)))
throw createError({
statusCode: 401,
statusMessage: t("errors.auth.invalidUserOrPass"),
message: t("errors.auth.invalidUserOrPass"),
});
const result = await sessionHandler.signin(h3, authMek.userId, {
+4 -4
View File
@@ -15,7 +15,7 @@ export const SharedRegisterValidator = type({
const CreateUserValidator = SharedRegisterValidator.and({
invitation: "string",
password: "string >= 14",
password: "string >= 8",
"displayName?": "string | undefined",
}).configure(throwingArktype);
@@ -27,7 +27,7 @@ export default defineEventHandler<{
if (!authManager.getAuthProviders().Simple)
throw createError({
statusCode: 403,
statusMessage: t("errors.auth.method.signinDisabled"),
message: t("errors.auth.method.signinDisabled"),
});
const user = await readValidatedBody(h3, CreateUserValidator);
@@ -38,7 +38,7 @@ export default defineEventHandler<{
if (!invitation)
throw createError({
statusCode: 401,
statusMessage: t("errors.auth.invalidInvite"),
message: t("errors.auth.invalidInvite"),
});
// reuse items from invite
@@ -51,7 +51,7 @@ export default defineEventHandler<{
if (existing > 0)
throw createError({
statusCode: 400,
statusMessage: t("errors.auth.usernameTaken"),
message: t("errors.auth.usernameTaken"),
});
const userId = randomUUID();
@@ -1,6 +1,5 @@
import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library";
export default defineClientEventHandler(async (h3) => {
const id = getRouterParam(h3, "id");
@@ -57,8 +56,5 @@ export default defineClientEventHandler(async (h3) => {
})),
};
return {
...gameVersionMapped,
size: libraryManager.getGameVersionSize(id, version),
};
return gameVersionMapped;
});
@@ -1,6 +1,7 @@
import type { Platform } from "~/prisma/client/enums";
import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
import prisma from "~/server/internal/db/database";
import type { GameVersionSize } from "~/server/internal/gamesize";
import gameSizeManager from "~/server/internal/gamesize";
type VersionDownloadOption = {
@@ -8,24 +9,23 @@ type VersionDownloadOption = {
displayName?: string | undefined;
versionPath?: string | undefined;
platform: Platform;
size: number;
size: GameVersionSize;
requiredContent: Array<{
gameId: string;
versionId: string;
name: string;
iconObjectId: string;
shortDescription: string;
size: number;
size: GameVersionSize;
}>;
};
export default defineClientEventHandler(async (h3) => {
const query = getQuery(h3);
const id = query.id?.toString();
const id = getRouterParam(h3, "id")!;
if (!id)
throw createError({
statusCode: 400,
statusMessage: "No ID in request query",
statusMessage: "No ID in router params",
});
const rawVersions = await prisma.gameVersion.findMany({
@@ -62,6 +62,7 @@ export default defineClientEventHandler(async (h3) => {
},
},
},
setups: true,
},
});
@@ -73,11 +74,11 @@ export default defineClientEventHandler(async (h3) => {
VersionDownloadOption["requiredContent"]
> = new Map();
for (const launch of v.launches) {
for (const launch of [...v.launches, ...v.setups]) {
if (!platformOptions.has(launch.platform))
platformOptions.set(launch.platform, []);
if (launch.executor) {
if ("executor" in launch && launch.executor) {
const old = platformOptions.get(launch.platform)!;
old.push({
gameId: launch.executor.gameVersion.game.id,
@@ -86,19 +87,14 @@ export default defineClientEventHandler(async (h3) => {
iconObjectId: launch.executor.gameVersion.game.mIconObjectId,
shortDescription:
launch.executor.gameVersion.game.mShortDescription,
size:
(await gameSizeManager.getGameVersionSize(
launch.executor.gameVersion.game.id,
launch.executor.gameVersion.versionId,
)) ?? 0,
size: (await gameSizeManager.getVersionSize(
launch.executor.gameVersion.versionId,
))!,
});
}
}
const size = await gameSizeManager.getGameVersionSize(
v.gameId,
v.versionId,
);
const size = await gameSizeManager.getVersionSize(v.versionId);
return platformOptions
.entries()
+2 -10
View File
@@ -1,29 +1,21 @@
import { APITokenMode } from "~/prisma/client/enums";
import { DateTime } from "luxon";
import type { UserACL } from "~/server/internal/acls";
import { defineClientEventHandler } from "~/server/internal/clients/event-handler";
import prisma from "~/server/internal/db/database";
import { CLIENT_WEBTOKEN_ACLS } from "~/server/plugins/04.auth-init";
export default defineClientEventHandler(
async (h3, { fetchUser, fetchClient, clientId }) => {
const user = await fetchUser();
const client = await fetchClient();
const acls: UserACL = [
"read",
"store:read",
"collections:read",
"object:read",
"settings:read",
];
const token = await prisma.aPIToken.create({
data: {
name: `${client.name} Web Access Token ${DateTime.now().toISO()}`,
clientId,
userId: user.id,
mode: APITokenMode.Client,
acls,
acls: CLIENT_WEBTOKEN_ACLS,
},
});
+12 -7
View File
@@ -1,20 +1,25 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls";
import userLibraryManager from "~/server/internal/userlibrary";
const CreateCollection = type({
name: "string",
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["collections:read"]);
const userId = await aclManager.getUserIdACL(h3, ["collections:new"]);
if (!userId)
throw createError({
statusCode: 403,
});
const body = await readBody(h3);
const name = body.name;
if (!name)
throw createError({ statusCode: 400, statusMessage: "Requires name" });
const body = await readDropValidatedBody(h3, CreateCollection);
// Create the collection using the manager
const newCollection = await userLibraryManager.collectionCreate(name, userId);
const newCollection = await userLibraryManager.collectionCreate(
body.name,
userId,
);
return newCollection;
});
+2 -2
View File
@@ -1,6 +1,6 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library";
import gameSizeManager from "~/server/internal/gamesize";
export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["store:read"]);
@@ -57,7 +57,7 @@ export default defineEventHandler(async (h3) => {
},
});
const size = await libraryManager.getGameVersionSize(game.id);
const size = (await gameSizeManager.getGameBreakdown(gameId))!;
return { game, rating, size };
});
+16 -7
View File
@@ -32,6 +32,11 @@ export default defineEventHandler(async (h3) => {
if (options instanceof ArkErrors)
throw createError({ statusCode: 400, statusMessage: options.summary });
const filterPlatforms = options.platform
?.split(",")
.map(parsePlatform)
.filter((e) => e !== undefined);
/**
* Generic filters
*/
@@ -46,23 +51,27 @@ export default defineEventHandler(async (h3) => {
},
}
: undefined;
const platformFilter = options.platform
? {
const platformFilter = filterPlatforms
? ({
versions: {
some: {
launches: {
some: {
platform: {
in: options.platform
.split(",")
.map(parsePlatform)
.filter((e) => e !== undefined),
in: filterPlatforms,
},
},
},
setups: {
some: {
platform: {
in: filterPlatforms,
},
},
},
},
},
}
} satisfies Prisma.GameWhereInput)
: undefined;
/**
@@ -0,0 +1,49 @@
import { aclManager } from "~/server/internal/acls";
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import prisma from "~/server/internal/db/database";
import { MFAMec } from "~/prisma/client/client";
import type { WebAuthNv1Credentials } from "~/server/internal/auth/webauthn";
const WebAuthnDelete = type({
id: "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, WebAuthnDelete);
const webauthnMec = await prisma.linkedMFAMec.findUnique({
where: { userId_mec: { userId, mec: MFAMec.WebAuthn } },
});
if (!webauthnMec)
throw createError({ statusCode: 400, message: "WebAuthn not enabled." });
const credentials =
webauthnMec.credentials as unknown as WebAuthNv1Credentials;
const index = credentials.passkeys.findIndex((v) => v.id === body.id);
credentials.passkeys.splice(index, 1);
// SAFETY: we request the object further up
// eslint-disable-next-line drop/no-prisma-delete
await prisma.linkedMFAMec.update({
where: {
userId_mec: {
userId,
mec: MFAMec.WebAuthn,
},
},
data: {
// This works, I don't know why the types don't line up
// eslint-disable-next-line @typescript-eslint/no-explicit-any
credentials: credentials as any,
},
});
});