Rearchitecture for v0.4.0 (#197)

* feat: database redist support

* feat: rearchitecture of database schemas, migration reset, and #180

* feat: import redists

* fix: giantbomb logging bug

* feat: partial user platform support + statusMessage -> message

* feat: add user platform filters to store view

* fix: sanitize svg uploads

... copilot suggested this

I feel dirty.

* feat: beginnings of platform & redist management

* feat: add server side redist patching

* fix: update drop-base commit

* feat: import of custom platforms & file extensions

* fix: redelete platform

* fix: remove platform

* feat: uninstall commands, new R UI

* checkpoint: before migrating to nuxt v4

* update to nuxt 4

* fix: fixes for Nuxt v4 update

* fix: remaining type issues

* feat: initial feedback to import other kinds of versions

* working commit

* fix: lint

* feat: redist import
This commit is contained in:
DecDuck
2025-11-10 10:36:13 +11:00
committed by GitHub
parent dfa30c8a65
commit 251ddb8ff8
465 changed files with 8029 additions and 7509 deletions

View File

@ -73,6 +73,10 @@ export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = {
"game:image:new": "Upload an image for a game.",
"game:image:delete": "Delete an image for a game.",
"redist:read": "Fetch redistributables on this instance.",
"redist:update": "Update redistributables on this instance.",
"redist:delete": "Delete redistributables on this instance.",
"company:read": "Fetch companies.",
"company:create": "Create a new company.",
"company:update": "Update existing companies.",
@ -84,6 +88,8 @@ export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = {
"import:game:read":
"Fetch games to be imported, and search the metadata for games.",
"import:game:new": "Import a game.",
"import:redist:read": "Fetch redists to be imported.",
"import:redist:new": "Import a redist.",
"tags:read": "Fetch all tags",
"tags:create": "Create a tag",

View File

@ -1,7 +1,7 @@
import { APITokenMode } from "~/prisma/client/enums";
import { APITokenMode } from "~~/prisma/client/enums";
import prisma from "../db/database";
import sessionHandler from "../session";
import type { MinimumRequestObject } from "~/server/h3";
import type { MinimumRequestObject } from "~~/server/h3";
export const userACLs = [
"read",
@ -67,6 +67,10 @@ export const systemACLs = [
"game:image:new",
"game:image:delete",
"redist:read",
"redist:update",
"redist:delete",
"company:read",
"company:update",
"company:create",
@ -74,9 +78,10 @@ export const systemACLs = [
"import:version:read",
"import:version:new",
"import:game:read",
"import:game:new",
"import:redist:read",
"import:redist:new",
"user:read",
"user:delete",

View File

@ -1,6 +1,6 @@
import { AuthMec } from "~/prisma/client/enums";
import { AuthMec } from "~~/prisma/client/enums";
import { OIDCManager } from "./oidc";
import { logger } from "~/server/internal/logging";
import { logger } from "~~/server/internal/logging";
class AuthManager {
private authProviders: {

View File

@ -1,12 +1,12 @@
import { randomUUID } from "crypto";
import prisma from "../../db/database";
import type { UserModel } from "~/prisma/client/models";
import { AuthMec } from "~/prisma/client/enums";
import type { UserModel } from "~~/prisma/client/models";
import { AuthMec } from "~~/prisma/client/enums";
import objectHandler from "../../objects";
import type { Readable } from "stream";
import * as jdenticon from "jdenticon";
import { systemConfig } from "../../config/sys-conf";
import { logger } from "~/server/internal/logging";
import { logger } from "~~/server/internal/logging";
interface OIDCWellKnown {
authorization_endpoint: string;

View File

@ -24,7 +24,7 @@ export class CertificateAuthority {
let ca;
if (root === undefined) {
const [cert, priv] = droplet.generateRootCa();
const bundle: CertificateBundle = { priv, cert };
const bundle: CertificateBundle = { priv: priv!, cert: cert! };
await store.store("ca", bundle);
ca = new CertificateAuthority(store, bundle);
} else {
@ -50,8 +50,8 @@ export class CertificateAuthority {
caCertificate.priv,
);
const certBundle: CertificateBundle = {
priv,
cert,
priv: priv!,
cert: cert!,
};
return certBundle;
}

View File

@ -1,29 +1,19 @@
import type { EnumDictionary } from "../utils/types";
import prisma from "../db/database";
import { ClientCapabilities } from "~/prisma/client/enums";
import { ClientCapabilities } from "~~/prisma/client/enums";
// These values are technically mapped to the database,
// but Typescript/Prisma doesn't let me link them
// They are also what are required by clients in the API
// BREAKING CHANGE
export enum InternalClientCapability {
PeerAPI = "peerAPI",
UserStatus = "userStatus",
CloudSaves = "cloudSaves",
TrackPlaytime = "trackPlaytime",
}
export const validCapabilities = Object.values(InternalClientCapability);
export const validCapabilities = Object.values(ClientCapabilities);
export type CapabilityConfiguration = {
[InternalClientCapability.PeerAPI]: object;
[InternalClientCapability.UserStatus]: object;
[InternalClientCapability.CloudSaves]: object;
[ClientCapabilities.PeerAPI]: object;
[ClientCapabilities.UserStatus]: object;
[ClientCapabilities.CloudSaves]: object;
};
class CapabilityManager {
private validationFunctions: EnumDictionary<
InternalClientCapability,
ClientCapabilities,
(configuration: object) => Promise<boolean>
> = {
/*
@ -77,14 +67,14 @@ class CapabilityManager {
return valid;
},
*/
[InternalClientCapability.PeerAPI]: async () => true,
[InternalClientCapability.UserStatus]: async () => true, // No requirements for user status
[InternalClientCapability.CloudSaves]: async () => true, // No requirements for cloud saves
[InternalClientCapability.TrackPlaytime]: async () => true,
[ClientCapabilities.PeerAPI]: async () => true,
[ClientCapabilities.UserStatus]: async () => true, // No requirements for user status
[ClientCapabilities.CloudSaves]: async () => true, // No requirements for cloud saves
[ClientCapabilities.TrackPlaytime]: async () => true,
};
async validateCapabilityConfiguration(
capability: InternalClientCapability,
capability: ClientCapabilities,
configuration: object,
) {
const validationFunction = this.validationFunctions[capability];
@ -93,15 +83,15 @@ class CapabilityManager {
}
async upsertClientCapability(
capability: InternalClientCapability,
capability: ClientCapabilities,
rawCapabilityConfiguration: object,
clientId: string,
) {
const upsertFunctions: EnumDictionary<
InternalClientCapability,
ClientCapabilities,
() => Promise<void> | void
> = {
[InternalClientCapability.PeerAPI]: async function () {
[ClientCapabilities.PeerAPI]: async function () {
// const configuration =rawCapability as CapabilityConfiguration[InternalClientCapability.PeerAPI];
const currentClient = await prisma.client.findUnique({
@ -139,10 +129,10 @@ class CapabilityManager {
},
});
},
[InternalClientCapability.UserStatus]: function (): Promise<void> | void {
[ClientCapabilities.UserStatus]: function (): Promise<void> | void {
throw new Error("Function not implemented.");
},
[InternalClientCapability.CloudSaves]: async function () {
[ClientCapabilities.CloudSaves]: async function () {
const currentClient = await prisma.client.findUnique({
where: { id: clientId },
select: {
@ -162,7 +152,7 @@ class CapabilityManager {
},
});
},
[InternalClientCapability.TrackPlaytime]: async function () {
[ClientCapabilities.TrackPlaytime]: async function () {
const currentClient = await prisma.client.findUnique({
where: { id: clientId },
select: {

View File

@ -1,8 +1,8 @@
import type { ClientModel, UserModel } from "~/prisma/client/models";
import type { ClientModel, UserModel } from "~~/prisma/client/models";
import type { EventHandlerRequest, H3Event } from "h3";
import droplet from "@drop-oss/droplet";
import prisma from "../db/database";
import { useCertificateAuthority } from "~/server/plugins/ca";
import { useCertificateAuthority } from "~~/server/plugins/ca";
export type EventHandlerFunction<T> = (
h3: H3Event<EventHandlerRequest>,
@ -23,7 +23,7 @@ export function defineClientEventHandler<T>(handler: EventHandlerFunction<T>) {
if (!header) throw createError({ statusCode: 403 });
const [method, ...parts] = header.split(" ");
let clientId: string;
let clientId: string | undefined;
switch (method) {
case "Debug": {
if (!import.meta.dev) throw createError({ statusCode: 403 });
@ -31,7 +31,7 @@ export function defineClientEventHandler<T>(handler: EventHandlerFunction<T>) {
if (!client)
throw createError({
statusCode: 400,
statusMessage: "No clients created.",
message: "No clients created.",
});
clientId = client.id;
break;
@ -55,7 +55,7 @@ export function defineClientEventHandler<T>(handler: EventHandlerFunction<T>) {
// We reject the request
throw createError({
statusCode: 403,
statusMessage: "Nonce expired",
message: "Nonce expired",
});
}
@ -66,21 +66,21 @@ export function defineClientEventHandler<T>(handler: EventHandlerFunction<T>) {
if (!certBundle)
throw createError({
statusCode: 403,
statusMessage: "Invalid client ID",
message: "Invalid client ID",
});
const valid = droplet.verifyNonce(certBundle.cert, nonce, signature);
if (!valid)
throw createError({
statusCode: 403,
statusMessage: "Invalid nonce signature.",
message: "Invalid nonce signature.",
});
break;
}
default: {
throw createError({
statusCode: 403,
statusMessage: "No authentication",
message: "No authentication",
});
}
}
@ -88,12 +88,12 @@ export function defineClientEventHandler<T>(handler: EventHandlerFunction<T>) {
if (clientId === undefined)
throw createError({
statusCode: 500,
statusMessage: "Failed to execute authentication pipeline.",
message: "Failed to execute authentication pipeline.",
});
async function fetchClient() {
const client = await prisma.client.findUnique({
where: { id: clientId },
where: { id: clientId! },
});
if (!client)
throw new Error(
@ -104,7 +104,7 @@ export function defineClientEventHandler<T>(handler: EventHandlerFunction<T>) {
async function fetchUser() {
const client = await prisma.client.findUnique({
where: { id: clientId },
where: { id: clientId! },
select: {
user: true,
},

View File

@ -1,23 +1,20 @@
import { randomUUID } from "node:crypto";
import prisma from "../db/database";
import type { Platform } from "~/prisma/client/enums";
import { useCertificateAuthority } from "~/server/plugins/ca";
import type { ClientCapabilities, HardwarePlatform } from "~~/prisma/client/enums";
import { useCertificateAuthority } from "~~/server/plugins/ca";
import type {
CapabilityConfiguration,
InternalClientCapability,
} from "./capabilities";
import capabilityManager from "./capabilities";
import type { PeerImpl } from "../tasks";
import userStatsManager from "~/server/internal/userstats";
import userStatsManager from "~~/server/internal/userstats";
export enum AuthMode {
Callback = "callback",
Code = "code",
}
export const AuthModes = ["callback", "code"] as const;
export type AuthMode = (typeof AuthModes)[number];
export interface ClientMetadata {
name: string;
platform: Platform;
platform: HardwarePlatform;
capabilities: Partial<CapabilityConfiguration>;
mode: AuthMode;
}
@ -63,9 +60,9 @@ export class ClientHandler {
});
switch (metadata.mode) {
case AuthMode.Callback:
case "callback":
return `/client/authorize/${clientId}`;
case AuthMode.Code: {
case "code": {
const code = randomUUID()
.replaceAll(/-/g, "")
.slice(0, 7)
@ -81,15 +78,15 @@ export class ClientHandler {
if (!clientId)
throw createError({
statusCode: 403,
statusMessage: "Invalid or unknown code.",
message: "Invalid or unknown code.",
});
const metadata = this.temporaryClientTable.get(clientId);
if (!metadata)
throw createError({ statusCode: 500, statusMessage: "Broken code." });
throw createError({ statusCode: 500, message: "Broken code." });
if (metadata.peer)
throw createError({
statusCode: 400,
statusMessage: "Pre-existing listener for this code.",
message: "Pre-existing listener for this code.",
});
metadata.peer = peer;
this.temporaryClientTable.set(clientId, metadata);
@ -130,12 +127,12 @@ export class ClientHandler {
if (!client)
throw createError({
statusCode: 500,
statusMessage: "Corrupted code, please restart the process.",
message: "Corrupted code, please restart the process.",
});
if (!client.peer)
throw createError({
statusCode: 400,
statusMessage: "Client has not connected yet. Please try again later.",
message: "Client has not connected yet. Please try again later.",
});
client.peer.send(
JSON.stringify({ type: "token", value: `${clientId}/${token}` }),
@ -173,7 +170,7 @@ export class ClientHandler {
metadata.data.capabilities,
)) {
await capabilityManager.upsertClientCapability(
capability as InternalClientCapability,
capability as ClientCapabilities,
configuration,
client.id,
);

View File

@ -1,4 +1,4 @@
import type { ApplicationSettingsModel } from "~/prisma/client/models";
import type { ApplicationSettingsModel } from "~~/prisma/client/models";
import prisma from "../db/database";
class ApplicationConfiguration {

View File

@ -1,4 +1,4 @@
import { PrismaClient } from "~/prisma/client/client";
import { PrismaClient } from "~~/prisma/client/client";
// import { PrismaPg } from "@prisma/adapter-pg";
const prismaClientSingleton = () => {

View File

@ -15,12 +15,13 @@ class DownloadContextManager {
}
> = new Map();
async createContext(game: string, versionName: string) {
const version = await prisma.gameVersion.findUnique({
async createContext(game: string, versionPath: string) {
const version = await prisma.version.findFirst({
where: {
gameId_versionName: {
gameId: game,
versionName,
gameId: game,
versionPath,
game: {
isNot: null,
},
},
include: {
@ -38,9 +39,9 @@ class DownloadContextManager {
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,
versionName: versionPath,
libraryId: version.game!.libraryId!,
libraryPath: version.game!.libraryPath,
});
return contextId;

View File

@ -1,4 +1,3 @@
import type { GameVersionModel } from "~/prisma/client/models";
import prisma from "../db/database";
import { sum } from "~/utils/array";
@ -15,11 +14,11 @@ export type DropManifest = {
export type DropManifestMetadata = {
manifest: DropManifest;
versionName: string;
versionId: string;
};
export type DropGeneratedManifest = DropManifest & {
[key: string]: { versionName: string };
[key: string]: { versionId: string };
};
class ManifestGenerator {
@ -32,7 +31,7 @@ class ManifestGenerator {
Object.entries(rootManifest.manifest).map(([key, value]) => {
return [
key,
Object.assign({}, value, { versionName: rootManifest.versionName }),
Object.assign({}, value, { versionId: rootManifest.versionId }),
];
}),
);
@ -45,7 +44,7 @@ class ManifestGenerator {
for (const [filename, chunk] of Object.entries(version.manifest)) {
if (manifest[filename]) continue;
manifest[filename] = Object.assign({}, chunk, {
versionName: version.versionName,
versionId: version.versionId,
});
}
}
@ -54,14 +53,26 @@ class ManifestGenerator {
}
// Local function because eventual caching
async generateManifest(gameId: string, versionName: string) {
const versions: GameVersionModel[] = [];
async generateManifest(versionId: string) {
const versions = [];
const baseVersion = await prisma.gameVersion.findUnique({
where: {
gameId_versionName: {
gameId: gameId,
versionName: versionName,
versionId,
version: {
gameId: {
not: null,
},
},
},
include: {
platform: true,
version: {
select: {
gameId: true,
dropletManifest: true,
versionIndex: true,
},
},
},
});
@ -73,31 +84,42 @@ class ManifestGenerator {
// 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--) {
for (let i = baseVersion.version.versionIndex - 1; true; i--) {
const currentVersion = await prisma.gameVersion.findFirst({
where: {
gameId: gameId,
versionIndex: i,
platform: baseVersion.platform,
version: {
gameId: baseVersion.version.gameId!,
versionIndex: i,
},
platform: {
id: baseVersion.platform.id,
},
},
include: {
version: {
select: {
dropletManifest: true,
},
},
},
});
if (!currentVersion) return undefined;
versions.push(currentVersion);
if (!currentVersion.delta) break;
if (!currentVersion?.delta) break;
}
}
const leastToMost = versions.reverse();
const metadata: DropManifestMetadata[] = leastToMost.map((e) => {
versions.reverse();
const metadata: DropManifestMetadata[] = versions.map((gameVersion) => {
return {
manifest: JSON.parse(
e.dropletManifest?.toString() ?? "{}",
gameVersion.version.dropletManifest?.toString() ?? "{}",
) as DropManifest,
versionName: e.versionName,
versionId: gameVersion.versionId,
};
});
const manifest = ManifestGenerator.generateManifestFromMetadata(
metadata[0],
metadata[0]!,
...metadata.slice(1),
);

View File

@ -1,8 +1,8 @@
import { sum } from "~/utils/array";
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 type { Game, Version } from "~~/prisma/client/client";
export type GameSize = {
gameName: string;
@ -15,7 +15,7 @@ export type VersionSize = GameSize & {
};
type VersionsSizes = {
[versionName: string]: VersionSize;
[versionId: string]: VersionSize;
};
type GameVersionsSize = {
@ -42,7 +42,7 @@ class GameSizeManager {
// All versions of a game combined
async getCombinedGameSize(gameId: string) {
const versions = await prisma.gameVersion.findMany({
const versions = await prisma.version.findMany({
where: { gameId },
});
const sizes = await Promise.all(
@ -57,10 +57,10 @@ class GameSizeManager {
async getGameVersionSize(
gameId: string,
versionName?: string,
versionId?: string,
): Promise<number | null> {
if (!versionName) {
const version = await prisma.gameVersion.findFirst({
if (!versionId) {
const version = await prisma.version.findFirst({
where: { gameId },
orderBy: {
versionIndex: "desc",
@ -69,13 +69,10 @@ class GameSizeManager {
if (!version) {
return null;
}
versionName = version.versionName;
versionId = version.versionId;
}
const manifest = await manifestGenerator.generateManifest(
gameId,
versionName,
);
const manifest = await manifestGenerator.generateManifest(versionId);
if (!manifest) {
return null;
}
@ -84,11 +81,11 @@ class GameSizeManager {
}
private async isLatestVersion(
gameVersions: GameVersion[],
version: GameVersion,
gameVersions: Version[],
version: Version,
): Promise<boolean> {
return gameVersions.length > 0
? gameVersions[0].versionName === version.versionName
? gameVersions[0].versionId === version.versionId
: false;
}
@ -101,7 +98,7 @@ class GameSizeManager {
return null;
}
const latestVersionName = Object.keys(versionsSizes).find(
(versionName) => versionsSizes[versionName].latest,
(versionId) => versionsSizes[versionId].latest,
);
if (!latestVersionName) {
return null;
@ -161,17 +158,17 @@ class GameSizeManager {
}
async cacheGameVersion(
game: Game & { versions: GameVersion[] },
versionName?: string,
game: Game & { versions: Version[] },
versionId?: string,
) {
const cacheVersion = async (version: GameVersion) => {
const size = await this.getGameVersionSize(game.id, version.versionName);
if (!version.versionName || !size) {
const cacheVersion = async (version: Version) => {
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 +183,9 @@ class GameSizeManager {
});
};
if (versionName) {
const version = await prisma.gameVersion.findFirst({
where: { gameId: game.id, versionName },
if (versionId) {
const version = await prisma.version.findFirst({
where: { gameId: game.id, versionId },
});
if (!version) {
return;
@ -212,7 +209,7 @@ class GameSizeManager {
.slice(0, top);
}
async deleteGameVersion(gameId: string, version: string) {
async deleteGameVersion(gameId: string, versionId: string) {
const game = await prisma.game.findFirst({ where: { id: gameId } });
if (game) {
await this.cacheCombinedGame(game);
@ -222,7 +219,7 @@ class GameSizeManager {
return;
}
// Remove the version from the VersionsSizes object
const { [version]: _, ...updatedVersionsSizes } = versionsSizes;
const { [versionId]: _, ...updatedVersionsSizes } = versionsSizes;
await this.gameVersionsSizesCache.set(gameId, updatedVersionsSizes);
}
@ -232,5 +229,5 @@ class GameSizeManager {
}
}
export const manager = new GameSizeManager();
export default manager;
export const gameSizeManager = new GameSizeManager();
export default gameSizeManager;

View File

@ -0,0 +1,3 @@
# Redistributables
They are called 'redist' in the codebase/database models for brevity. Additionally, because they intentionally are drop-ins for games, they are added to all the functions that deal with games, without changing the names.

View File

@ -9,14 +9,29 @@ 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";
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 type { ImportVersion } from "~~/server/api/v1/admin/import/version/index.post";
import type {
GameVersionCreateInput,
LaunchOptionCreateManyInput,
VersionCreateInput,
VersionWhereInput,
} from "~~/prisma/client/models";
import type { PlatformLink } from "~~/prisma/client/client";
import { convertIDToLink } from "../platform/link";
import type { WorkingLibrarySource } from "~~/server/api/v1/admin/library/sources/index.get";
import gameSizeManager from "../gamesize";
export const VersionImportModes = ["game", "redist"] as const;
export type VersionImportMode = (typeof VersionImportModes)[number];
const modeToLink: { [key in VersionImportMode]: string } = {
game: "g",
redist: "r",
};
export function createGameImportTaskId(libraryId: string, libraryPath: string) {
return createHash("md5")
@ -57,14 +72,15 @@ class LibraryManager {
}
async fetchGamesByLibrary() {
const results: { [key: string]: { [key: string]: GameModel } } = {};
const results: { [key: string]: { [key: string]: boolean } } = {};
const games = await prisma.game.findMany({});
for (const game of games) {
const libraryId = game.libraryId!;
const libraryPath = game.libraryPath!;
const redist = await prisma.redist.findMany({});
for (const item of [...games, ...redist]) {
const libraryId = item.libraryId!;
const libraryPath = item.libraryPath!;
results[libraryId] ??= {};
results[libraryId][libraryPath] = game;
results[libraryId][libraryPath] = true;
}
return results;
@ -90,18 +106,31 @@ class LibraryManager {
async fetchUnimportedGameVersions(libraryId: string, libraryPath: string) {
const provider = this.libraries.get(libraryId);
if (!provider) return undefined;
const game = await prisma.game.findUnique({
where: {
libraryKey: {
libraryId,
libraryPath,
const game =
(await prisma.game.findUnique({
where: {
libraryKey: {
libraryId,
libraryPath,
},
},
},
select: {
id: true,
versions: true,
},
});
select: {
id: true,
versions: true,
},
})) ??
(await prisma.redist.findUnique({
where: {
libraryKey: {
libraryId,
libraryPath,
},
},
select: {
id: true,
versions: true,
},
}));
if (!game) return undefined;
try {
@ -121,29 +150,23 @@ class LibraryManager {
}
}
async fetchGamesWithStatus() {
const games = await prisma.game.findMany({
include: {
versions: {
select: {
versionName: true,
},
},
library: true,
},
orderBy: {
mName: "asc",
},
});
async fetchLibraryObjectWithStatus<T>(
objects: Array<
{
libraryId: string;
libraryPath: string;
versions: Array<unknown>;
} & T
>,
) {
return await Promise.all(
games.map(async (e) => {
objects.map(async (e) => {
const versions = await this.fetchUnimportedGameVersions(
e.libraryId ?? "",
e.libraryPath,
);
return {
game: e,
value: e,
status: versions
? {
noVersions: e.versions.length == 0,
@ -155,22 +178,220 @@ class LibraryManager {
);
}
async fetchGamesWithStatus() {
const games = await prisma.game.findMany({
include: {
versions: {
select: {
versionId: true,
versionName: true,
},
},
library: {
select: {
id: true,
name: true,
},
},
},
orderBy: {
mName: "asc",
},
});
return await this.fetchLibraryObjectWithStatus(games);
}
async fetchRedistsWithStatus() {
const redists = await prisma.redist.findMany({
include: {
versions: {
select: {
versionId: true,
versionName: true,
},
},
library: {
select: {
id: true,
name: true,
},
},
platform: true,
},
orderBy: {
mName: "asc",
},
});
return await this.fetchLibraryObjectWithStatus(redists);
}
private async fetchLibraryPath(
id: string,
mode: VersionImportMode,
platform?: PlatformLink,
): Promise<
| [
{ mName: string; libraryId: string; libraryPath: string } | null,
VersionWhereInput,
]
| undefined
> {
switch (mode) {
case "game":
return [
await prisma.game.findUnique({
where: { id },
select: { mName: true, libraryId: true, libraryPath: true },
}),
{ gameId: id, gameVersions: { some: { platform } } },
];
case "redist":
return [
await prisma.redist.findUnique({
where: { id },
select: { mName: true, libraryId: true, libraryPath: true },
}),
{ redistId: id },
];
}
return undefined;
}
private createVersionOptions(
id: string,
currentIndex: number,
metadata: typeof ImportVersion.infer,
): Omit<
VersionCreateInput,
"versionPath" | "versionName" | "dropletManifest"
> {
const installCreator = {
install: {
create: {
name: "",
description: "",
command: metadata.install!,
args: metadata.installArgs || "",
},
},
} satisfies Partial<GameVersionCreateInput>;
const uninstallCreator = {
uninstall: {
create: {
name: "",
description: "",
command: metadata.uninstall!,
args: metadata.uninstallArgs || "",
},
},
} satisfies Partial<GameVersionCreateInput>;
switch (metadata.mode) {
case "game": {
return {
versionIndex: currentIndex,
game: {
connect: {
id,
},
},
gameVersions: {
create: {
delta: metadata.delta,
umuIdOverride: metadata.umuId,
onlySetup: metadata.onlySetup,
launches: {
createMany: {
data: metadata.launches.map(
(v) =>
({
name: v.name,
description: v.description,
command: v.launchCommand,
args: v.launchArgs,
}) satisfies LaunchOptionCreateManyInput,
),
},
},
...(metadata.install ? installCreator : undefined),
...(metadata.uninstall ? uninstallCreator : undefined),
platform: {
connect: {
id: metadata.platform,
},
},
},
},
};
}
case "redist":
return {
versionIndex: currentIndex,
redist: {
connect: {
id,
},
},
redistVersions: {
create: {
versionIndex: currentIndex,
delta: metadata.delta,
launches: {
createMany: {
data: metadata.launches.map(
(v) =>
({
name: v.name,
description: v.description,
command: v.launchCommand,
args: v.launchArgs,
}) satisfies LaunchOptionCreateManyInput,
),
},
},
...(metadata.install ? installCreator : undefined),
...(metadata.uninstall ? uninstallCreator : undefined),
platform: {
connect: {
id: metadata.platform,
},
},
},
},
};
}
}
/**
* Fetches recommendations and extra data about the version. Doesn't actually check if it's been imported.
* @param gameId
* @param versionName
* @param id
* @param version
* @returns
*/
async fetchUnimportedVersionInformation(gameId: string, versionName: string) {
const game = await prisma.game.findUnique({
where: { id: gameId },
select: { libraryPath: true, libraryId: true, mName: true },
});
if (!game || !game.libraryId) return undefined;
async fetchUnimportedVersionInformation(
id: string,
mode: VersionImportMode,
version: string,
) {
const value = await this.fetchLibraryPath(id, mode);
if (!value?.[0] || !value[0].libraryId) return undefined;
const [libraryDetails] = value;
const library = this.libraries.get(game.libraryId);
const library = this.libraries.get(libraryDetails.libraryId);
if (!library) return undefined;
const userPlatforms = await prisma.userPlatform.findMany({});
const fileExts: { [key: string]: string[] } = {
Linux: [
// Ext for Unity games
@ -189,13 +410,20 @@ class LibraryManager {
],
};
for (const platform of userPlatforms) {
fileExts[platform.id] = platform.fileExtensions;
}
const options: Array<{
filename: string;
platform: string;
match: number;
}> = [];
const files = await library.versionReaddir(game.libraryPath, versionName);
const files = await library.versionReaddir(
libraryDetails.libraryPath,
version,
);
for (const filename of files) {
const basename = path.basename(filename);
const dotLocation = filename.lastIndexOf(".");
@ -204,7 +432,7 @@ class LibraryManager {
for (const [platform, checkExts] of Object.entries(fileExts)) {
for (const checkExt of checkExts) {
if (checkExt != ext) continue;
const fuzzyValue = fuzzy(basename, game.mName);
const fuzzyValue = fuzzy(basename, libraryDetails.mName);
options.push({
filename,
platform,
@ -227,6 +455,10 @@ class LibraryManager {
})) > 0;
if (hasGame) return false;
const hasRedist =
(await prisma.redist.count({ where: { libraryId, libraryPath } })) > 0;
if (hasRedist) return false;
return true;
}
@ -239,46 +471,70 @@ 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;
},
id: string,
version: string,
metadata: typeof ImportVersion.infer,
) {
const taskId = createVersionImportTaskId(gameId, versionName);
const taskId = createVersionImportTaskId(id, version);
const platform = parsePlatform(metadata.platform);
if (!platform) return undefined;
if (metadata.mode === "game") {
if (metadata.onlySetup) {
if (!metadata.install)
throw createError({
statusCode: 400,
message: "An install command is required in only-setup mode.",
});
} else {
if (!metadata.delta && metadata.launches.length == 0)
throw createError({
statusCode: 400,
message:
"At least one launch command is required in non-delta, non-setup mode.",
});
}
}
const game = await prisma.game.findUnique({
where: { id: gameId },
select: { mName: true, libraryId: true, libraryPath: true },
const platform = await convertIDToLink(metadata.platform);
if (!platform)
throw createError({ statusCode: 400, message: "Invalid platform." });
const value = await this.fetchLibraryPath(id, metadata.mode, platform);
if (!value || !value[0])
throw createError({
statusCode: 400,
message: `${metadata.mode} not found.`,
});
const [libraryDetails, idFilter] = value;
const library = this.libraries.get(libraryDetails.libraryId);
if (!library)
throw createError({
statusCode: 500,
message: "Library not found but exists in database?",
});
const currentIndex = await prisma.version.count({
where: { ...idFilter },
});
if (!game || !game.libraryId) return undefined;
const library = this.libraries.get(game.libraryId);
if (!library) return undefined;
if (metadata.delta && currentIndex == 0)
throw createError({
statusCode: 400,
message:
"At least one pre-existing version of the same platform is required for delta mode.",
});
taskHandler.create({
id: taskId,
taskGroup: "import:game",
name: `Importing version ${versionName} for ${game.mName}`,
name: `Importing version "${metadata.name}" (${version}) for ${libraryDetails.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,
libraryDetails.libraryPath,
version,
(err, value) => {
if (err) throw err;
progress(value * 0.9);
@ -291,59 +547,35 @@ class LibraryManager {
logger.info("Created manifest successfully!");
const currentIndex = await prisma.gameVersion.count({
where: { gameId: gameId },
});
// 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,
const createdVersion = await prisma.version.create({
data: {
versionPath: version,
versionName: metadata.name ?? version,
dropletManifest: manifest,
onlySetup: true,
setupCommand: metadata.setup,
setupArgs: metadata.setupArgs.split(" "),
},
});
} 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(" "),
},
});
}
...libraryManager.createVersionOptions(id, currentIndex, metadata),
},
});
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}.`,
actions: [`View|/admin/library/${gameId}`],
nonce: `version-create-${id}-${version}`,
title: `'${libraryDetails.mName}' ('${version}') finished importing.`,
description: `Drop finished importing version ${version} for ${libraryDetails.mName}.`,
actions: [`View|/admin/library/${modeToLink[metadata.mode]}/${id}`],
acls: ["system:import:version:read"],
});
await libraryManager.cacheCombinedGameSize(gameId);
await libraryManager.cacheGameVersionSize(gameId, versionName);
if (metadata.mode === "game") {
await libraryManager.cacheCombinedGameSize(id);
await libraryManager.cacheGameVersionSize(
id,
createdVersion.versionId,
);
}
progress(100);
},
});
@ -374,17 +606,22 @@ class LibraryManager {
return await library.readFile(game, version, filename, options);
}
async deleteGameVersion(gameId: string, version: string) {
await prisma.gameVersion.delete({
async deleteGameVersion(versionId: string) {
const version = await prisma.version.delete({
where: {
gameId_versionName: {
gameId: gameId,
versionName: version,
},
versionId,
},
include: {
game: true,
},
});
await gameSizeManager.deleteGameVersion(gameId, version);
if (version.game) {
await gameSizeManager.deleteGameVersion(
version.game.id,
version.versionId,
);
}
}
async deleteGame(gameId: string) {
@ -398,9 +635,9 @@ class LibraryManager {
async getGameVersionSize(
gameId: string,
versionName?: string,
versionId?: string,
): Promise<number | null> {
return gameSizeManager.getGameVersionSize(gameId, versionName);
return gameSizeManager.getGameVersionSize(gameId, versionId);
}
async getBiggestGamesCombinedVersions(top: number) {
@ -425,7 +662,7 @@ class LibraryManager {
await gameSizeManager.cacheCombinedGame(game);
}
async cacheGameVersionSize(gameId: string, versionName: string) {
async cacheGameVersionSize(gameId: string, versionId: string) {
const game = await prisma.game.findFirst({
where: { id: gameId },
include: { versions: true },
@ -433,7 +670,7 @@ class LibraryManager {
if (!game) {
return;
}
await gameSizeManager.cacheGameVersion(game, versionName);
await gameSizeManager.cacheGameVersion(game, versionId);
}
}

View File

@ -1,4 +1,4 @@
import type { LibraryBackend } from "~/prisma/client/enums";
import type { LibraryBackend } from "~~/prisma/client/enums";
export abstract class LibraryProvider<CFG> {
constructor(_config: CFG, _id: string) {

View File

@ -4,11 +4,11 @@ import {
VersionNotFoundError,
type LibraryProvider,
} from "../provider";
import { LibraryBackend } from "~/prisma/client/enums";
import { LibraryBackend } from "~~/prisma/client/enums";
import fs from "fs";
import path from "path";
import droplet, { DropletHandler } from "@drop-oss/droplet";
import { fsStats } from "~/server/internal/utils/files";
import { fsStats } from "~~/server/internal/utils/files";
export const FilesystemProviderConfig = type({
baseDir: "string",

View File

@ -1,12 +1,12 @@
import { ArkErrors, type } from "arktype";
import type { LibraryProvider } from "../provider";
import { VersionNotFoundError } from "../provider";
import { LibraryBackend } from "~/prisma/client/enums";
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 { fsStats } from "~/server/internal/utils/files";
import { fsStats } from "~~/server/internal/utils/files";
export const FlatFilesystemProviderConfig = type({
baseDir: "string",

View File

@ -1,5 +1,5 @@
import type { CompanyModel } from "~/prisma/client/models";
import { MetadataSource } from "~/prisma/client/enums";
import type { CompanyModel } from "~~/prisma/client/models";
import { MetadataSource } from "~~/prisma/client/enums";
import type { MetadataProvider } from ".";
import { MissingMetadataProviderConfig } from ".";
import type {
@ -13,7 +13,7 @@ import type {
import axios, { type AxiosRequestConfig } from "axios";
import TurndownService from "turndown";
import { DateTime } from "luxon";
import type { TaskRunContext } from "../tasks";
import type { TaskRunContext } from "../tasks/utils";
interface GiantBombResponseType<T> {
error: "OK" | string;

View File

@ -1,5 +1,5 @@
import type { CompanyModel } from "~/prisma/client/models";
import { MetadataSource } from "~/prisma/client/enums";
import type { CompanyModel } from "~~/prisma/client/models";
import { MetadataSource } from "~~/prisma/client/enums";
import type { MetadataProvider } from ".";
import { MissingMetadataProviderConfig } from ".";
import type {
@ -13,8 +13,8 @@ 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 { logger } from "~~/server/internal/logging";
import type { TaskRunContext } from "../tasks/utils";
type IGDBID = number;

View File

@ -1,5 +1,5 @@
import type { Prisma } from "~/prisma/client/client";
import { MetadataSource } from "~/prisma/client/enums";
import type { Prisma } from "~~/prisma/client/client";
import { MetadataSource } from "~~/prisma/client/enums";
import prisma from "../db/database";
import type {
_FetchGameMetadataParams,
@ -13,13 +13,14 @@ import type {
import { ObjectTransactionalHandler } from "../objects/transactional";
import { PriorityListIndexed } from "../utils/prioritylist";
import { systemConfig } from "../config/sys-conf";
import type { TaskRunContext } from "../tasks";
import taskHandler, { wrapTaskContext } from "../tasks";
import { randomUUID } from "crypto";
import { fuzzy } from "fast-fuzzy";
import { logger } from "~/server/internal/logging";
import { createGameImportTaskId } from "../library";
import type { GameTagModel } from "~/prisma/client/models";
import { logger } from "~~/server/internal/logging";
import libraryManager, { createGameImportTaskId } from "../library";
import type { GameTagModel } from "~~/prisma/client/models";
import type { TaskRunContext} from "../tasks/utils";
import { wrapTaskContext } from "../tasks/utils";
import taskHandler from "../tasks";
export class MissingMetadataProviderConfig extends Error {
private providerName: string;
@ -175,15 +176,8 @@ export class MetadataHandler {
if (!provider)
throw new Error(`Invalid metadata provider for ID "${result.sourceId}"`);
const existing = await prisma.game.findUnique({
where: {
metadataKey: {
metadataSource: provider.source(),
metadataId: result.id,
},
},
});
if (existing) return undefined;
const okay = await libraryManager.checkUnimportedGamePath(libraryId, libraryPath);
if (!okay) return undefined;
const gameId = randomUUID();

View File

@ -1,4 +1,4 @@
import { MetadataSource } from "~/prisma/client/enums";
import { MetadataSource } from "~~/prisma/client/enums";
import type { MetadataProvider } from ".";
import type {
_FetchGameMetadataParams,

View File

@ -1,5 +1,5 @@
import type { CompanyModel } from "~/prisma/client/models";
import { MetadataSource } from "~/prisma/client/enums";
import type { CompanyModel } from "~~/prisma/client/models";
import { MetadataSource } from "~~/prisma/client/enums";
import type { MetadataProvider } from ".";
import type {
GameMetadataSearchResult,
@ -15,8 +15,8 @@ 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 { logger } from "~~/server/internal/logging";
import type { TaskRunContext } from "../tasks/utils";
interface PCGamingWikiParseRawPage {
parse: {
@ -200,7 +200,7 @@ export class PCGamingWikiProvider implements MetadataProvider {
return url.pathname.replace("/games/", "").replace(/\/$/, "");
}
default: {
logger.warn("Pcgamingwiki, unknown host", url.hostname);
logger.warn("Pcgamingwiki, unknown host: %s", url.hostname);
return undefined;
}
}
@ -234,7 +234,7 @@ export class PCGamingWikiProvider implements MetadataProvider {
});
if (ratingObj instanceof type.errors) {
logger.info(
"pcgamingwiki: failed to properly get review rating",
"pcgamingwiki: failed to properly get review rating: %s",
ratingObj.summary,
);
return undefined;

View File

@ -1,4 +1,4 @@
import { MetadataSource } from "~/prisma/client/enums";
import { MetadataSource } from "~~/prisma/client/enums";
import type { MetadataProvider } from ".";
import type {
GameMetadataSearchResult,
@ -8,9 +8,9 @@ import type {
CompanyMetadata,
GameMetadataRating,
} from "./types";
import type { TaskRunContext } from "../tasks";
import axios from "axios";
import * as jdenticon from "jdenticon";
import type { TaskRunContext } from "../tasks/utils";
/**
* Note: The Steam API is largely undocumented.

View File

@ -1,4 +1,4 @@
import type { Company, GameRating } from "~/prisma/client";
import type { Company, GameRating } from "~~/prisma/client";
import type { TransactionDataType } from "../objects/transactional";
import type { ObjectReference } from "../objects/objectHandler";

View File

@ -6,7 +6,7 @@ Design goals:
2. Real-time; use websocket listeners to keep clients up-to-date
*/
import type { NotificationModel } from "~/prisma/client/models";
import type { NotificationModel } from "~~/prisma/client/models";
import prisma from "../db/database";
import type { GlobalACL } from "../acls";

View File

@ -9,7 +9,7 @@ import prisma from "../db/database";
import cacheHandler from "../cache";
import { systemConfig } from "../config/sys-conf";
import { type } from "arktype";
import { logger } from "~/server/internal/logging";
import { logger } from "~~/server/internal/logging";
import type pino from "pino";
export class FsObjectBackend extends ObjectBackend {
@ -123,7 +123,7 @@ export class FsObjectBackend extends ObjectBackend {
const metadataRaw = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
const metadata = objectMetadata(metadataRaw);
if (metadata instanceof type.errors) {
logger.error("FsObjectBackend#fetchMetadata", metadata.summary);
logger.error("FsObjectBackend#fetchMetadata: %s", metadata.summary);
return undefined;
}
await this.metadataCache.set(id, metadata);
@ -194,11 +194,13 @@ export class FsObjectBackend extends ObjectBackend {
try {
fs.rmSync(filePath);
cleanupLogger.info(
`[FsObjectBackend#cleanupMetadata]: Removed ${file}`,
`[FsObjectBackend#cleanupMetadata]: Removed %s`,
file
);
} catch (error) {
cleanupLogger.error(
`[FsObjectBackend#cleanupMetadata]: Failed to remove ${file}`,
`[FsObjectBackend#cleanupMetadata]: Failed to remove %s: %s`,
file,
error,
);
}

View File

@ -32,15 +32,12 @@ export const objectMetadata = type({
});
export type ObjectMetadata = typeof objectMetadata.infer;
export enum ObjectPermission {
Read = "read",
Write = "write",
Delete = "delete",
}
export const ObjectPermissions = ["read", "write", "delete"] as const;
export type ObjectPermission = (typeof ObjectPermissions)[number];
export const ObjectPermissionPriority: Array<ObjectPermission> = [
ObjectPermission.Read,
ObjectPermission.Write,
ObjectPermission.Delete,
"read",
"write",
"delete",
];
export type Object = { mime: string; data: Source };

View File

@ -5,7 +5,7 @@ This is used as a utility in metadata handling, so we only fetch the objects if
import type { Readable } from "stream";
import { randomUUID } from "node:crypto";
import objectHandler from ".";
import type { TaskRunContext } from "../tasks";
import type { TaskRunContext } from "../tasks/utils";
export type TransactionDataType = string | Readable | Buffer;
type TransactionTable = Map<string, TransactionDataType>; // ID to data

View File

@ -0,0 +1,47 @@
import { HardwarePlatform } from "~~/prisma/client/enums";
import prisma from "../db/database";
import type { PlatformLink } from "~~/prisma/client/client";
export async function convertIDsToPlatforms(platformIDs: string[]) {
const userPlatforms = await prisma.userPlatform.findMany({
where: {
id: {
in: platformIDs,
},
},
});
const platforms = platformIDs.map(
(e) => userPlatforms.find((v) => v.id === e) ?? (e as HardwarePlatform),
);
return platforms;
}
export async function convertIDToLink(
id: string,
): Promise<PlatformLink | undefined> {
const link = await prisma.platformLink.findUnique({
where: { id },
});
if (link) return link;
if (HardwarePlatform[id as HardwarePlatform]) {
return await prisma.platformLink.create({
data: {
id,
},
});
}
const userPlatform = await prisma.userPlatform.findUnique({
where: { id },
});
if (!userPlatform) return undefined;
return await prisma.platformLink.create({
data: {
id,
},
});
}

View File

@ -32,7 +32,7 @@ class SaveManager {
},
});
if (!save)
throw createError({ statusCode: 404, statusMessage: "Save not found" });
throw createError({ statusCode: 404, message: "Save not found" });
const newSaveObjectId = randomUUID();
const newSaveStream = await objectHandler.createWithStream(
@ -43,7 +43,7 @@ class SaveManager {
if (!newSaveStream)
throw createError({
statusCode: 500,
statusMessage: "Failed to create writing stream to storage backend.",
message: "Failed to create writing stream to storage backend.",
});
let hash: string | undefined;
@ -64,7 +64,7 @@ class SaveManager {
await objectHandler.deleteAsSystem(newSaveObjectId);
throw createError({
statusCode: 500,
statusMessage: "Hash failed to generate",
message: "Hash failed to generate",
});
}

View File

@ -80,7 +80,7 @@ class ScreenshotManager {
if (!saveStream)
throw createError({
statusCode: 500,
statusMessage: "Failed to create writing stream to storage backend.",
message: "Failed to create writing stream to storage backend.",
});
// pipe into object store

View File

@ -2,7 +2,7 @@ import type { H3Event } from "h3";
import type { Session, SessionProvider } from "./types";
import { randomUUID } from "node:crypto";
import { parse as parseCookies } from "cookie-es";
import type { MinimumRequestObject } from "~/server/h3";
import type { MinimumRequestObject } from "~~/server/h3";
import type { DurationLike } from "luxon";
import { DateTime } from "luxon";
import createDBSessionHandler from "./db";

View File

@ -1,22 +1,32 @@
export const taskGroups = {
"cleanup:invitations": {
concurrency: false,
},
"cleanup:objects": {
concurrency: false,
},
"cleanup:sessions": {
concurrency: false,
},
"check:update": {
concurrency: false,
},
"import:game": {
concurrency: true,
},
debug: {
concurrency: true,
},
} as const;
export const TASK_GROUPS = [
"cleanup:invitations",
"cleanup:objects",
"cleanup:sessions",
"check:update",
"import:game",
"import:version",
] as const;
export type TaskGroup = keyof typeof taskGroups;
export type TaskGroup = (typeof TASK_GROUPS)[number];
export const TASK_GROUP_CONFIG: { [key in TaskGroup]: { concurrency: boolean } } =
{
"cleanup:invitations": {
concurrency: false
},
"cleanup:objects": {
concurrency: false
},
"cleanup:sessions": {
concurrency: false
},
"check:update": {
concurrency: false
},
"import:game": {
concurrency: true
},
"import:version": {
concurrency: true
}
};

View File

@ -1,5 +1,5 @@
import droplet from "@drop-oss/droplet";
import type { MinimumRequestObject } from "~/server/h3";
import type { MinimumRequestObject } from "~~/server/h3";
import type { GlobalACL } from "../acls";
import aclManager from "../acls";
@ -7,12 +7,13 @@ import cleanupInvites from "./registry/invitations";
import cleanupSessions from "./registry/sessions";
import checkUpdate from "./registry/update";
import cleanupObjects from "./registry/objects";
import { taskGroups, type TaskGroup } from "./group";
import { TASK_GROUP_CONFIG, type TaskGroup } from "./group";
import prisma from "../db/database";
import { type } from "arktype";
import pino from "pino";
import { logger } from "~/server/internal/logging";
import { logger } from "~~/server/internal/logging";
import { Writable } from "node:stream";
import type { TaskRunContext } from "./utils";
// a task that has been run
type FinishedTask = {
@ -53,7 +54,6 @@ class TaskHandler {
"cleanup:invitations",
"cleanup:sessions",
"check:update",
"debug",
];
private weeklyScheduledTasks: TaskGroup[] = ["cleanup:objects"];
@ -82,7 +82,7 @@ class TaskHandler {
let logOffset: number = 0;
// if taskgroup disallows concurrency
if (!taskGroups[task.taskGroup].concurrency) {
if (!TASK_GROUP_CONFIG[task.taskGroup].concurrency) {
for (const existingTask of this.taskPool.values()) {
// if a task is already running, we don't want to start another
if (existingTask.taskGroup === task.taskGroup) {
@ -149,7 +149,7 @@ class TaskHandler {
}
} catch (e) {
// fallback: ignore or log error
logger.error("Failed to parse log chunk", {
logger.error("Failed to parse log chunk %s", {
error: e,
chunk: chunk,
});
@ -177,7 +177,7 @@ class TaskHandler {
const progress = (progress: number) => {
if (progress < 0 || progress > 100) {
logger.error("Progress must be between 0 and 100", { progress });
logger.error("Progress must be between 0 and 100, actually %d", progress);
return;
}
const taskEntry = this.taskPool.get(task.id);
@ -412,36 +412,6 @@ class TaskHandler {
}
}
export type TaskRunContext = {
progress: (progress: number) => void;
logger: typeof logger;
};
export function wrapTaskContext(
context: TaskRunContext,
options: { min: number; max: number; prefix: string },
): TaskRunContext {
const child = context.logger.child({
prefix: options.prefix,
});
return {
progress(progress) {
if (progress > 100 || progress < 0) {
logger.warn("[wrapTaskContext] progress must be between 0 and 100");
}
// I was too tired to figure this out
// https://stackoverflow.com/a/929107
const oldRange = 100;
const newRange = options.max - options.min;
const adjustedProgress = (progress * newRange) / oldRange + options.min;
return context.progress(adjustedProgress);
},
logger: child,
};
}
export interface Task {
id: string;
taskGroup: TaskGroup;
@ -484,31 +454,6 @@ export const TaskLog = type({
level: "string",
});
// /**
// * Create a log message with a timestamp in the format YYYY-MM-DD HH:mm:ss.SSS UTC
// * @param message
// * @returns
// */
// function msgWithTimestamp(message: string): string {
// const now = new Date();
// const pad = (n: number, width = 2) => n.toString().padStart(width, "0");
// const year = now.getUTCFullYear();
// const month = pad(now.getUTCMonth() + 1);
// const day = pad(now.getUTCDate());
// const hours = pad(now.getUTCHours());
// const minutes = pad(now.getUTCMinutes());
// const seconds = pad(now.getUTCSeconds());
// const milliseconds = pad(now.getUTCMilliseconds(), 3);
// const log: typeof TaskLog.infer = {
// timestamp: `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds} UTC`,
// message,
// };
// return JSON.stringify(log);
// }
export function defineDropTask(buildTask: BuildTask): DropTask {
return {

View File

@ -1,5 +1,6 @@
import { defineDropTask } from "..";
// import { defineDropTask } from "..";
/*
export default defineDropTask({
buildId: () => `debug:${new Date().toISOString()}`,
name: "Debug Task",
@ -16,3 +17,4 @@ export default defineDropTask({
}
},
});
*/

View File

@ -1,4 +1,4 @@
import prisma from "~/server/internal/db/database";
import prisma from "~~/server/internal/db/database";
import { defineDropTask } from "..";
export default defineDropTask({

View File

@ -1,5 +1,5 @@
import prisma from "~/server/internal/db/database";
import objectHandler from "~/server/internal/objects";
import prisma from "~~/server/internal/db/database";
import objectHandler from "~~/server/internal/objects";
import { defineDropTask } from "..";
type FieldReferenceMap = {

View File

@ -1,4 +1,4 @@
import sessionHandler from "~/server/internal/session";
import sessionHandler from "~~/server/internal/session";
import { defineDropTask } from "..";
export default defineDropTask({

View File

@ -49,7 +49,7 @@ export default defineDropTask({
// if response failed somehow
if (!response.ok) {
logger.info("Failed to check for update ", {
logger.info("Failed to check for update: %s", {
status: response.status,
body: response.body,
});

View File

@ -0,0 +1,58 @@
import { logger } from "../logging";
export type TaskRunContext = {
progress: (progress: number) => void;
logger: typeof logger;
};
export function wrapTaskContext(
context: TaskRunContext,
options: { min: number; max: number; prefix: string },
): TaskRunContext {
const child = context.logger.child({
prefix: options.prefix,
});
return {
progress(progress) {
if (progress > 100 || progress < 0) {
logger.warn("[wrapTaskContext] progress must be between 0 and 100");
}
// I was too tired to figure this out
// https://stackoverflow.com/a/929107
const oldRange = 100;
const newRange = options.max - options.min;
const adjustedProgress = (progress * newRange) / oldRange + options.min;
return context.progress(adjustedProgress);
},
logger: child,
};
}
// /**
// * Create a log message with a timestamp in the format YYYY-MM-DD HH:mm:ss.SSS UTC
// * @param message
// * @returns
// */
// function msgWithTimestamp(message: string): string {
// const now = new Date();
// const pad = (n: number, width = 2) => n.toString().padStart(width, "0");
// const year = now.getUTCFullYear();
// const month = pad(now.getUTCMonth() + 1);
// const day = pad(now.getUTCDate());
// const hours = pad(now.getUTCHours());
// const minutes = pad(now.getUTCMinutes());
// const seconds = pad(now.getUTCSeconds());
// const milliseconds = pad(now.getUTCMilliseconds(), 3);
// const log: typeof TaskLog.infer = {
// timestamp: `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds} UTC`,
// message,
// };
// return JSON.stringify(log);
// }

View File

@ -1,18 +1,27 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { EventHandlerRequest, H3Event } from "h3";
import type { Dump, Pull } from "../objects/transactional";
import type { Dump, Pull, Register } from "../objects/transactional";
import { ObjectTransactionalHandler } from "../objects/transactional";
type RecursiveType =
| { [key: string]: RecursiveType }
| string
| number
| Array<RecursiveType>;
export async function handleFileUpload(
h3: H3Event<EventHandlerRequest>,
metadata: { [key: string]: string },
permissions: Array<string>,
max = -1,
): Promise<[string[], { [key: string]: string }, Pull, Dump] | undefined> {
): Promise<
[string[], { [key: string]: RecursiveType }, Pull, Dump, Register] | undefined
> {
const formData = await readMultipartFormData(h3);
if (!formData) return undefined;
const transactionalHandler = new ObjectTransactionalHandler();
const [add, pull, dump] = transactionalHandler.new(metadata, permissions);
const options: { [key: string]: string } = {};
const options: any = {};
const ids = [];
for (const entry of formData) {
@ -25,8 +34,15 @@ export async function handleFileUpload(
}
if (!entry.name) continue;
options[entry.name] = entry.data.toString("utf-8");
const path = entry.name.split(".");
let v = options;
for (const pathPart of path.slice(0, -1)) {
(v as any)[pathPart] ??= {};
v = (v as any)[pathPart];
}
(v as any)[path.at(-1)!] = entry.data.toString("utf-8");
}
return [ids, options, pull, dump];
return [ids, options, pull, dump, add];
}

View File

@ -1,14 +1,14 @@
import { Platform } from "~/prisma/client/enums";
import { HardwarePlatform } from "~~/prisma/client/enums";
export function parsePlatform(platform: string) {
switch (platform.toLowerCase()) {
case "linux":
return Platform.Linux;
return HardwarePlatform.Linux;
case "windows":
return Platform.Windows;
return HardwarePlatform.Windows;
case "mac":
case "macos":
return Platform.macOS;
return HardwarePlatform.macOS;
}
return undefined;

View File

@ -80,11 +80,12 @@ export class PriorityListIndexed<T> extends PriorityList<T> {
override pop(position?: number): PriorityTagged<T> {
const value = super.pop(position);
if(!value) return undefined!;
const index = this.getIndex(value.object);
this.indexMap.delete(index);
return value;
return value!;
}
get(index: string) {