mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-10 04:22:09 +10:00
Merge branch 'Huskydog9988-more-fixes' into develop
This commit is contained in:
@ -22,6 +22,10 @@ export const userACLDescriptions: ObjectFromList<typeof userACLs> = {
|
||||
"notifications:listen": "Connect to a websocket to recieve notifications.",
|
||||
"notifications:delete": "Delete this account's notifications.",
|
||||
|
||||
"screenshots:new": "Create screenshots for this account",
|
||||
"screenshots:read": "Read all screenshots for this account",
|
||||
"screenshots:delete": "Delete a screenshot for this account",
|
||||
|
||||
"collections:new": "Create collections for this account.",
|
||||
"collections:read": "Fetch all collections (including library).",
|
||||
"collections:delete": "Delete a collection for this account.",
|
||||
|
||||
@ -17,6 +17,10 @@ export const userACLs = [
|
||||
"notifications:listen",
|
||||
"notifications:delete",
|
||||
|
||||
"screenshots:new",
|
||||
"screenshots:read",
|
||||
"screenshots:delete",
|
||||
|
||||
"collections:new",
|
||||
"collections:read",
|
||||
"collections:delete",
|
||||
@ -83,6 +87,12 @@ class ACLManager {
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get userId and require one of the specified acls
|
||||
* @param request
|
||||
* @param acls
|
||||
* @returns
|
||||
*/
|
||||
async getUserIdACL(request: MinimumRequestObject | undefined, acls: UserACL) {
|
||||
if (!request)
|
||||
throw new Error("Native web requests not available - weird deployment?");
|
||||
|
||||
@ -2,6 +2,7 @@ import path from "path";
|
||||
import fs from "fs";
|
||||
import type { CertificateBundle } from "./ca";
|
||||
import prisma from "../db/database";
|
||||
import { systemConfig } from "../config/sys-conf";
|
||||
|
||||
export type CertificateStore = {
|
||||
store(name: string, data: CertificateBundle): Promise<void>;
|
||||
@ -10,7 +11,8 @@ export type CertificateStore = {
|
||||
checkBlacklistCertificate(name: string): Promise<boolean>;
|
||||
};
|
||||
|
||||
export const fsCertificateStore = (base: string) => {
|
||||
export const fsCertificateStore = () => {
|
||||
const base = path.join(systemConfig.getDataFolder(), "certs");
|
||||
const blacklist = path.join(base, ".blacklist");
|
||||
fs.mkdirSync(blacklist, { recursive: true });
|
||||
const store: CertificateStore = {
|
||||
|
||||
@ -10,6 +10,7 @@ export enum InternalClientCapability {
|
||||
PeerAPI = "peerAPI",
|
||||
UserStatus = "userStatus",
|
||||
CloudSaves = "cloudSaves",
|
||||
TrackPlaytime = "trackPlaytime",
|
||||
}
|
||||
|
||||
export const validCapabilities = Object.values(InternalClientCapability);
|
||||
@ -79,6 +80,7 @@ class CapabilityManager {
|
||||
[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,
|
||||
};
|
||||
|
||||
async validateCapabilityConfiguration(
|
||||
@ -160,6 +162,28 @@ class CapabilityManager {
|
||||
},
|
||||
});
|
||||
},
|
||||
[InternalClientCapability.TrackPlaytime]: async function () {
|
||||
const currentClient = await prisma.client.findUnique({
|
||||
where: { id: clientId },
|
||||
select: {
|
||||
capabilities: true,
|
||||
},
|
||||
});
|
||||
if (!currentClient) throw new Error("Invalid client ID");
|
||||
if (
|
||||
currentClient.capabilities.includes(ClientCapabilities.TrackPlaytime)
|
||||
)
|
||||
return;
|
||||
|
||||
await prisma.client.update({
|
||||
where: { id: clientId },
|
||||
data: {
|
||||
capabilities: {
|
||||
push: ClientCapabilities.TrackPlaytime,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
await upsertFunctions[capability]();
|
||||
}
|
||||
|
||||
@ -2,7 +2,10 @@ import { randomUUID } from "node:crypto";
|
||||
import prisma from "../db/database";
|
||||
import type { Platform } from "~/prisma/client";
|
||||
import { useCertificateAuthority } from "~/server/plugins/ca";
|
||||
import type { CapabilityConfiguration, InternalClientCapability } from "./capabilities";
|
||||
import type {
|
||||
CapabilityConfiguration,
|
||||
InternalClientCapability,
|
||||
} from "./capabilities";
|
||||
import capabilityManager from "./capabilities";
|
||||
|
||||
export interface ClientMetadata {
|
||||
|
||||
42
server/internal/config/sys-conf.ts
Normal file
42
server/internal/config/sys-conf.ts
Normal file
@ -0,0 +1,42 @@
|
||||
class SystemConfig {
|
||||
private libraryFolder = process.env.LIBRARY ?? "./.data/library";
|
||||
private dataFolder = process.env.DATA ?? "./.data/data";
|
||||
|
||||
private dropVersion;
|
||||
private gitRef;
|
||||
|
||||
private checkForUpdates =
|
||||
process.env.CHECK_FOR_UPDATES !== undefined &&
|
||||
process.env.CHECK_FOR_UPDATES.toLocaleLowerCase() === "true"
|
||||
? true
|
||||
: false;
|
||||
|
||||
constructor() {
|
||||
// get drop version and git ref from nuxt config
|
||||
const config = useRuntimeConfig();
|
||||
this.dropVersion = config.dropVersion;
|
||||
this.gitRef = config.gitRef;
|
||||
}
|
||||
|
||||
getLibraryFolder() {
|
||||
return this.libraryFolder;
|
||||
}
|
||||
|
||||
getDataFolder() {
|
||||
return this.dataFolder;
|
||||
}
|
||||
|
||||
getDropVersion() {
|
||||
return this.dropVersion;
|
||||
}
|
||||
|
||||
getGitRef() {
|
||||
return this.gitRef;
|
||||
}
|
||||
|
||||
shouldCheckForUpdates() {
|
||||
return this.checkForUpdates;
|
||||
}
|
||||
}
|
||||
|
||||
export const systemConfig = new SystemConfig();
|
||||
@ -15,12 +15,13 @@ import taskHandler from "../tasks";
|
||||
import { parsePlatform } from "../utils/parseplatform";
|
||||
import droplet from "@drop-oss/droplet";
|
||||
import notificationSystem from "../notifications";
|
||||
import { systemConfig } from "../config/sys-conf";
|
||||
|
||||
class LibraryManager {
|
||||
private basePath: string;
|
||||
|
||||
constructor() {
|
||||
this.basePath = process.env.LIBRARY ?? "./.data/library";
|
||||
this.basePath = systemConfig.getLibraryFolder();
|
||||
fs.mkdirSync(this.basePath, { recursive: true });
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import type { Company } from "~/prisma/client";
|
||||
import { MetadataSource } from "~/prisma/client";
|
||||
import { MetadataSource, type Company } from "~/prisma/client";
|
||||
import type { MetadataProvider } from ".";
|
||||
import { MissingMetadataProviderConfig } from ".";
|
||||
import type {
|
||||
@ -9,8 +8,7 @@ import type {
|
||||
_FetchCompanyMetadataParams,
|
||||
CompanyMetadata,
|
||||
} from "./types";
|
||||
import type { AxiosRequestConfig } from "axios";
|
||||
import axios from "axios";
|
||||
import axios, { type AxiosRequestConfig } from "axios";
|
||||
import TurndownService from "turndown";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
@ -207,8 +205,9 @@ export class GiantBombProvider implements MetadataProvider {
|
||||
description: longDescription,
|
||||
released: releaseDate,
|
||||
|
||||
reviewCount: 0,
|
||||
reviewRating: 0,
|
||||
tags: [],
|
||||
|
||||
reviews: [],
|
||||
|
||||
publishers,
|
||||
developers,
|
||||
|
||||
@ -12,6 +12,7 @@ import type {
|
||||
import type { AxiosRequestConfig } from "axios";
|
||||
import axios from "axios";
|
||||
import { DateTime } from "luxon";
|
||||
import * as jdenticon from "jdenticon";
|
||||
|
||||
type IGDBID = number;
|
||||
|
||||
@ -31,6 +32,12 @@ interface IGDBItem {
|
||||
id: IGDBID;
|
||||
}
|
||||
|
||||
interface IGDBGenre extends IGDBItem {
|
||||
name: string;
|
||||
slug: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
// denotes role a company had in a game
|
||||
interface IGDBInvolvedCompany extends IGDBItem {
|
||||
company: IGDBID;
|
||||
@ -68,8 +75,8 @@ interface IGDBCover extends IGDBItem {
|
||||
|
||||
interface IGDBSearchStub extends IGDBItem {
|
||||
name: string;
|
||||
cover: IGDBID;
|
||||
first_release_date: number; // unix timestamp
|
||||
cover?: IGDBID;
|
||||
first_release_date?: number; // unix timestamp
|
||||
summary: string;
|
||||
}
|
||||
|
||||
@ -155,7 +162,7 @@ export class IGDBProvider implements MetadataProvider {
|
||||
}
|
||||
|
||||
private async authWithTwitch() {
|
||||
console.log("authorizing with twitch");
|
||||
console.log("IGDB authorizing with twitch");
|
||||
const params = new URLSearchParams({
|
||||
client_id: this.clientId,
|
||||
client_secret: this.clientSecret,
|
||||
@ -168,10 +175,17 @@ export class IGDBProvider implements MetadataProvider {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (response.status !== 200)
|
||||
throw new Error(
|
||||
`Error in IDGB \nStatus Code: ${response.status}\n${response.data}`,
|
||||
);
|
||||
|
||||
this.accessToken = response.data.access_token;
|
||||
this.accessTokenExpiry = DateTime.now().plus({
|
||||
seconds: response.data.expires_in,
|
||||
});
|
||||
|
||||
console.log("IDGB done authorizing with twitch");
|
||||
}
|
||||
|
||||
private async refreshCredentials() {
|
||||
@ -231,6 +245,11 @@ export class IGDBProvider implements MetadataProvider {
|
||||
}
|
||||
|
||||
private async _getMediaInternal(mediaID: IGDBID, type: string) {
|
||||
if (mediaID === undefined)
|
||||
throw new Error(
|
||||
`IGDB mediaID when getting item of type ${type} was undefined`,
|
||||
);
|
||||
|
||||
const body = `where id = ${mediaID}; fields url;`;
|
||||
const response = await this.request<IGDBCover>(type, body);
|
||||
|
||||
@ -244,6 +263,7 @@ export class IGDBProvider implements MetadataProvider {
|
||||
result = `https:${cover.url}`;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -263,6 +283,32 @@ export class IGDBProvider implements MetadataProvider {
|
||||
return msg.length > len ? msg.substring(0, 280) + "..." : msg;
|
||||
}
|
||||
|
||||
private async _getGenreInternal(genreID: IGDBID) {
|
||||
if (genreID === undefined) throw new Error(`IGDB genreID was undefined`);
|
||||
|
||||
const body = `where id = ${genreID}; fields slug,name,url;`;
|
||||
const response = await this.request<IGDBGenre>("genres", body);
|
||||
|
||||
let result = "";
|
||||
|
||||
response.forEach((genre) => {
|
||||
result = genre.name;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async getGenres(genres: IGDBID[] | undefined): Promise<string[]> {
|
||||
if (genres === undefined) return [];
|
||||
|
||||
const results: string[] = [];
|
||||
for (const genre of genres) {
|
||||
results.push(await this._getGenreInternal(genre));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
name() {
|
||||
return "IGDB";
|
||||
}
|
||||
@ -276,12 +322,24 @@ export class IGDBProvider implements MetadataProvider {
|
||||
|
||||
const results: GameMetadataSearchResult[] = [];
|
||||
for (let i = 0; i < response.length; i++) {
|
||||
let icon = "";
|
||||
const cover = response[i].cover;
|
||||
if (cover !== undefined) {
|
||||
icon = await this.getCoverURL(cover);
|
||||
} else {
|
||||
icon = "";
|
||||
}
|
||||
|
||||
const firstReleaseDate = response[i].first_release_date;
|
||||
results.push({
|
||||
id: "" + response[i].id,
|
||||
name: response[i].name,
|
||||
icon: await this.getCoverURL(response[i].cover),
|
||||
icon,
|
||||
description: response[i].summary,
|
||||
year: DateTime.fromSeconds(response[i].first_release_date).year,
|
||||
year:
|
||||
firstReleaseDate === undefined
|
||||
? 0
|
||||
: DateTime.fromSeconds(firstReleaseDate).year,
|
||||
});
|
||||
}
|
||||
|
||||
@ -297,7 +355,14 @@ export class IGDBProvider implements MetadataProvider {
|
||||
const response = await this.request<IGDBGameFull>("games", body);
|
||||
|
||||
for (let i = 0; i < response.length; i++) {
|
||||
const icon = createObject(await this.getCoverURL(response[i].cover));
|
||||
let iconRaw;
|
||||
const cover = response[i].cover;
|
||||
if (cover !== undefined) {
|
||||
iconRaw = await this.getCoverURL(cover);
|
||||
} else {
|
||||
iconRaw = jdenticon.toPng(id, 512);
|
||||
}
|
||||
const icon = createObject(iconRaw);
|
||||
let banner = "";
|
||||
|
||||
const images = [icon];
|
||||
@ -343,21 +408,33 @@ export class IGDBProvider implements MetadataProvider {
|
||||
}
|
||||
}
|
||||
|
||||
const firstReleaseDate = response[i].first_release_date;
|
||||
|
||||
return {
|
||||
id: "" + response[i].id,
|
||||
name: response[i].name,
|
||||
shortDescription: this.trimMessage(response[i].summary, 280),
|
||||
description: response[i].summary,
|
||||
released: DateTime.fromSeconds(
|
||||
response[i].first_release_date,
|
||||
).toJSDate(),
|
||||
released:
|
||||
firstReleaseDate === undefined
|
||||
? new Date()
|
||||
: DateTime.fromSeconds(firstReleaseDate).toJSDate(),
|
||||
|
||||
reviewCount: response[i]?.total_rating_count ?? 0,
|
||||
reviewRating: (response[i]?.total_rating ?? 0) / 100,
|
||||
reviews: [
|
||||
{
|
||||
metadataId: "" + response[i].id,
|
||||
metadataSource: MetadataSource.IGDB,
|
||||
mReviewCount: response[i]?.total_rating_count ?? 0,
|
||||
mReviewRating: (response[i]?.total_rating ?? 0) / 100,
|
||||
mReviewHref: response[i].url,
|
||||
},
|
||||
],
|
||||
|
||||
publishers: [],
|
||||
developers: [],
|
||||
|
||||
tags: await this.getGenres(response[i].genres),
|
||||
|
||||
icon,
|
||||
bannerId: banner,
|
||||
coverId: icon,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { MetadataSource } from "~/prisma/client";
|
||||
import { MetadataSource, type GameRating } from "~/prisma/client";
|
||||
import prisma from "../db/database";
|
||||
import type {
|
||||
_FetchGameMetadataParams,
|
||||
@ -7,10 +7,11 @@ import type {
|
||||
GameMetadataSearchResult,
|
||||
InternalGameMetadataResult,
|
||||
CompanyMetadata,
|
||||
GameMetadataRating,
|
||||
} from "./types";
|
||||
import { ObjectTransactionalHandler } from "../objects/transactional";
|
||||
import { PriorityListIndexed } from "../utils/prioritylist";
|
||||
import { DROP_VERSION } from "../consts";
|
||||
import { systemConfig } from "../config/sys-conf";
|
||||
|
||||
export class MissingMetadataProviderConfig extends Error {
|
||||
private providerName: string;
|
||||
@ -26,7 +27,7 @@ export class MissingMetadataProviderConfig extends Error {
|
||||
}
|
||||
|
||||
// TODO: add useragent to all outbound api calls (best practice)
|
||||
export const DropUserAgent = `Drop/${DROP_VERSION}`;
|
||||
export const DropUserAgent = `Drop/${systemConfig.getDropVersion()}`;
|
||||
|
||||
export abstract class MetadataProvider {
|
||||
abstract name(): string;
|
||||
@ -111,6 +112,58 @@ export class MetadataHandler {
|
||||
);
|
||||
}
|
||||
|
||||
private parseTags(tags: string[]) {
|
||||
const results: {
|
||||
where: {
|
||||
name: string;
|
||||
};
|
||||
create: {
|
||||
name: string;
|
||||
};
|
||||
}[] = [];
|
||||
|
||||
tags.forEach((t) =>
|
||||
results.push({
|
||||
where: {
|
||||
name: t,
|
||||
},
|
||||
create: {
|
||||
name: t,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private parseRatings(ratings: GameMetadataRating[]) {
|
||||
const results: {
|
||||
where: {
|
||||
metadataKey: {
|
||||
metadataId: string;
|
||||
metadataSource: MetadataSource;
|
||||
};
|
||||
};
|
||||
create: Omit<GameRating, "gameId" | "created" | "id">;
|
||||
}[] = [];
|
||||
|
||||
ratings.forEach((r) => {
|
||||
results.push({
|
||||
where: {
|
||||
metadataKey: {
|
||||
metadataId: r.metadataId,
|
||||
metadataSource: r.metadataSource,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
...r,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async createGame(
|
||||
result: InternalGameMetadataResult,
|
||||
libraryBasePath: string,
|
||||
@ -157,9 +210,6 @@ export class MetadataHandler {
|
||||
mName: metadata.name,
|
||||
mShortDescription: metadata.shortDescription,
|
||||
mDescription: metadata.description,
|
||||
|
||||
mReviewCount: metadata.reviewCount,
|
||||
mReviewRating: metadata.reviewRating,
|
||||
mReleased: metadata.released,
|
||||
|
||||
mIconObjectId: metadata.icon,
|
||||
@ -174,6 +224,13 @@ export class MetadataHandler {
|
||||
connect: metadata.developers,
|
||||
},
|
||||
|
||||
ratings: {
|
||||
connectOrCreate: this.parseRatings(metadata.reviews),
|
||||
},
|
||||
tags: {
|
||||
connectOrCreate: this.parseTags(metadata.tags),
|
||||
},
|
||||
|
||||
libraryBasePath,
|
||||
},
|
||||
});
|
||||
@ -216,11 +273,14 @@ export class MetadataHandler {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we're successful
|
||||
await pullObjects();
|
||||
|
||||
const object = await prisma.company.create({
|
||||
data: {
|
||||
const object = await prisma.company.upsert({
|
||||
where: {
|
||||
metadataKey: {
|
||||
metadataSource: provider.source(),
|
||||
metadataId: result.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
metadataSource: provider.source(),
|
||||
metadataId: result.id,
|
||||
metadataOriginalQuery: query,
|
||||
@ -232,8 +292,15 @@ export class MetadataHandler {
|
||||
mBannerObjectId: result.banner,
|
||||
mWebsite: result.website,
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
if (object.mLogoObjectId == result.logo) {
|
||||
// We created, and didn't update
|
||||
// So pull objects
|
||||
await pullObjects();
|
||||
}
|
||||
|
||||
return object;
|
||||
}
|
||||
|
||||
|
||||
@ -33,8 +33,8 @@ export class ManualMetadataProvider implements MetadataProvider {
|
||||
released: new Date(),
|
||||
publishers: [],
|
||||
developers: [],
|
||||
reviewCount: 0,
|
||||
reviewRating: 0,
|
||||
tags: [],
|
||||
reviews: [],
|
||||
|
||||
icon: iconId,
|
||||
coverId: iconId,
|
||||
|
||||
@ -7,11 +7,31 @@ import type {
|
||||
GameMetadata,
|
||||
_FetchCompanyMetadataParams,
|
||||
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";
|
||||
|
||||
interface PCGamingWikiParseRawPage {
|
||||
parse: {
|
||||
title: string;
|
||||
pageid: number;
|
||||
revid: number;
|
||||
displaytitle: string;
|
||||
// array of links
|
||||
externallinks: string[];
|
||||
// array of wiki file names
|
||||
images: string[];
|
||||
text: {
|
||||
// rendered page contents
|
||||
"*": string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface PCGamingWikiPage {
|
||||
PageID: string;
|
||||
@ -25,12 +45,19 @@ interface PCGamingWikiSearchStub extends PCGamingWikiPage {
|
||||
}
|
||||
|
||||
interface PCGamingWikiGame extends PCGamingWikiSearchStub {
|
||||
Developers: string | null;
|
||||
Genres: string | null;
|
||||
Publishers: string | null;
|
||||
Themes: string | null;
|
||||
Developers: string | string[] | null;
|
||||
Publishers: string | string[] | null;
|
||||
|
||||
// TODO: save this somewhere, maybe a tag?
|
||||
Series: string | null;
|
||||
Modes: string | null;
|
||||
|
||||
// tags
|
||||
Perspectives: string | string[] | null; // ie: First-person
|
||||
Genres: string | string[] | null; // ie: Action, FPS
|
||||
"Art styles": string | string[] | null; // ie: Stylized
|
||||
Themes: string | string[] | null; // ie: Post-apocalyptic, Sci-fi, Space
|
||||
Modes: string | string[] | null; // ie: Singleplayer, Multiplayer
|
||||
Pacing: string | string[] | null; // ie: Real-time
|
||||
}
|
||||
|
||||
interface PCGamingWikiCompany extends PCGamingWikiPage {
|
||||
@ -55,6 +82,14 @@ interface PCGamingWikiCargoResult<T> {
|
||||
};
|
||||
}
|
||||
|
||||
type StringArrayKeys<T> = {
|
||||
[K in keyof T]: T[K] extends string | string[] | null ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
const ratingProviderReview = type({
|
||||
rating: "string.integer.parse",
|
||||
});
|
||||
|
||||
// Api Docs: https://www.pcgamingwiki.com/wiki/PCGamingWiki:API
|
||||
// Good tool for helping build cargo queries: https://www.pcgamingwiki.com/wiki/Special:CargoQuery
|
||||
export class PCGamingWikiProvider implements MetadataProvider {
|
||||
@ -75,20 +110,161 @@ export class PCGamingWikiProvider implements MetadataProvider {
|
||||
url: finalURL,
|
||||
baseURL: "",
|
||||
};
|
||||
const response = await axios.request<PCGamingWikiCargoResult<T>>(
|
||||
const response = await axios.request<T>(
|
||||
Object.assign({}, options, overlay),
|
||||
);
|
||||
|
||||
if (response.status !== 200)
|
||||
throw new Error(
|
||||
`Error in pcgamingwiki \nStatus Code: ${response.status}`,
|
||||
`Error in pcgamingwiki \nStatus Code: ${response.status}\n${response.data}`,
|
||||
);
|
||||
else if (response.data.error !== undefined)
|
||||
throw new Error(`Error in pcgamingwiki, malformed query`);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async cargoQuery<T>(
|
||||
query: URLSearchParams,
|
||||
options?: AxiosRequestConfig,
|
||||
) {
|
||||
const response = await this.request<PCGamingWikiCargoResult<T>>(
|
||||
query,
|
||||
options,
|
||||
);
|
||||
if (response.data.error !== undefined)
|
||||
throw new Error(`Error in pcgamingwiki cargo query`);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the raw wiki page for parsing,
|
||||
* requested values are to be considered unstable as compared to cargo queries
|
||||
* @param pageID
|
||||
* @returns
|
||||
*/
|
||||
private async getPageContent(pageID: string) {
|
||||
const searchParams = new URLSearchParams({
|
||||
action: "parse",
|
||||
format: "json",
|
||||
pageid: pageID,
|
||||
});
|
||||
const res = await this.request<PCGamingWikiParseRawPage>(searchParams);
|
||||
const $ = cheerio.load(res.data.parse.text["*"]);
|
||||
// get intro based on 'introduction' class
|
||||
const introductionEle = $(".introduction").first();
|
||||
// remove citations from intro
|
||||
introductionEle.find("sup").remove();
|
||||
|
||||
const infoBoxEle = $(".template-infobox").first();
|
||||
const receptionEle = infoBoxEle
|
||||
.find(".template-infobox-header")
|
||||
.filter((_, el) => $(el).text().trim() === "Reception");
|
||||
|
||||
const receptionResults: (GameMetadataRating | undefined)[] = [];
|
||||
if (receptionEle.length > 0) {
|
||||
// we have a match!
|
||||
|
||||
const ratingElements = infoBoxEle.find(".template-infobox-type");
|
||||
|
||||
// TODO: cleanup this ratnest
|
||||
const parseIdFromHref = (href: string): string | undefined => {
|
||||
const url = new URL(href);
|
||||
const opencriticRegex = /^\/game\/(\d+)\/.+$/;
|
||||
switch (url.hostname.toLocaleLowerCase()) {
|
||||
case "www.metacritic.com": {
|
||||
// https://www.metacritic.com/game/elden-ring/critic-reviews/?platform=pc
|
||||
return url.pathname
|
||||
.replace("/game/", "")
|
||||
.replace("/critic-reviews", "")
|
||||
.replace(/\/$/, "");
|
||||
}
|
||||
case "opencritic.com": {
|
||||
// https://opencritic.com/game/12090/elden-ring
|
||||
let id = "unknown";
|
||||
let matches;
|
||||
if ((matches = opencriticRegex.exec(url.pathname)) !== null) {
|
||||
matches.forEach((match, _groupIndex) => {
|
||||
// console.log(`Found match, group ${_groupIndex}: ${match}`);
|
||||
id = match;
|
||||
});
|
||||
}
|
||||
|
||||
if (id === "unknown") {
|
||||
return undefined;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
case "www.igdb.com": {
|
||||
// https://www.igdb.com/games/elden-ring
|
||||
return url.pathname.replace("/games/", "").replace(/\/$/, "");
|
||||
}
|
||||
default: {
|
||||
console.warn("Pcgamingwiki, unknown host", url.hostname);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
const getRating = (
|
||||
source: MetadataSource,
|
||||
): GameMetadataRating | undefined => {
|
||||
const providerEle = ratingElements.filter(
|
||||
(_, el) =>
|
||||
$(el).text().trim().toLocaleLowerCase() ===
|
||||
source.toLocaleLowerCase(),
|
||||
);
|
||||
if (providerEle.length > 0) {
|
||||
// get info associated with provider
|
||||
const reviewEle = providerEle
|
||||
.first()
|
||||
.parent()
|
||||
.find(".template-infobox-info")
|
||||
.find("a")
|
||||
.first();
|
||||
|
||||
const href = reviewEle.attr("href");
|
||||
if (!href) {
|
||||
console.log(
|
||||
`pcgamingwiki: failed to properly get review href for ${source}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
const ratingObj = ratingProviderReview({
|
||||
rating: reviewEle.text().trim(),
|
||||
});
|
||||
if (ratingObj instanceof type.errors) {
|
||||
console.log(
|
||||
"pcgamingwiki: failed to properly get review rating",
|
||||
ratingObj.summary,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const id = parseIdFromHref(href);
|
||||
if (!id) return undefined;
|
||||
|
||||
return {
|
||||
mReviewHref: href,
|
||||
metadataId: id,
|
||||
metadataSource: source,
|
||||
mReviewCount: 0,
|
||||
// make float within 0 to 1
|
||||
mReviewRating: ratingObj.rating / 100,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
receptionResults.push(getRating(MetadataSource.Metacritic));
|
||||
receptionResults.push(getRating(MetadataSource.IGDB));
|
||||
receptionResults.push(getRating(MetadataSource.OpenCritic));
|
||||
}
|
||||
|
||||
return {
|
||||
shortIntro: introductionEle.find("p").first().text().trim(),
|
||||
introduction: introductionEle.text().trim(),
|
||||
reception: receptionResults,
|
||||
};
|
||||
}
|
||||
|
||||
async search(query: string) {
|
||||
const searchParams = new URLSearchParams({
|
||||
action: "cargoquery",
|
||||
@ -99,43 +275,58 @@ export class PCGamingWikiProvider implements MetadataProvider {
|
||||
format: "json",
|
||||
});
|
||||
|
||||
const res = await this.request<PCGamingWikiSearchStub>(searchParams);
|
||||
const response =
|
||||
await this.cargoQuery<PCGamingWikiSearchStub>(searchParams);
|
||||
|
||||
const mapped = res.data.cargoquery.map((result) => {
|
||||
const results: GameMetadataSearchResult[] = [];
|
||||
for (const result of response.data.cargoquery) {
|
||||
const game = result.title;
|
||||
const pageContent = await this.getPageContent(game.PageID);
|
||||
|
||||
const metadata: GameMetadataSearchResult = {
|
||||
results.push({
|
||||
id: game.PageID,
|
||||
name: game.PageName,
|
||||
icon: game["Cover URL"] ?? "",
|
||||
description: "", // TODO: need to render the `Introduction` template somehow (or we could just hardcode it)
|
||||
description: pageContent.shortIntro,
|
||||
year:
|
||||
game.Released !== null && game.Released.length > 0
|
||||
? // sometimes will provide multiple dates
|
||||
this.parseTS(game.Released).year
|
||||
: 0,
|
||||
};
|
||||
return metadata;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return mapped;
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the specific format that the wiki returns when specifying a company
|
||||
* @param companyStr
|
||||
* Parses the specific format that the wiki returns when specifying an array
|
||||
* @param input string or array
|
||||
* @returns
|
||||
*/
|
||||
private parseCompanyStr(companyStr: string): string[] {
|
||||
const results: string[] = [];
|
||||
// provides the string as a list of companies
|
||||
// ie: "Company:Digerati Distribution,Company:Greylock Studio"
|
||||
const items = companyStr.split(",");
|
||||
private parseWikiStringArray(input: string | string[]): string[] {
|
||||
const cleanStr = (str: string): string => {
|
||||
// remove any dumb prefixes we don't care about
|
||||
return str.replace("Company:", "").trim();
|
||||
};
|
||||
|
||||
items.forEach((item) => {
|
||||
// remove the `Company:` and trim and whitespace
|
||||
results.push(item.replace("Company:", "").trim());
|
||||
});
|
||||
// input can provides the string as a list
|
||||
// ie: "Company:Digerati Distribution,Company:Greylock Studio"
|
||||
// or as an array, sometimes the array has empty values
|
||||
|
||||
const results: string[] = [];
|
||||
if (Array.isArray(input)) {
|
||||
input.forEach((c) => {
|
||||
const clean = cleanStr(c);
|
||||
if (clean !== "") results.push(clean);
|
||||
});
|
||||
} else {
|
||||
const items = input.split(",");
|
||||
items.forEach((item) => {
|
||||
const clean = cleanStr(item);
|
||||
if (clean !== "") results.push(clean);
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@ -156,6 +347,28 @@ export class PCGamingWikiProvider implements MetadataProvider {
|
||||
return websiteStr.replaceAll(/\[|]/g, "").split(" ")[0] ?? "";
|
||||
}
|
||||
|
||||
private compileTags(game: PCGamingWikiGame): string[] {
|
||||
const results: string[] = [];
|
||||
|
||||
const properties: StringArrayKeys<PCGamingWikiGame>[] = [
|
||||
"Art styles",
|
||||
"Genres",
|
||||
"Modes",
|
||||
"Pacing",
|
||||
"Perspectives",
|
||||
"Themes",
|
||||
];
|
||||
|
||||
// loop through all above keys, get the tags they contain
|
||||
properties.forEach((p) => {
|
||||
if (game[p] === null) return;
|
||||
|
||||
results.push(...this.parseWikiStringArray(game[p]));
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async fetchGame({
|
||||
id,
|
||||
name,
|
||||
@ -167,12 +380,15 @@ export class PCGamingWikiProvider implements MetadataProvider {
|
||||
action: "cargoquery",
|
||||
tables: "Infobox_game",
|
||||
fields:
|
||||
"Infobox_game._pageID=PageID,Infobox_game._pageName=PageName,Infobox_game.Cover_URL,Infobox_game.Developers,Infobox_game.Released,Infobox_game.Genres,Infobox_game.Publishers,Infobox_game.Themes,Infobox_game.Series,Infobox_game.Modes",
|
||||
"Infobox_game._pageID=PageID,Infobox_game._pageName=PageName,Infobox_game.Cover_URL,Infobox_game.Developers,Infobox_game.Released,Infobox_game.Genres,Infobox_game.Publishers,Infobox_game.Themes,Infobox_game.Series,Infobox_game.Modes,Infobox_game.Perspectives,Infobox_game.Art_styles,Infobox_game.Pacing",
|
||||
where: `Infobox_game._pageID="${id}"`,
|
||||
format: "json",
|
||||
});
|
||||
|
||||
const res = await this.request<PCGamingWikiGame>(searchParams);
|
||||
const [res, pageContent] = await Promise.all([
|
||||
this.cargoQuery<PCGamingWikiGame>(searchParams),
|
||||
this.getPageContent(id),
|
||||
]);
|
||||
if (res.data.cargoquery.length < 1)
|
||||
throw new Error("Error in pcgamingwiki, no game");
|
||||
|
||||
@ -180,7 +396,7 @@ export class PCGamingWikiProvider implements MetadataProvider {
|
||||
|
||||
const publishers: Company[] = [];
|
||||
if (game.Publishers !== null) {
|
||||
const pubListClean = this.parseCompanyStr(game.Publishers);
|
||||
const pubListClean = this.parseWikiStringArray(game.Publishers);
|
||||
for (const pub of pubListClean) {
|
||||
const res = await publisher(pub);
|
||||
if (res === undefined) continue;
|
||||
@ -190,7 +406,7 @@ export class PCGamingWikiProvider implements MetadataProvider {
|
||||
|
||||
const developers: Company[] = [];
|
||||
if (game.Developers !== null) {
|
||||
const devListClean = this.parseCompanyStr(game.Developers);
|
||||
const devListClean = this.parseWikiStringArray(game.Developers);
|
||||
for (const dev of devListClean) {
|
||||
const res = await developer(dev);
|
||||
if (res === undefined) continue;
|
||||
@ -206,15 +422,15 @@ export class PCGamingWikiProvider implements MetadataProvider {
|
||||
const metadata: GameMetadata = {
|
||||
id: game.PageID,
|
||||
name: game.PageName,
|
||||
shortDescription: "", // TODO: (again) need to render the `Introduction` template somehow (or we could just hardcode it)
|
||||
description: "",
|
||||
shortDescription: pageContent.shortIntro,
|
||||
description: pageContent.introduction,
|
||||
released: game.Released
|
||||
? DateTime.fromISO(game.Released.split(";")[0]).toJSDate()
|
||||
: new Date(),
|
||||
|
||||
reviewCount: 0,
|
||||
reviewRating: 0,
|
||||
tags: this.compileTags(game),
|
||||
|
||||
reviews: pageContent.reception.filter((v) => typeof v !== "undefined"),
|
||||
publishers,
|
||||
developers,
|
||||
|
||||
@ -240,16 +456,16 @@ export class PCGamingWikiProvider implements MetadataProvider {
|
||||
format: "json",
|
||||
});
|
||||
|
||||
const res = await this.request<PCGamingWikiCompany>(searchParams);
|
||||
const res = await this.cargoQuery<PCGamingWikiCompany>(searchParams);
|
||||
|
||||
// TODO: replace
|
||||
// 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;
|
||||
|
||||
const fixedCompanyName =
|
||||
this.parseCompanyStr(company.PageName)[0] ?? company.PageName;
|
||||
this.parseWikiStringArray(company.PageName)[0] ?? company.PageName;
|
||||
|
||||
const metadata: CompanyMetadata = {
|
||||
id: company.PageID,
|
||||
|
||||
16
server/internal/metadata/types.d.ts
vendored
16
server/internal/metadata/types.d.ts
vendored
@ -1,4 +1,4 @@
|
||||
import type { Company } from "~/prisma/client";
|
||||
import type { Company, GameRating } from "~/prisma/client";
|
||||
import type { TransactionDataType } from "../objects/transactional";
|
||||
import type { ObjectReference } from "../objects/objectHandler";
|
||||
|
||||
@ -18,6 +18,15 @@ export interface GameMetadataSource {
|
||||
export type InternalGameMetadataResult = GameMetadataSearchResult &
|
||||
GameMetadataSource;
|
||||
|
||||
export type GameMetadataRating = Pick<
|
||||
GameRating,
|
||||
| "metadataSource"
|
||||
| "metadataId"
|
||||
| "mReviewCount"
|
||||
| "mReviewHref"
|
||||
| "mReviewRating"
|
||||
>;
|
||||
|
||||
export interface GameMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
@ -30,8 +39,9 @@ export interface GameMetadata {
|
||||
publishers: Company[];
|
||||
developers: Company[];
|
||||
|
||||
reviewCount: number;
|
||||
reviewRating: number;
|
||||
tags: string[];
|
||||
|
||||
reviews: GameMetadataRating[];
|
||||
|
||||
// Created with another utility function
|
||||
icon: ObjectReference;
|
||||
|
||||
@ -10,6 +10,9 @@ import type { Notification } from "~/prisma/client";
|
||||
import prisma from "../db/database";
|
||||
import type { GlobalACL } from "../acls";
|
||||
|
||||
// type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
|
||||
|
||||
// TODO: document notification action format
|
||||
export type NotificationCreateArgs = Pick<
|
||||
Notification,
|
||||
"title" | "description" | "actions" | "nonce"
|
||||
@ -72,14 +75,18 @@ class NotificationSystem {
|
||||
throw new Error("No nonce in notificationCreateArgs");
|
||||
const notification = await prisma.notification.upsert({
|
||||
where: {
|
||||
nonce: notificationCreateArgs.nonce,
|
||||
userId_nonce: {
|
||||
nonce: notificationCreateArgs.nonce,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
userId: userId,
|
||||
// we don't need to update the userid right?
|
||||
// userId: userId,
|
||||
...notificationCreateArgs,
|
||||
},
|
||||
create: {
|
||||
userId: userId,
|
||||
userId,
|
||||
...notificationCreateArgs,
|
||||
},
|
||||
});
|
||||
@ -87,6 +94,27 @@ class NotificationSystem {
|
||||
await this.pushNotification(userId, notification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal call to batch push notifications to many users
|
||||
* @param notificationCreateArgs
|
||||
* @param users
|
||||
*/
|
||||
private async _pushMany(
|
||||
notificationCreateArgs: NotificationCreateArgs,
|
||||
users: { id: string }[],
|
||||
) {
|
||||
const res: Promise<void>[] = [];
|
||||
for (const user of users) {
|
||||
res.push(this.push(user.id, notificationCreateArgs));
|
||||
}
|
||||
// wait for all notifications to pass
|
||||
await Promise.all(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to all users
|
||||
* @param notificationCreateArgs
|
||||
*/
|
||||
async pushAll(notificationCreateArgs: NotificationCreateArgs) {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { not: "system" } },
|
||||
@ -95,13 +123,27 @@ class NotificationSystem {
|
||||
},
|
||||
});
|
||||
|
||||
for (const user of users) {
|
||||
await this.push(user.id, notificationCreateArgs);
|
||||
}
|
||||
await this._pushMany(notificationCreateArgs, users);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to all system level users
|
||||
* @param notificationCreateArgs
|
||||
* @returns
|
||||
*/
|
||||
async systemPush(notificationCreateArgs: NotificationCreateArgs) {
|
||||
return await this.pushAll(notificationCreateArgs);
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
id: { not: "system" },
|
||||
// no reason to send to any users other then admins rn
|
||||
admin: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
await this._pushMany(notificationCreateArgs, users);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { ObjectMetadata, ObjectReference, Source } from "./objectHandler";
|
||||
import { ObjectBackend } from "./objectHandler";
|
||||
import { ObjectBackend, objectMetadata } from "./objectHandler";
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
@ -7,16 +7,20 @@ import { Readable } from "stream";
|
||||
import { createHash } from "crypto";
|
||||
import prisma from "../db/database";
|
||||
import cacheHandler from "../cache";
|
||||
import { systemConfig } from "../config/sys-conf";
|
||||
import { type } from "arktype";
|
||||
|
||||
export class FsObjectBackend extends ObjectBackend {
|
||||
private baseObjectPath: string;
|
||||
private baseMetadataPath: string;
|
||||
|
||||
private hashStore = new FsHashStore();
|
||||
private metadataCache =
|
||||
cacheHandler.createCache<ObjectMetadata>("ObjectMetadata");
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const basePath = process.env.FS_BACKEND_PATH ?? "./.data/objects";
|
||||
const basePath = path.join(systemConfig.getDataFolder(), "objects");
|
||||
this.baseObjectPath = path.join(basePath, "objects");
|
||||
this.baseMetadataPath = path.join(basePath, "metadata");
|
||||
|
||||
@ -98,17 +102,30 @@ export class FsObjectBackend extends ObjectBackend {
|
||||
const objectPath = path.join(this.baseObjectPath, id);
|
||||
if (!fs.existsSync(objectPath)) return true;
|
||||
fs.rmSync(objectPath);
|
||||
// remove item from cache
|
||||
const metadataPath = path.join(this.baseMetadataPath, `${id}.json`);
|
||||
if (!fs.existsSync(metadataPath)) return true;
|
||||
fs.rmSync(metadataPath);
|
||||
// remove item from caches
|
||||
await this.metadataCache.remove(id);
|
||||
await this.hashStore.delete(id);
|
||||
return true;
|
||||
}
|
||||
async fetchMetadata(
|
||||
id: ObjectReference,
|
||||
): Promise<ObjectMetadata | undefined> {
|
||||
const cacheResult = await this.metadataCache.get(id);
|
||||
if (cacheResult !== null) return cacheResult;
|
||||
|
||||
const metadataPath = path.join(this.baseMetadataPath, `${id}.json`);
|
||||
if (!fs.existsSync(metadataPath)) return undefined;
|
||||
const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
|
||||
return metadata as ObjectMetadata;
|
||||
const metadataRaw = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
|
||||
const metadata = objectMetadata(metadataRaw);
|
||||
if (metadata instanceof type.errors) {
|
||||
console.error("FsObjectBackend#fetchMetadata", metadata.summary);
|
||||
return undefined;
|
||||
}
|
||||
await this.metadataCache.set(id, metadata);
|
||||
return metadata;
|
||||
}
|
||||
async writeMetadata(
|
||||
id: ObjectReference,
|
||||
@ -117,6 +134,7 @@ export class FsObjectBackend extends ObjectBackend {
|
||||
const metadataPath = path.join(this.baseMetadataPath, `${id}.json`);
|
||||
if (!fs.existsSync(metadataPath)) return false;
|
||||
fs.writeFileSync(metadataPath, JSON.stringify(metadata));
|
||||
await this.metadataCache.set(id, metadata);
|
||||
return true;
|
||||
}
|
||||
async fetchHash(id: ObjectReference): Promise<string | undefined> {
|
||||
@ -152,9 +170,34 @@ export class FsObjectBackend extends ObjectBackend {
|
||||
await store.save(id, hashResult);
|
||||
return typeof hashResult;
|
||||
}
|
||||
|
||||
async listAll(): Promise<string[]> {
|
||||
return fs.readdirSync(this.baseObjectPath);
|
||||
}
|
||||
|
||||
async cleanupMetadata() {
|
||||
const metadataFiles = fs.readdirSync(this.baseMetadataPath);
|
||||
const objects = await this.listAll();
|
||||
|
||||
const extraFiles = metadataFiles.filter(
|
||||
(file) => !objects.includes(file.replace(/\.json$/, "")),
|
||||
);
|
||||
console.log(
|
||||
`[FsObjectBackend#cleanupMetadata]: Found ${extraFiles.length} metadata files without corresponding objects.`,
|
||||
);
|
||||
for (const file of extraFiles) {
|
||||
const filePath = path.join(this.baseMetadataPath, file);
|
||||
try {
|
||||
fs.rmSync(filePath);
|
||||
console.log(`[FsObjectBackend#cleanupMetadata]: Removed ${file}`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[FsObjectBackend#cleanupMetadata]: Failed to remove ${file}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FsHashStore {
|
||||
|
||||
@ -14,17 +14,22 @@
|
||||
* anotherUserId:write
|
||||
*/
|
||||
|
||||
import { type } from "arktype";
|
||||
import { parse as getMimeTypeBuffer } from "file-type-mime";
|
||||
import type { Writable } from "stream";
|
||||
import { Readable } from "stream";
|
||||
import { getMimeType as getMimeTypeStream } from "stream-mime-type";
|
||||
|
||||
export type ObjectReference = string;
|
||||
export type ObjectMetadata = {
|
||||
mime: string;
|
||||
permissions: string[];
|
||||
userMetadata: { [key: string]: string };
|
||||
};
|
||||
|
||||
export const objectMetadata = type({
|
||||
mime: "string",
|
||||
permissions: "string[]",
|
||||
userMetadata: {
|
||||
"[string]": "string",
|
||||
},
|
||||
});
|
||||
export type ObjectMetadata = typeof objectMetadata.infer;
|
||||
|
||||
export enum ObjectPermission {
|
||||
Read = "read",
|
||||
@ -66,6 +71,7 @@ export abstract class ObjectBackend {
|
||||
): Promise<boolean>;
|
||||
abstract fetchHash(id: ObjectReference): Promise<string | undefined>;
|
||||
abstract listAll(): Promise<string[]>;
|
||||
abstract cleanupMetadata(): Promise<void>;
|
||||
}
|
||||
|
||||
export class ObjectHandler {
|
||||
@ -252,4 +258,13 @@ export class ObjectHandler {
|
||||
async listAll() {
|
||||
return await this.backend.listAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Purges metadata for objects that no longer exist
|
||||
* This is useful for cleaning up metadata files that are left behinds
|
||||
* @returns
|
||||
*/
|
||||
async cleanupMetadata() {
|
||||
return await this.backend.cleanupMetadata();
|
||||
}
|
||||
}
|
||||
|
||||
50
server/internal/playtime/index.ts
Normal file
50
server/internal/playtime/index.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import prisma from "../db/database";
|
||||
|
||||
class PlaytimeManager {
|
||||
/**
|
||||
* Get a user's playtime on a game
|
||||
* @param gameId
|
||||
* @param userId
|
||||
* @returns
|
||||
*/
|
||||
async get(gameId: string, userId: string) {
|
||||
return await prisma.playtime.findUnique({
|
||||
where: {
|
||||
gameId_userId: {
|
||||
gameId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add time to a user's playtime
|
||||
* @param gameId
|
||||
* @param userId
|
||||
* @param seconds seconds played
|
||||
*/
|
||||
async add(gameId: string, userId: string, seconds: number) {
|
||||
await prisma.playtime.upsert({
|
||||
where: {
|
||||
gameId_userId: {
|
||||
gameId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
gameId,
|
||||
userId,
|
||||
seconds,
|
||||
},
|
||||
update: {
|
||||
seconds: {
|
||||
increment: seconds,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const playtimeManager = new PlaytimeManager();
|
||||
export default playtimeManager;
|
||||
@ -5,6 +5,11 @@ import stream from "node:stream/promises";
|
||||
import prisma from "../db/database";
|
||||
|
||||
class ScreenshotManager {
|
||||
/**
|
||||
* Gets a specific screenshot
|
||||
* @param id
|
||||
* @returns
|
||||
*/
|
||||
async get(id: string) {
|
||||
return await prisma.screenshot.findUnique({
|
||||
where: {
|
||||
@ -13,7 +18,27 @@ class ScreenshotManager {
|
||||
});
|
||||
}
|
||||
|
||||
async getAllByGame(gameId: string, userId: string) {
|
||||
/**
|
||||
* Get all user screenshots
|
||||
* @param userId
|
||||
* @returns
|
||||
*/
|
||||
async getUserAll(userId: string) {
|
||||
const results = await prisma.screenshot.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all user screenshots in a specific game
|
||||
* @param userId
|
||||
* @param gameId
|
||||
* @returns
|
||||
*/
|
||||
async getUserAllByGame(userId: string, gameId: string) {
|
||||
const results = await prisma.screenshot.findMany({
|
||||
where: {
|
||||
gameId,
|
||||
@ -23,6 +48,10 @@ class ScreenshotManager {
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific screenshot
|
||||
* @param id
|
||||
*/
|
||||
async delete(id: string) {
|
||||
await prisma.screenshot.delete({
|
||||
where: {
|
||||
@ -31,9 +60,22 @@ class ScreenshotManager {
|
||||
});
|
||||
}
|
||||
|
||||
async upload(gameId: string, userId: string, inputStream: IncomingMessage) {
|
||||
/**
|
||||
* Allows a user to upload a screenshot
|
||||
* @param userId
|
||||
* @param gameId
|
||||
* @param inputStream
|
||||
*/
|
||||
async upload(userId: string, gameId: string, inputStream: IncomingMessage) {
|
||||
const objectId = randomUUID();
|
||||
const saveStream = await objectHandler.createWithStream(objectId, {}, []);
|
||||
const saveStream = await objectHandler.createWithStream(
|
||||
objectId,
|
||||
{
|
||||
// TODO: set createAt to the time screenshot was taken
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
[`${userId}:read`, `${userId}:delete`],
|
||||
);
|
||||
if (!saveStream)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
@ -43,12 +85,12 @@ class ScreenshotManager {
|
||||
// pipe into object store
|
||||
await stream.pipeline(inputStream, saveStream);
|
||||
|
||||
// TODO: set createAt to the time screenshot was taken
|
||||
await prisma.screenshot.create({
|
||||
data: {
|
||||
gameId,
|
||||
userId,
|
||||
objectId,
|
||||
private: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user