Merge branch 'Huskydog9988-more-fixes' into develop

This commit is contained in:
DecDuck
2025-05-30 08:40:42 +10:00
57 changed files with 1530 additions and 376 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -33,8 +33,8 @@ export class ManualMetadataProvider implements MetadataProvider {
released: new Date(),
publishers: [],
developers: [],
reviewCount: 0,
reviewRating: 0,
tags: [],
reviews: [],
icon: iconId,
coverId: iconId,

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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