From 3df6818ffeadb89334bb57f334a16ce645b6f833 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Sat, 10 May 2025 15:16:26 -0400 Subject: [PATCH 01/25] feat: openapi support plus more api validation --- nuxt.config.ts | 20 ++++++++- .../v1/admin/auth/invitation/index.delete.ts | 22 +++++++--- .../v1/admin/auth/invitation/index.post.ts | 41 ++++++++----------- .../api/v1/admin/game/image/index.delete.ts | 23 ++++++++--- server/api/v1/auth/signin/simple.post.ts | 4 +- server/api/v1/auth/signup/simple.post.ts | 5 ++- server/routes/auth/callback/oidc.get.ts | 8 ++++ server/routes/auth/oidc.get.ts | 8 ++++ server/routes/auth/signout.get.ts | 8 ++++ 9 files changed, 100 insertions(+), 39 deletions(-) diff --git a/nuxt.config.ts b/nuxt.config.ts index e4e014d..440e1b5 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,5 +1,7 @@ import tailwindcss from "@tailwindcss/vite"; +const dropVersion = "0.3"; + // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ // Nuxt-only config @@ -31,24 +33,38 @@ export default defineNuxtConfig({ }, }, + appConfig: { + dropVersion: dropVersion, + }, + routeRules: { "/api/**": { cors: true }, }, nitro: { minify: true, + compressPublicAssets: true, experimental: { websocket: true, tasks: true, + openAPI: true, + }, + + openAPI: { + // tracking for dynamic openapi schema https://github.com/nitrojs/nitro/issues/2974 + meta: { + title: "Drop", + description: + "Drop is an open-source, self-hosted game distribution platform, creating a Steam-like experience for DRM-free games.", + version: dropVersion, + }, }, scheduledTasks: { "0 * * * *": ["cleanup:invitations", "cleanup:sessions"], }, - compressPublicAssets: true, - storage: { appCache: { driver: "lru-cache", diff --git a/server/api/v1/admin/auth/invitation/index.delete.ts b/server/api/v1/admin/auth/invitation/index.delete.ts index 381dea3..34d24bd 100644 --- a/server/api/v1/admin/auth/invitation/index.delete.ts +++ b/server/api/v1/admin/auth/invitation/index.delete.ts @@ -1,20 +1,30 @@ +import { type } from "arktype"; import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; -export default defineEventHandler(async (h3) => { +const DeleteInvite = type({ + id: "string", +}); + +export default defineEventHandler<{ + body: typeof DeleteInvite.infer; +}>(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, [ "auth:simple:invitation:delete", ]); if (!allowed) throw createError({ statusCode: 403 }); - const body = await readBody(h3); - const id = body.id; - if (!id) + const body = DeleteInvite(await readBody(h3)); + if (body instanceof type.errors) { + // hover out.summary to see validation errors + console.error(body.summary); + throw createError({ statusCode: 400, - statusMessage: "id required for deletion", + statusMessage: body.summary, }); + } - await prisma.invitation.delete({ where: { id: id } }); + await prisma.invitation.delete({ where: { id: body.id } }); return {}; }); diff --git a/server/api/v1/admin/auth/invitation/index.post.ts b/server/api/v1/admin/auth/invitation/index.post.ts index 015557e..58305c2 100644 --- a/server/api/v1/admin/auth/invitation/index.post.ts +++ b/server/api/v1/admin/auth/invitation/index.post.ts @@ -1,40 +1,35 @@ +import { type } from "arktype"; import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; -export default defineEventHandler(async (h3) => { +const CreateInvite = type({ + isAdmin: "boolean", + username: "string", + email: "string.email", + expires: "string.date.iso.parse", +}); + +export default defineEventHandler<{ + body: typeof CreateInvite.infer; +}>(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, [ "auth:simple:invitation:new", ]); if (!allowed) throw createError({ statusCode: 403 }); - const body = await readBody(h3); - const isAdmin = body.isAdmin; - const username = body.username; - const email = body.email; - const expires = body.expires; - - if (!expires) - throw createError({ statusCode: 400, statusMessage: "No expires field." }); - if (isAdmin !== undefined && typeof isAdmin !== "boolean") - throw createError({ - statusCode: 400, - statusMessage: "isAdmin must be a boolean", - }); + const body = CreateInvite(await readBody(h3)); + if (body instanceof type.errors) { + // hover out.summary to see validation errors + console.error(body.summary); - const expiresDate = new Date(expires); - if (!(expiresDate instanceof Date && !isNaN(expiresDate.getTime()))) throw createError({ statusCode: 400, - statusMessage: "Invalid expires date", + statusMessage: body.summary, }); + } const invitation = await prisma.invitation.create({ - data: { - isAdmin: isAdmin, - username: username, - email: email, - expires: expiresDate, - }, + data: body, }); return invitation; diff --git a/server/api/v1/admin/game/image/index.delete.ts b/server/api/v1/admin/game/image/index.delete.ts index 3558584..9d9754e 100644 --- a/server/api/v1/admin/game/image/index.delete.ts +++ b/server/api/v1/admin/game/image/index.delete.ts @@ -1,20 +1,31 @@ import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; import objectHandler from "~/server/internal/objects"; +import { type } from "arktype"; -export default defineEventHandler(async (h3) => { +const ModifyGameImage = type({ + gameId: "string", + imageId: "string", +}); + +export default defineEventHandler<{ + body: typeof ModifyGameImage.infer; +}>(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, ["game:image:delete"]); if (!allowed) throw createError({ statusCode: 403 }); - const body = await readBody(h3); - const gameId = body.gameId; - const imageId = body.imageId; + const body = ModifyGameImage(await readBody(h3)); + if (body instanceof type.errors) { + // hover out.summary to see validation errors + console.error(body.summary); - if (!gameId || !imageId) throw createError({ statusCode: 400, - statusMessage: "Missing gameId or imageId in body", + statusMessage: body.summary, }); + } + const gameId = body.gameId; + const imageId = body.imageId; const game = await prisma.game.findUnique({ where: { diff --git a/server/api/v1/auth/signin/simple.post.ts b/server/api/v1/auth/signin/simple.post.ts index 42eacdf..f184ca8 100644 --- a/server/api/v1/auth/signin/simple.post.ts +++ b/server/api/v1/auth/signin/simple.post.ts @@ -15,7 +15,9 @@ const signinValidator = type({ "rememberMe?": "boolean | undefined", }); -export default defineEventHandler(async (h3) => { +export default defineEventHandler<{ + body: typeof signinValidator.infer; +}>(async (h3) => { if (!enabledAuthManagers.Simple) throw createError({ statusCode: 403, diff --git a/server/api/v1/auth/signup/simple.post.ts b/server/api/v1/auth/signup/simple.post.ts index ade27c1..5541ec3 100644 --- a/server/api/v1/auth/signup/simple.post.ts +++ b/server/api/v1/auth/signup/simple.post.ts @@ -7,13 +7,16 @@ import { type } from "arktype"; import { randomUUID } from "node:crypto"; const userValidator = type({ + invitation: "string", username: "string >= 5", email: "string.email", password: "string >= 14", "displayName?": "string | undefined", }); -export default defineEventHandler(async (h3) => { +export default defineEventHandler<{ + body: typeof userValidator.infer; +}>(async (h3) => { const body = await readBody(h3); const invitationId = body.invitation; diff --git a/server/routes/auth/callback/oidc.get.ts b/server/routes/auth/callback/oidc.get.ts index e2a6854..3d7d27f 100644 --- a/server/routes/auth/callback/oidc.get.ts +++ b/server/routes/auth/callback/oidc.get.ts @@ -1,6 +1,14 @@ import sessionHandler from "~/server/internal/session"; import { enabledAuthManagers } from "~/server/plugins/04.auth-init"; +defineRouteMeta({ + openAPI: { + tags: ["Auth"], + description: "OIDC Signin callback", + parameters: [], + }, +}); + export default defineEventHandler(async (h3) => { if (!enabledAuthManagers.OpenID) return sendRedirect(h3, "/auth/signin"); diff --git a/server/routes/auth/oidc.get.ts b/server/routes/auth/oidc.get.ts index be3cf94..d655fce 100644 --- a/server/routes/auth/oidc.get.ts +++ b/server/routes/auth/oidc.get.ts @@ -1,5 +1,13 @@ import { enabledAuthManagers } from "~/server/plugins/04.auth-init"; +defineRouteMeta({ + openAPI: { + tags: ["Auth"], + description: "OIDC Signin redirect", + parameters: [], + }, +}); + export default defineEventHandler((h3) => { if (!enabledAuthManagers.OpenID) return sendRedirect(h3, "/auth/signin"); diff --git a/server/routes/auth/signout.get.ts b/server/routes/auth/signout.get.ts index ebeb6f7..3f0b277 100644 --- a/server/routes/auth/signout.get.ts +++ b/server/routes/auth/signout.get.ts @@ -1,5 +1,13 @@ import sessionHandler from "../../internal/session"; +defineRouteMeta({ + openAPI: { + tags: ["Auth"], + description: "Tells server to deauthorize this session", + parameters: [], + }, +}); + export default defineEventHandler(async (h3) => { await sessionHandler.signout(h3); From 60d22ea280047826e239f94be083ca08ba8b1b9d Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Sat, 10 May 2025 16:02:44 -0400 Subject: [PATCH 02/25] fix: back button link in admin dash --- layouts/admin.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/layouts/admin.vue b/layouts/admin.vue index 0b6d810..91c2713 100644 --- a/layouts/admin.vue +++ b/layouts/admin.vue @@ -196,7 +196,7 @@ const navigation: Array = [ }, { label: "Back", - route: "/", + route: "/store", prefix: ".", icon: ArrowLeftIcon, }, From fc7473864325dfa8f806e8468a07898a49343bb1 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Sat, 10 May 2025 16:18:28 -0400 Subject: [PATCH 03/25] feat: new unified data folder --- nuxt.config.ts | 26 +++++++++++++++----------- server/internal/clients/ca-store.ts | 4 +++- server/internal/config/sys-conf.ts | 14 ++++++++++++++ server/internal/library/index.ts | 3 ++- server/internal/objects/fsBackend.ts | 3 ++- server/plugins/ca.ts | 4 +--- 6 files changed, 37 insertions(+), 17 deletions(-) create mode 100644 server/internal/config/sys-conf.ts diff --git a/nuxt.config.ts b/nuxt.config.ts index 440e1b5..b531bcd 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -4,6 +4,17 @@ const dropVersion = "0.3"; // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ + extends: ["./drop-base"], + + // Module config from here down + modules: [ + "vue3-carousel-nuxt", + "nuxt-security", + // "@nuxt/image", + "@nuxt/fonts", + "@nuxt/eslint", + ], + // Nuxt-only config telemetry: false, compatibilityDate: "2024-04-03", @@ -23,6 +34,10 @@ export default defineNuxtConfig({ viewTransition: true, }, + // future: { + // compatibilityVersion: 4, + // }, + vite: { plugins: [tailwindcss()], }, @@ -92,17 +107,6 @@ export default defineNuxtConfig({ }, }, - extends: ["./drop-base"], - - // Module config from here down - modules: [ - "vue3-carousel-nuxt", - "nuxt-security", - // "@nuxt/image", - "@nuxt/fonts", - "@nuxt/eslint", - ], - carousel: { prefix: "Vue", }, diff --git a/server/internal/clients/ca-store.ts b/server/internal/clients/ca-store.ts index 317515b..a424731 100644 --- a/server/internal/clients/ca-store.ts +++ b/server/internal/clients/ca-store.ts @@ -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; @@ -10,7 +11,8 @@ export type CertificateStore = { checkBlacklistCertificate(name: string): Promise; }; -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 = { diff --git a/server/internal/config/sys-conf.ts b/server/internal/config/sys-conf.ts new file mode 100644 index 0000000..306d81e --- /dev/null +++ b/server/internal/config/sys-conf.ts @@ -0,0 +1,14 @@ +class SystemConfig { + private libraryFolder = process.env.LIBRARY ?? "./.data/library"; + private dataFolder = process.env.DATA ?? "./.data/data"; + + getLibraryFolder() { + return this.libraryFolder; + } + + getDataFolder() { + return this.dataFolder; + } +} + +export const systemConfig = new SystemConfig(); diff --git a/server/internal/library/index.ts b/server/internal/library/index.ts index 91d3b21..6556702 100644 --- a/server/internal/library/index.ts +++ b/server/internal/library/index.ts @@ -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 }); } diff --git a/server/internal/objects/fsBackend.ts b/server/internal/objects/fsBackend.ts index 3f0b865..34e0800 100644 --- a/server/internal/objects/fsBackend.ts +++ b/server/internal/objects/fsBackend.ts @@ -7,6 +7,7 @@ import { Readable } from "stream"; import { createHash } from "crypto"; import prisma from "../db/database"; import cacheHandler from "../cache"; +import { systemConfig } from "../config/sys-conf"; export class FsObjectBackend extends ObjectBackend { private baseObjectPath: string; @@ -16,7 +17,7 @@ export class FsObjectBackend extends ObjectBackend { 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"); diff --git a/server/plugins/ca.ts b/server/plugins/ca.ts index 0650f51..4f2aff2 100644 --- a/server/plugins/ca.ts +++ b/server/plugins/ca.ts @@ -9,9 +9,7 @@ export const useCertificateAuthority = () => { }; export default defineNitroPlugin(async () => { - // const basePath = process.env.CLIENT_CERTIFICATES ?? "./certs"; - // fs.mkdirSync(basePath, { recursive: true }); - // const store = fsCertificateStore(basePath); + // const store = fsCertificateStore(); ca = await CertificateAuthority.new(dbCertificateStore()); }); From 1bbdf46a0e039670c207364217956c10154813ad Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Sat, 10 May 2025 17:27:39 -0400 Subject: [PATCH 04/25] feat: better docker builds --- .dockerignore | 7 ++++++- Dockerfile | 42 +++++++++++++++++++++++-------------- build/launch.sh | 5 ++--- deploy-template/compose.yml | 12 ++++------- 4 files changed, 38 insertions(+), 28 deletions(-) diff --git a/.dockerignore b/.dockerignore index 4abae2b..4078721 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,6 +8,7 @@ dist # Node dependencies node_modules +.yarn # Logs logs @@ -23,4 +24,8 @@ logs .env.* !.env.example -.data +# deploy template +deploy-template/ + +# generated prisma client +/prisma/client diff --git a/Dockerfile b/Dockerfile index 128e0d0..73ba58f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,30 +1,40 @@ -# pull pre-configured and updated build environment -FROM debian:testing-20250317-slim AS build-system +# Unified deps builder +FROM node:lts-alpine AS deps +WORKDIR /app +COPY package.json yarn.lock ./ +RUN yarn install --network-timeout 1000000 --ignore-scripts +# Build for app +FROM node:lts-alpine AS build-system # setup workdir - has to be the same filepath as app because fuckin' Prisma WORKDIR /app -# install dependencies and build -RUN apt-get update -y -RUN apt-get install node-corepack -y -RUN corepack enable +ENV NODE_ENV=production +ENV NUXT_TELEMETRY_DISABLED=1 + +# copy deps and rest of project files +COPY --from=deps /app/node_modules ./node_modules COPY . . -RUN NUXT_TELEMETRY_DISABLED=1 yarn install --network-timeout 1000000 -RUN NUXT_TELEMETRY_DISABLED=1 yarn prisma generate -RUN NUXT_TELEMETRY_DISABLED=1 yarn build -# create run environment for Drop -FROM node:lts-slim AS run-system +# build +RUN yarn postinstall +RUN yarn build +# create run environment for Drop +FROM node:lts-alpine AS run-system WORKDIR /app +ENV NODE_ENV=production +ENV NUXT_TELEMETRY_DISABLED=1 + +RUN yarn add --network-timeout 1000000 --no-lockfile prisma@6.7.0 + +COPY --from=build-system /app/package.json ./ COPY --from=build-system /app/.output ./app COPY --from=build-system /app/prisma ./prisma -COPY --from=build-system /app/package.json ./ COPY --from=build-system /app/build ./startup -# OpenSSL as a dependency for Drop (TODO: seperate build environment) -RUN apt-get update -y && apt-get install -y openssl -RUN yarn global add prisma@6.7.0 +ENV LIBRARY="/library" +ENV DATA="/data" -CMD ["/app/startup/launch.sh"] \ No newline at end of file +CMD ["sh", "/app/startup/launch.sh"] diff --git a/build/launch.sh b/build/launch.sh index 0925938..173b311 100755 --- a/build/launch.sh +++ b/build/launch.sh @@ -2,8 +2,7 @@ # This file starts up the Drop server by running migrations and then starting the executable echo "[Drop] performing migrations..." -ls ./prisma/migrations/ -prisma migrate deploy +yarn prisma migrate deploy # Actually start the application -node /app/app/server/index.mjs \ No newline at end of file +node /app/app/server/index.mjs diff --git a/deploy-template/compose.yml b/deploy-template/compose.yml index d3011a6..5573191 100644 --- a/deploy-template/compose.yml +++ b/deploy-template/compose.yml @@ -1,6 +1,7 @@ services: postgres: - image: postgres:14-alpine + # using alpine image to reduce image size + image: postgres:alpine ports: - 5432:5432 healthcheck: @@ -16,7 +17,7 @@ services: - POSTGRES_USER=drop - POSTGRES_DB=drop drop: - image: registry.deepcore.dev/drop-oss/drop/main:latest + image: ghcr.io/drop-oss/drop depends_on: postgres: condition: service_healthy @@ -24,11 +25,6 @@ services: - 3000:3000 volumes: - ./library:/library - - ./certs:/certs - - ./objects:/objects + - ./data:/data environment: - DATABASE_URL=postgres://drop:drop@postgres:5432/drop - - FS_BACKEND_PATH=/objects - - CLIENT_CERTIFICATES=/certs - - LIBRARY=/library - - GIANT_BOMB_API_KEY=REPLACE_WITH_YOUR_KEY From a8ee27eea9758fac419391593f20f4ba07329c45 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Sun, 11 May 2025 00:35:16 -0400 Subject: [PATCH 05/25] feat: pcgamgingwiki now provides a description --- package.json | 2 +- prisma/models/content.prisma | 1 + prisma/models/news.prisma | 1 + server/internal/metadata/giantbomb.ts | 6 +- server/internal/metadata/pcgamingwiki.ts | 74 ++++++- yarn.lock | 249 ++++++++++------------- 6 files changed, 175 insertions(+), 158 deletions(-) diff --git a/package.json b/package.json index 14c2030..398a063 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "arktype": "^2.1.10", "axios": "^1.7.7", "bcryptjs": "^3.0.2", + "cheerio": "^1.0.0", "cookie-es": "^2.0.0", "fast-fuzzy": "^1.12.0", "file-type-mime": "^0.4.3", @@ -36,7 +37,6 @@ "nuxt": "^3.16.2", "nuxt-security": "2.2.0", "prisma": "^6.7.0", - "sharp": "^0.33.5", "stream-mime-type": "^2.0.0", "turndown": "^7.2.0", "unstorage": "^1.15.0", diff --git a/prisma/models/content.prisma b/prisma/models/content.prisma index daaada3..ee3b267 100644 --- a/prisma/models/content.prisma +++ b/prisma/models/content.prisma @@ -34,6 +34,7 @@ model Game { collections CollectionEntry[] saves SaveSlot[] screenshots Screenshot[] + tags Tag[] developers Company[] @relation(name: "developers") publishers Company[] @relation(name: "publishers") diff --git a/prisma/models/news.prisma b/prisma/models/news.prisma index dcc25ec..1878194 100644 --- a/prisma/models/news.prisma +++ b/prisma/models/news.prisma @@ -3,6 +3,7 @@ model Tag { name String @unique articles Article[] + games Game[] } model Article { diff --git a/server/internal/metadata/giantbomb.ts b/server/internal/metadata/giantbomb.ts index d617f0c..0a9922d 100644 --- a/server/internal/metadata/giantbomb.ts +++ b/server/internal/metadata/giantbomb.ts @@ -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"; diff --git a/server/internal/metadata/pcgamingwiki.ts b/server/internal/metadata/pcgamingwiki.ts index 5ebce0c..504c617 100644 --- a/server/internal/metadata/pcgamingwiki.ts +++ b/server/internal/metadata/pcgamingwiki.ts @@ -12,6 +12,29 @@ import type { AxiosRequestConfig } from "axios"; import axios from "axios"; import * as jdenticon from "jdenticon"; import { DateTime } from "luxon"; +import * as cheerio from "cheerio"; + +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 PCGamingWikiParsedPage { + shortIntro: string; + introduction: string; +} interface PCGamingWikiPage { PageID: string; @@ -75,7 +98,7 @@ export class PCGamingWikiProvider implements MetadataProvider { url: finalURL, baseURL: "", }; - const response = await axios.request>( + const response = await axios.request( Object.assign({}, options, overlay), ); @@ -83,12 +106,42 @@ export class PCGamingWikiProvider implements MetadataProvider { throw new Error( `Error in pcgamingwiki \nStatus Code: ${response.status}`, ); - else if (response.data.error !== undefined) - throw new Error(`Error in pcgamingwiki, malformed query`); return response; } + private async cargoQuery( + query: URLSearchParams, + options?: AxiosRequestConfig, + ) { + const response = await this.request>( + query, + options, + ); + if (response.data.error !== undefined) + throw new Error(`Error in pcgamingwiki cargo query`); + return response; + } + + private async getPageContent( + pageID: string, + ): Promise { + const searchParams = new URLSearchParams({ + action: "parse", + format: "json", + pageid: pageID, + }); + const res = await this.request(searchParams); + const $ = cheerio.load(res.data.parse.text["*"]); + // get intro based on 'introduction' class + const introductionEle = $(".introduction").first(); + + return { + shortIntro: introductionEle.find("p").first().text(), + introduction: introductionEle.text(), + }; + } + async search(query: string) { const searchParams = new URLSearchParams({ action: "cargoquery", @@ -99,7 +152,7 @@ export class PCGamingWikiProvider implements MetadataProvider { format: "json", }); - const res = await this.request(searchParams); + const res = await this.cargoQuery(searchParams); const mapped = res.data.cargoquery.map((result) => { const game = result.title; @@ -172,7 +225,10 @@ export class PCGamingWikiProvider implements MetadataProvider { format: "json", }); - const res = await this.request(searchParams); + const [res, pageContent] = await Promise.all([ + this.cargoQuery(searchParams), + this.getPageContent(id), + ]); if (res.data.cargoquery.length < 1) throw new Error("Error in pcgamingwiki, no game"); @@ -206,8 +262,8 @@ 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(), @@ -240,9 +296,9 @@ export class PCGamingWikiProvider implements MetadataProvider { format: "json", }); - const res = await this.request(searchParams); + const res = await this.cargoQuery(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++) { diff --git a/yarn.lock b/yarn.lock index 73d1c63..99ff8c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -379,7 +379,7 @@ "@emnapi/wasi-threads" "1.0.1" tslib "^2.4.0" -"@emnapi/runtime@^1.2.0", "@emnapi/runtime@^1.4.0": +"@emnapi/runtime@^1.4.0": version "1.4.0" resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.4.0.tgz#8f509bf1059a5551c8fe829a1c4e91db35fdfbee" integrity sha512-64WYIf4UYcdLnbKn/umDlNjQDSS8AgZrI/R9+x5ilkUVFxXcA1Ebl+gQLc/6mERA4407Xof0R7wEyEuj091CVw== @@ -671,119 +671,6 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.2.tgz#1860473de7dfa1546767448f333db80cb0ff2161" integrity sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ== -"@img/sharp-darwin-arm64@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz#ef5b5a07862805f1e8145a377c8ba6e98813ca08" - integrity sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ== - optionalDependencies: - "@img/sharp-libvips-darwin-arm64" "1.0.4" - -"@img/sharp-darwin-x64@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz#e03d3451cd9e664faa72948cc70a403ea4063d61" - integrity sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q== - optionalDependencies: - "@img/sharp-libvips-darwin-x64" "1.0.4" - -"@img/sharp-libvips-darwin-arm64@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz#447c5026700c01a993c7804eb8af5f6e9868c07f" - integrity sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg== - -"@img/sharp-libvips-darwin-x64@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz#e0456f8f7c623f9dbfbdc77383caa72281d86062" - integrity sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ== - -"@img/sharp-libvips-linux-arm64@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz#979b1c66c9a91f7ff2893556ef267f90ebe51704" - integrity sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA== - -"@img/sharp-libvips-linux-arm@1.0.5": - version "1.0.5" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz#99f922d4e15216ec205dcb6891b721bfd2884197" - integrity sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g== - -"@img/sharp-libvips-linux-s390x@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz#f8a5eb1f374a082f72b3f45e2fb25b8118a8a5ce" - integrity sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA== - -"@img/sharp-libvips-linux-x64@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz#d4c4619cdd157774906e15770ee119931c7ef5e0" - integrity sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw== - -"@img/sharp-libvips-linuxmusl-arm64@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz#166778da0f48dd2bded1fa3033cee6b588f0d5d5" - integrity sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA== - -"@img/sharp-libvips-linuxmusl-x64@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz#93794e4d7720b077fcad3e02982f2f1c246751ff" - integrity sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw== - -"@img/sharp-linux-arm64@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz#edb0697e7a8279c9fc829a60fc35644c4839bb22" - integrity sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA== - optionalDependencies: - "@img/sharp-libvips-linux-arm64" "1.0.4" - -"@img/sharp-linux-arm@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz#422c1a352e7b5832842577dc51602bcd5b6f5eff" - integrity sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ== - optionalDependencies: - "@img/sharp-libvips-linux-arm" "1.0.5" - -"@img/sharp-linux-s390x@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz#f5c077926b48e97e4a04d004dfaf175972059667" - integrity sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q== - optionalDependencies: - "@img/sharp-libvips-linux-s390x" "1.0.4" - -"@img/sharp-linux-x64@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz#d806e0afd71ae6775cc87f0da8f2d03a7c2209cb" - integrity sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA== - optionalDependencies: - "@img/sharp-libvips-linux-x64" "1.0.4" - -"@img/sharp-linuxmusl-arm64@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz#252975b915894fb315af5deea174651e208d3d6b" - integrity sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g== - optionalDependencies: - "@img/sharp-libvips-linuxmusl-arm64" "1.0.4" - -"@img/sharp-linuxmusl-x64@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz#3f4609ac5d8ef8ec7dadee80b560961a60fd4f48" - integrity sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw== - optionalDependencies: - "@img/sharp-libvips-linuxmusl-x64" "1.0.4" - -"@img/sharp-wasm32@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz#6f44f3283069d935bb5ca5813153572f3e6f61a1" - integrity sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg== - dependencies: - "@emnapi/runtime" "^1.2.0" - -"@img/sharp-win32-ia32@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz#1a0c839a40c5351e9885628c85f2e5dfd02b52a9" - integrity sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ== - -"@img/sharp-win32-x64@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz#56f00962ff0c4e0eb93d34a047d29fa995e3e342" - integrity sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg== - "@ioredis/commands@^1.1.1": version "1.2.0" resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11" @@ -2805,6 +2692,35 @@ character-entities@^2.0.0: resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-2.0.2.tgz#2d09c2e72cd9523076ccb21157dff66ad43fcc22" integrity sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ== +cheerio-select@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-2.1.0.tgz#4d8673286b8126ca2a8e42740d5e3c4884ae21b4" + integrity sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g== + dependencies: + boolbase "^1.0.0" + css-select "^5.1.0" + css-what "^6.1.0" + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.0.1" + +cheerio@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0.tgz#1ede4895a82f26e8af71009f961a9b8cb60d6a81" + integrity sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww== + dependencies: + cheerio-select "^2.1.0" + dom-serializer "^2.0.0" + domhandler "^5.0.3" + domutils "^3.1.0" + encoding-sniffer "^0.2.0" + htmlparser2 "^9.1.0" + parse5 "^7.1.2" + parse5-htmlparser2-tree-adapter "^7.0.0" + parse5-parser-stream "^7.1.2" + undici "^6.19.5" + whatwg-mimetype "^4.0.0" + chokidar@^4.0.0, chokidar@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" @@ -3344,7 +3260,7 @@ domhandler@^5.0.2, domhandler@^5.0.3: dependencies: domelementtype "^2.3.0" -domutils@^3.0.1: +domutils@^3.0.1, domutils@^3.1.0: version "3.2.2" resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78" integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw== @@ -3414,6 +3330,14 @@ encodeurl@~2.0.0: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== +encoding-sniffer@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz#799569d66d443babe82af18c9f403498365ef1d5" + integrity sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg== + dependencies: + iconv-lite "^0.6.3" + whatwg-encoding "^3.1.1" + end-of-stream@^1.1.0, end-of-stream@^1.4.1: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -3434,6 +3358,11 @@ entities@^4.2.0, entities@^4.5.0: resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== +entities@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-6.0.0.tgz#09c9e29cb79b0a6459a9b9db9efb418ac5bb8e51" + integrity sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw== + error-stack-parser-es@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz#e6a1655dd12f39bb3a85bf4c7088187d78740327" @@ -4256,6 +4185,16 @@ hosted-git-info@^7.0.0: dependencies: lru-cache "^10.0.1" +htmlparser2@^9.1.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-9.1.0.tgz#cdb498d8a75a51f739b61d3f718136c369bc8c23" + integrity sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.1.0" + entities "^4.5.0" + http-errors@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" @@ -4290,6 +4229,13 @@ human-signals@^5.0.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-5.0.0.tgz#42665a284f9ae0dade3ba41ebc37eb4b852f3a28" integrity sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ== +iconv-lite@0.6.3, iconv-lite@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + ieee754@^1.1.13, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -5760,6 +5706,28 @@ parse-url@^9.2.0: "@types/parse-path" "^7.0.0" parse-path "^7.0.0" +parse5-htmlparser2-tree-adapter@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz#b5a806548ed893a43e24ccb42fbb78069311e81b" + integrity sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g== + dependencies: + domhandler "^5.0.3" + parse5 "^7.0.0" + +parse5-parser-stream@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz#d7c20eadc37968d272e2c02660fff92dd27e60e1" + integrity sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow== + dependencies: + parse5 "^7.0.0" + +parse5@^7.0.0, parse5@^7.1.2: + version "7.3.0" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.3.0.tgz#d7e224fa72399c7a175099f45fc2ad024b05ec05" + integrity sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw== + dependencies: + entities "^6.0.0" + parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" @@ -6455,6 +6423,11 @@ safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@~5.2.0: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== +"safer-buffer@>= 2.1.2 < 3.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + sass@^1.79.4: version "1.86.0" resolved "https://registry.yarnpkg.com/sass/-/sass-1.86.0.tgz#f49464fb6237a903a93f4e8760ef6e37a5030114" @@ -6552,35 +6525,6 @@ sharp@^0.32.6: tar-fs "^3.0.4" tunnel-agent "^0.6.0" -sharp@^0.33.5: - version "0.33.5" - resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.33.5.tgz#13e0e4130cc309d6a9497596715240b2ec0c594e" - integrity sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw== - dependencies: - color "^4.2.3" - detect-libc "^2.0.3" - semver "^7.6.3" - optionalDependencies: - "@img/sharp-darwin-arm64" "0.33.5" - "@img/sharp-darwin-x64" "0.33.5" - "@img/sharp-libvips-darwin-arm64" "1.0.4" - "@img/sharp-libvips-darwin-x64" "1.0.4" - "@img/sharp-libvips-linux-arm" "1.0.5" - "@img/sharp-libvips-linux-arm64" "1.0.4" - "@img/sharp-libvips-linux-s390x" "1.0.4" - "@img/sharp-libvips-linux-x64" "1.0.4" - "@img/sharp-libvips-linuxmusl-arm64" "1.0.4" - "@img/sharp-libvips-linuxmusl-x64" "1.0.4" - "@img/sharp-linux-arm" "0.33.5" - "@img/sharp-linux-arm64" "0.33.5" - "@img/sharp-linux-s390x" "0.33.5" - "@img/sharp-linux-x64" "0.33.5" - "@img/sharp-linuxmusl-arm64" "0.33.5" - "@img/sharp-linuxmusl-x64" "0.33.5" - "@img/sharp-wasm32" "0.33.5" - "@img/sharp-win32-ia32" "0.33.5" - "@img/sharp-win32-x64" "0.33.5" - shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -7168,6 +7112,11 @@ undici-types@~6.20.0: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433" integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== +undici@^6.19.5: + version "6.21.2" + resolved "https://registry.yarnpkg.com/undici/-/undici-6.21.2.tgz#49c5884e8f9039c65a89ee9018ef3c8e2f1f4928" + integrity sha512-uROZWze0R0itiAKVPsYhFov9LxrPMHLMEQFszeI2gCN6bnIIZ8twzBCJcN2LJrBBLfrP0t1FW0g+JmKVl8Vk1g== + unenv@^2.0.0-rc.15: version "2.0.0-rc.15" resolved "https://registry.yarnpkg.com/unenv/-/unenv-2.0.0-rc.15.tgz#7fe427b6634f00bda1ade4fecdbc6b2dd7af63be" @@ -7581,6 +7530,18 @@ webpack-virtual-modules@^0.6.2: resolved "https://registry.yarnpkg.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz#057faa9065c8acf48f24cb57ac0e77739ab9a7e8" integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ== +whatwg-encoding@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz#d0f4ef769905d426e1688f3e34381a99b60b76e5" + integrity sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ== + dependencies: + iconv-lite "0.6.3" + +whatwg-mimetype@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz#bc1bf94a985dc50388d54a9258ac405c3ca2fc0a" + integrity sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg== + whatwg-url@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" From dad21617541c78233b01fd235e2c0c56993f1fb8 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Sun, 11 May 2025 12:52:00 -0400 Subject: [PATCH 06/25] feat: games now have tag support --- .../migration.sql | 16 ++++ .../api/v1/admin/game/image/index.delete.ts | 6 +- server/internal/metadata/giantbomb.ts | 2 + server/internal/metadata/igdb.ts | 3 + server/internal/metadata/index.ts | 28 ++++++ server/internal/metadata/manual.ts | 1 + server/internal/metadata/pcgamingwiki.ts | 88 +++++++++++++++---- server/internal/metadata/types.d.ts | 2 + 8 files changed, 124 insertions(+), 22 deletions(-) create mode 100644 prisma/migrations/20250511154134_add_tags_to_games/migration.sql diff --git a/prisma/migrations/20250511154134_add_tags_to_games/migration.sql b/prisma/migrations/20250511154134_add_tags_to_games/migration.sql new file mode 100644 index 0000000..fb10131 --- /dev/null +++ b/prisma/migrations/20250511154134_add_tags_to_games/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "_GameToTag" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_GameToTag_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE INDEX "_GameToTag_B_index" ON "_GameToTag"("B"); + +-- AddForeignKey +ALTER TABLE "_GameToTag" ADD CONSTRAINT "_GameToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Game"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_GameToTag" ADD CONSTRAINT "_GameToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/api/v1/admin/game/image/index.delete.ts b/server/api/v1/admin/game/image/index.delete.ts index 9d9754e..9ef00ac 100644 --- a/server/api/v1/admin/game/image/index.delete.ts +++ b/server/api/v1/admin/game/image/index.delete.ts @@ -3,18 +3,18 @@ import prisma from "~/server/internal/db/database"; import objectHandler from "~/server/internal/objects"; import { type } from "arktype"; -const ModifyGameImage = type({ +const DeleteGameImage = type({ gameId: "string", imageId: "string", }); export default defineEventHandler<{ - body: typeof ModifyGameImage.infer; + body: typeof DeleteGameImage.infer; }>(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, ["game:image:delete"]); if (!allowed) throw createError({ statusCode: 403 }); - const body = ModifyGameImage(await readBody(h3)); + const body = DeleteGameImage(await readBody(h3)); if (body instanceof type.errors) { // hover out.summary to see validation errors console.error(body.summary); diff --git a/server/internal/metadata/giantbomb.ts b/server/internal/metadata/giantbomb.ts index 0a9922d..2441aa4 100644 --- a/server/internal/metadata/giantbomb.ts +++ b/server/internal/metadata/giantbomb.ts @@ -205,6 +205,8 @@ export class GiantBombProvider implements MetadataProvider { description: longDescription, released: releaseDate, + tags: [], + reviewCount: 0, reviewRating: 0, diff --git a/server/internal/metadata/igdb.ts b/server/internal/metadata/igdb.ts index 1970d89..22ae57e 100644 --- a/server/internal/metadata/igdb.ts +++ b/server/internal/metadata/igdb.ts @@ -358,6 +358,9 @@ export class IGDBProvider implements MetadataProvider { publishers: [], developers: [], + // TODO: support tags + tags: [], + icon, bannerId: banner, coverId: icon, diff --git a/server/internal/metadata/index.ts b/server/internal/metadata/index.ts index 9c1f03f..f50e8e4 100644 --- a/server/internal/metadata/index.ts +++ b/server/internal/metadata/index.ts @@ -110,6 +110,30 @@ 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; + } + async createGame( result: InternalGameMetadataResult, libraryBasePath: string, @@ -173,6 +197,10 @@ export class MetadataHandler { connect: metadata.developers, }, + tags: { + connectOrCreate: this.parseTags(metadata.tags), + }, + libraryBasePath, }, }); diff --git a/server/internal/metadata/manual.ts b/server/internal/metadata/manual.ts index fb3786c..d7b00dc 100644 --- a/server/internal/metadata/manual.ts +++ b/server/internal/metadata/manual.ts @@ -33,6 +33,7 @@ export class ManualMetadataProvider implements MetadataProvider { released: new Date(), publishers: [], developers: [], + tags: [], reviewCount: 0, reviewRating: 0, diff --git a/server/internal/metadata/pcgamingwiki.ts b/server/internal/metadata/pcgamingwiki.ts index 504c617..489e41a 100644 --- a/server/internal/metadata/pcgamingwiki.ts +++ b/server/internal/metadata/pcgamingwiki.ts @@ -48,12 +48,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 { @@ -78,6 +85,10 @@ interface PCGamingWikiCargoResult { }; } +type StringArrayKeys = { + [K in keyof T]: T[K] extends string | string[] | null ? K : never; +}[keyof T]; + // 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 { @@ -135,6 +146,8 @@ export class PCGamingWikiProvider implements MetadataProvider { 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(); return { shortIntro: introductionEle.find("p").first().text(), @@ -175,20 +188,33 @@ export class PCGamingWikiProvider implements MetadataProvider { } /** - * 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 + 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(); + }; + + // input can provides the string as a list // ie: "Company:Digerati Distribution,Company:Greylock Studio" - const items = companyStr.split(","); + // or as an array, sometimes the array has empty values - items.forEach((item) => { - // remove the `Company:` and trim and whitespace - results.push(item.replace("Company:", "").trim()); - }); + 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; } @@ -209,6 +235,28 @@ export class PCGamingWikiProvider implements MetadataProvider { return websiteStr.replaceAll(/\[|]/g, "").split(" ")[0] ?? ""; } + private compileTags(game: PCGamingWikiGame): string[] { + const results: string[] = []; + + const properties: StringArrayKeys[] = [ + "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, @@ -220,7 +268,7 @@ 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", }); @@ -236,7 +284,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; @@ -246,7 +294,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; @@ -268,6 +316,8 @@ export class PCGamingWikiProvider implements MetadataProvider { ? DateTime.fromISO(game.Released.split(";")[0]).toJSDate() : new Date(), + tags: this.compileTags(game), + reviewCount: 0, reviewRating: 0, @@ -305,7 +355,7 @@ export class PCGamingWikiProvider implements MetadataProvider { 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, diff --git a/server/internal/metadata/types.d.ts b/server/internal/metadata/types.d.ts index 22f30f8..b627041 100644 --- a/server/internal/metadata/types.d.ts +++ b/server/internal/metadata/types.d.ts @@ -30,6 +30,8 @@ export interface GameMetadata { publishers: Company[]; developers: Company[]; + tags: string[]; + reviewCount: number; reviewRating: number; From a101ff07c4be65826f87b2a06dd36b012d0647b6 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Wed, 14 May 2025 15:40:55 -0400 Subject: [PATCH 07/25] fix: allow notification nonce reuse per user --- .../migration.sql | 11 +++++++++++ prisma/models/user.prisma | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20250514193830_allow_notification_nonce_reuse_per_user/migration.sql diff --git a/prisma/migrations/20250514193830_allow_notification_nonce_reuse_per_user/migration.sql b/prisma/migrations/20250514193830_allow_notification_nonce_reuse_per_user/migration.sql new file mode 100644 index 0000000..035b237 --- /dev/null +++ b/prisma/migrations/20250514193830_allow_notification_nonce_reuse_per_user/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[userId,nonce]` on the table `Notification` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropIndex +DROP INDEX "Notification_nonce_key"; + +-- CreateIndex +CREATE UNIQUE INDEX "Notification_userId_nonce_key" ON "Notification"("userId", "nonce"); diff --git a/prisma/models/user.prisma b/prisma/models/user.prisma index 20f1c60..0b4f272 100644 --- a/prisma/models/user.prisma +++ b/prisma/models/user.prisma @@ -24,7 +24,7 @@ model User { model Notification { id String @id @default(uuid()) - nonce String? @unique + nonce String? userId String user User @relation(fields: [userId], references: [id]) @@ -35,4 +35,6 @@ model Notification { actions String[] read Boolean @default(false) + + @@unique([userId, nonce]) } From b03349671012e693bf238b4cabc23ca4c3597924 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Wed, 14 May 2025 16:07:25 -0400 Subject: [PATCH 08/25] feat: update checker based gh releases --- nuxt.config.ts | 2 +- package.json | 2 + server/h3.d.ts | 4 + server/internal/config/sys-conf.ts | 14 +++ server/internal/metadata/index.ts | 3 +- server/internal/notifications/index.ts | 36 ++++++- server/plugins/tasks.ts | 3 + server/tasks/check/update.ts | 143 +++++++++++++++++++++++++ yarn.lock | 5 + 9 files changed, 205 insertions(+), 7 deletions(-) create mode 100644 server/tasks/check/update.ts diff --git a/nuxt.config.ts b/nuxt.config.ts index b531bcd..96b9415 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,6 +1,6 @@ import tailwindcss from "@tailwindcss/vite"; -const dropVersion = "0.3"; +const dropVersion = "v0.3.0"; // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ diff --git a/package.json b/package.json index 398a063..b9a52f9 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "nuxt": "^3.16.2", "nuxt-security": "2.2.0", "prisma": "^6.7.0", + "semver": "^7.7.1", "stream-mime-type": "^2.0.0", "turndown": "^7.2.0", "unstorage": "^1.15.0", @@ -53,6 +54,7 @@ "@types/bcryptjs": "^3.0.0", "@types/luxon": "^3.6.2", "@types/node": "^22.13.16", + "@types/semver": "^7.7.0", "@types/turndown": "^5.0.5", "autoprefixer": "^10.4.20", "eslint": "^9.24.0", diff --git a/server/h3.d.ts b/server/h3.d.ts index 76ff537..67eced1 100644 --- a/server/h3.d.ts +++ b/server/h3.d.ts @@ -1 +1,5 @@ export type MinimumRequestObject = { headers: Headers }; + +export type TaskReturn = + | { success: true; data: T; error?: never } + | { success: false; data?: never; error: { message: string } }; diff --git a/server/internal/config/sys-conf.ts b/server/internal/config/sys-conf.ts index 306d81e..560a661 100644 --- a/server/internal/config/sys-conf.ts +++ b/server/internal/config/sys-conf.ts @@ -1,6 +1,12 @@ class SystemConfig { private libraryFolder = process.env.LIBRARY ?? "./.data/library"; private dataFolder = process.env.DATA ?? "./.data/data"; + private dropVersion = "v0.3.0"; + private checkForUpdates = + process.env.CHECK_FOR_UPDATES !== undefined && + process.env.CHECK_FOR_UPDATES.toLocaleLowerCase() === "true" + ? true + : false; getLibraryFolder() { return this.libraryFolder; @@ -9,6 +15,14 @@ class SystemConfig { getDataFolder() { return this.dataFolder; } + + getDropVersion() { + return this.dropVersion; + } + + shouldCheckForUpdates() { + return this.checkForUpdates; + } } export const systemConfig = new SystemConfig(); diff --git a/server/internal/metadata/index.ts b/server/internal/metadata/index.ts index f50e8e4..8721459 100644 --- a/server/internal/metadata/index.ts +++ b/server/internal/metadata/index.ts @@ -10,6 +10,7 @@ import type { } from "./types"; import { ObjectTransactionalHandler } from "../objects/transactional"; import { PriorityListIndexed } from "../utils/prioritylist"; +import { systemConfig } from "../config/sys-conf"; export class MissingMetadataProviderConfig extends Error { private providerName: string; @@ -25,7 +26,7 @@ export class MissingMetadataProviderConfig extends Error { } // TODO: add useragent to all outbound api calls (best practice) -export const DropUserAgent = "Drop/0.2"; +export const DropUserAgent = `Drop/${systemConfig.getDropVersion()}`; export abstract class MetadataProvider { abstract name(): string; diff --git a/server/internal/notifications/index.ts b/server/internal/notifications/index.ts index 930f317..67570dc 100644 --- a/server/internal/notifications/index.ts +++ b/server/internal/notifications/index.ts @@ -9,6 +9,7 @@ Design goals: import type { Notification } from "~/prisma/client"; import prisma from "../db/database"; +// TODO: document notification action format export type NotificationCreateArgs = Pick< Notification, "title" | "description" | "actions" | "nonce" @@ -61,14 +62,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, }, }); @@ -84,13 +89,34 @@ class NotificationSystem { }, }); + const res: Promise[] = []; for (const user of users) { - await this.push(user.id, notificationCreateArgs); + res.push(this.push(user.id, notificationCreateArgs)); } + // wait for all notifications to pass + await Promise.all(res); } async systemPush(notificationCreateArgs: NotificationCreateArgs) { - return await this.push("system", notificationCreateArgs); + await this.push("system", notificationCreateArgs); + } + + async pushAllAdmins(notificationCreateArgs: NotificationCreateArgs) { + const users = await prisma.user.findMany({ + where: { + admin: true, + }, + select: { + id: true, + }, + }); + + const res: Promise[] = []; + for (const user of users) { + res.push(this.push(user.id, notificationCreateArgs)); + } + // wait for all notifications to pass + await Promise.all(res); } } diff --git a/server/plugins/tasks.ts b/server/plugins/tasks.ts index 3c8dde4..a822007 100644 --- a/server/plugins/tasks.ts +++ b/server/plugins/tasks.ts @@ -3,5 +3,8 @@ export default defineNitroPlugin(async (_nitro) => { await Promise.all([ runTask("cleanup:invitations"), runTask("cleanup:sessions"), + // TODO: maybe implement some sort of rate limit thing to prevent this from calling github api a bunch in the event of crashloop or whatever? + // probably will require custom task scheduler for object cleanup anyway, so something to thing about + runTask("check:update"), ]); }); diff --git a/server/tasks/check/update.ts b/server/tasks/check/update.ts new file mode 100644 index 0000000..96de84d --- /dev/null +++ b/server/tasks/check/update.ts @@ -0,0 +1,143 @@ +import { type } from "arktype"; +import { systemConfig } from "../../internal/config/sys-conf"; +import * as semver from "semver"; +import type { TaskReturn } from "../../h3"; +import notificationSystem from "../../internal/notifications"; + +const latestRelease = type({ + url: "string", // api url for specific release + html_url: "string", // user facing url + id: "number", // release id + tag_name: "string", // tag used for release + name: "string", // release name + draft: "boolean", + prerelease: "boolean", + created_at: "string", + published_at: "string", +}); + +export default defineTask({ + meta: { + name: "check:update", + }, + async run() { + if (systemConfig.shouldCheckForUpdates()) { + console.log("[Task check:update]: Checking for update"); + try { + const response = await fetch( + "https://api.github.com/repos/Drop-OSS/drop/releases/latest", + ); + + if (!response.ok) { + console.log("[Task check:update]: Failed to check for update", { + status: response.status, + body: response.body, + }); + + return { + result: { + success: false, + error: { + message: "" + response.status, + }, + }, + }; + } + + const resJson = await response.json(); + const body = latestRelease(resJson); + if (body instanceof type.errors) { + console.error(body.summary); + console.log("GitHub Api response", resJson); + return { + result: { + success: false, + error: { + message: body.summary, + }, + }, + }; + } + + // const currVerStr = systemConfig.getDropVersion() + const currVerStr = "v0.1"; + + const latestVer = semver.coerce(body.tag_name); + const currVer = semver.coerce(currVerStr); + if (latestVer === null) { + const msg = "Github Api returned invalid semver tag"; + console.log("[Task check:update]:", msg); + return { + result: { + success: false, + error: { + message: msg, + }, + }, + }; + } else if (currVer === null) { + const msg = "Drop provided a invalid semver tag"; + console.log("[Task check:update]:", msg); + return { + result: { + success: false, + error: { + message: msg, + }, + }, + }; + } + + if (semver.gt(latestVer, currVer)) { + console.log("[Task check:update]: Update available"); + notificationSystem.pushAllAdmins({ + nonce: `drop-update-available-${currVer}-to-${latestVer}`, + title: `Update available to v${latestVer}`, + description: `A new version of Drop is available v${latestVer}`, + actions: [`View|${body.html_url}`], + }); + } else { + console.log("[Task check:update]: no update available"); + } + + console.log("[Task check:update]: Done"); + } catch (e) { + console.error(e); + if (typeof e === "string") { + return { + result: { + success: false, + error: { + message: e, + }, + }, + }; + } else if (e instanceof Error) { + return { + result: { + success: false, + error: { + message: e.message, + }, + }, + }; + } + + return { + result: { + success: false, + error: { + message: "unknown cause, please check console", + }, + }, + }; + } + } + return { + result: { + success: true, + data: undefined, + }, + }; + }, +}); diff --git a/yarn.lock b/yarn.lock index 99ff8c7..634ac27 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1830,6 +1830,11 @@ resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.2.tgz#97d26e00cd4a0423b4af620abecf3e6f442b7975" integrity sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q== +"@types/semver@^7.7.0": + version "7.7.0" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.7.0.tgz#64c441bdae033b378b6eef7d0c3d77c329b9378e" + integrity sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA== + "@types/turndown@^5.0.5": version "5.0.5" resolved "https://registry.yarnpkg.com/@types/turndown/-/turndown-5.0.5.tgz#614de24fc9ace4d8c0d9483ba81dc8c1976dd26f" From ccdbbcf01cc489b0850c2bde6acd4a44883ae0a8 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Wed, 14 May 2025 16:30:35 -0400 Subject: [PATCH 09/25] fix: editing game image metadata in admin panel --- pages/admin/metadata/games/[id]/index.vue | 13 ++++++------- server/api/v1/admin/game/image/index.delete.ts | 4 +--- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/pages/admin/metadata/games/[id]/index.vue b/pages/admin/metadata/games/[id]/index.vue index 7af908a..9631a7a 100644 --- a/pages/admin/metadata/games/[id]/index.vue +++ b/pages/admin/metadata/games/[id]/index.vue @@ -10,7 +10,8 @@ class="flex flex-col lg:flex-row lg:justify-between items-start lg:items-center gap-2" >
- + +

{{ game.mName }} @@ -285,7 +286,6 @@

- Date: Wed, 14 May 2025 16:51:45 -0400 Subject: [PATCH 10/25] fix: object fs backend not deleting metadata --- server/internal/objects/fsBackend.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/internal/objects/fsBackend.ts b/server/internal/objects/fsBackend.ts index 34e0800..375026f 100644 --- a/server/internal/objects/fsBackend.ts +++ b/server/internal/objects/fsBackend.ts @@ -99,6 +99,9 @@ export class FsObjectBackend extends ObjectBackend { const objectPath = path.join(this.baseObjectPath, id); if (!fs.existsSync(objectPath)) return true; fs.rmSync(objectPath); + const metadataPath = path.join(this.baseMetadataPath, `${id}.json`); + if (!fs.existsSync(metadataPath)) return true; + fs.rmSync(metadataPath); // remove item from cache await this.hashStore.delete(id); return true; @@ -153,6 +156,7 @@ export class FsObjectBackend extends ObjectBackend { await store.save(id, hashResult); return typeof hashResult; } + async listAll(): Promise { return fs.readdirSync(this.baseObjectPath); } From 2cc3f1329c64b5830d1df3030631acc3a096f59c Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Wed, 14 May 2025 17:19:39 -0400 Subject: [PATCH 11/25] feat: fs object metadata cache and validation --- server/internal/objects/fsBackend.ts | 22 ++++++++++++++++++---- server/internal/objects/objectHandler.ts | 15 ++++++++++----- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/server/internal/objects/fsBackend.ts b/server/internal/objects/fsBackend.ts index 375026f..de0ea0a 100644 --- a/server/internal/objects/fsBackend.ts +++ b/server/internal/objects/fsBackend.ts @@ -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"; @@ -8,12 +8,15 @@ 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"); constructor() { super(); @@ -102,17 +105,27 @@ export class FsObjectBackend extends ObjectBackend { const metadataPath = path.join(this.baseMetadataPath, `${id}.json`); if (!fs.existsSync(metadataPath)) return true; fs.rmSync(metadataPath); - // remove item from cache + // remove item from caches + await this.metadataCache.remove(id); await this.hashStore.delete(id); return true; } async fetchMetadata( id: ObjectReference, ): Promise { + 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, @@ -121,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 { diff --git a/server/internal/objects/objectHandler.ts b/server/internal/objects/objectHandler.ts index edbf8ad..81cb11f 100644 --- a/server/internal/objects/objectHandler.ts +++ b/server/internal/objects/objectHandler.ts @@ -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", From 898516b33d3a8ea976620bc885b215f7d4e139ad Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Wed, 14 May 2025 18:27:31 -0400 Subject: [PATCH 12/25] chore: style --- pages/admin/metadata/index.vue | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pages/admin/metadata/index.vue b/pages/admin/metadata/index.vue index 44c8d15..72dab31 100644 --- a/pages/admin/metadata/index.vue +++ b/pages/admin/metadata/index.vue @@ -20,7 +20,8 @@ to="/admin/metadata/games" class="transition group aspect-[3/2] flex flex-col justify-center items-center rounded-lg bg-zinc-950 hover:bg-zinc-950/50 shadow" > - GAMES @@ -28,7 +29,8 @@ to="/admin/metadata/companies" class="transition group aspect-[3/2] flex flex-col justify-center items-center rounded-lg bg-zinc-950 hover:bg-zinc-950/50 shadow" > - Companies From 56e1ba64ed4865cfaf581d9e62d1ca4286ed39e8 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Wed, 14 May 2025 19:54:06 -0400 Subject: [PATCH 13/25] fix: check update not using drop's correct version --- server/tasks/check/update.ts | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/server/tasks/check/update.ts b/server/tasks/check/update.ts index 96de84d..6fbaa80 100644 --- a/server/tasks/check/update.ts +++ b/server/tasks/check/update.ts @@ -23,11 +23,28 @@ export default defineTask({ async run() { if (systemConfig.shouldCheckForUpdates()) { console.log("[Task check:update]: Checking for update"); + + const currVerStr = systemConfig.getDropVersion(); + const currVer = semver.coerce(currVerStr); + if (currVer === null) { + const msg = "Drop provided a invalid semver tag"; + console.log("[Task check:update]:", msg); + return { + result: { + success: false, + error: { + message: msg, + }, + }, + }; + } + try { const response = await fetch( "https://api.github.com/repos/Drop-OSS/drop/releases/latest", ); + // if response failed somehow if (!response.ok) { console.log("[Task check:update]: Failed to check for update", { status: response.status, @@ -44,6 +61,7 @@ export default defineTask({ }; } + // parse and validate response const resJson = await response.json(); const body = latestRelease(resJson); if (body instanceof type.errors) { @@ -59,11 +77,8 @@ export default defineTask({ }; } - // const currVerStr = systemConfig.getDropVersion() - const currVerStr = "v0.1"; - + // parse remote version const latestVer = semver.coerce(body.tag_name); - const currVer = semver.coerce(currVerStr); if (latestVer === null) { const msg = "Github Api returned invalid semver tag"; console.log("[Task check:update]:", msg); @@ -75,19 +90,9 @@ export default defineTask({ }, }, }; - } else if (currVer === null) { - const msg = "Drop provided a invalid semver tag"; - console.log("[Task check:update]:", msg); - return { - result: { - success: false, - error: { - message: msg, - }, - }, - }; } + // check if is newer version if (semver.gt(latestVer, currVer)) { console.log("[Task check:update]: Update available"); notificationSystem.pushAllAdmins({ From 6df2ef1740965c9c1f37d396b7a814e27d2c4d72 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Wed, 14 May 2025 21:38:16 -0400 Subject: [PATCH 14/25] fix: igdb assuming certain values always exist --- server/internal/metadata/igdb.ts | 56 +++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/server/internal/metadata/igdb.ts b/server/internal/metadata/igdb.ts index 22ae57e..075ad38 100644 --- a/server/internal/metadata/igdb.ts +++ b/server/internal/metadata/igdb.ts @@ -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; @@ -68,8 +69,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 +156,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 +169,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,12 +239,19 @@ 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(type, body); let result = ""; response.forEach((cover) => { + console.log(cover); + if (cover.url.startsWith("https:")) { result = cover.url; } else { @@ -244,6 +259,7 @@ export class IGDBProvider implements MetadataProvider { result = `https:${cover.url}`; } }); + return result; } @@ -276,12 +292,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 = "/wallpapers/error-wallpaper.jpg"; + } + + 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 + ? DateTime.now().year + : DateTime.fromSeconds(firstReleaseDate).year, }); } @@ -297,7 +325,14 @@ export class IGDBProvider implements MetadataProvider { const response = await this.request("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,14 +378,17 @@ 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 + ? DateTime.now().toJSDate() + : DateTime.fromSeconds(firstReleaseDate).toJSDate(), reviewCount: response[i]?.total_rating_count ?? 0, reviewRating: (response[i]?.total_rating ?? 0) / 100, From bea26a9a6da1066cf86bceb132dd6f171b544505 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Wed, 14 May 2025 21:40:25 -0400 Subject: [PATCH 15/25] feat: game metadata rating support --- pages/store/[id]/index.vue | 7 +- prisma/models/content.prisma | 23 +++- server/internal/metadata/giantbomb.ts | 3 +- server/internal/metadata/igdb.ts | 11 +- server/internal/metadata/index.ts | 37 +++++- server/internal/metadata/manual.ts | 3 +- server/internal/metadata/pcgamingwiki.ts | 138 ++++++++++++++++++++--- server/internal/metadata/types.d.ts | 14 ++- 8 files changed, 203 insertions(+), 33 deletions(-) diff --git a/pages/store/[id]/index.vue b/pages/store/[id]/index.vue index cc6943f..48f2ec7 100644 --- a/pages/store/[id]/index.vue +++ b/pages/store/[id]/index.vue @@ -103,9 +103,7 @@ 'w-4 h-4', ]" /> - ({{ game.mReviewCount }} reviews) + ({{ 0 }} reviews) @@ -220,7 +218,8 @@ const platforms = game.versions .flat() .filter((e, i, u) => u.indexOf(e) === i); -const rating = Math.round(game.mReviewRating * 5); +// const rating = Math.round(game.mReviewRating * 5); +const rating = Math.round(0 * 5); const ratingArray = Array(5) .fill(null) .map((_, i) => i + 1 <= rating); diff --git a/prisma/models/content.prisma b/prisma/models/content.prisma index ee3b267..4631b06 100644 --- a/prisma/models/content.prisma +++ b/prisma/models/content.prisma @@ -3,6 +3,8 @@ enum MetadataSource { GiantBomb PCGamingWiki IGDB + Metacritic + OpenCritic } model Game { @@ -19,8 +21,7 @@ model Game { mDescription String // Supports markdown mReleased DateTime // When the game was released - mReviewCount Int - mReviewRating Float // 0 to 1 + ratings GameRating[] mIconObjectId String // linked to objects in s3 mBannerObjectId String // linked to objects in s3 @@ -42,6 +43,24 @@ model Game { @@unique([metadataSource, metadataId], name: "metadataKey") } +model GameRating { + id String @id @default(uuid()) + + metadataSource MetadataSource + metadataId String + created DateTime @default(now()) + + mReviewCount Int + mReviewRating Float // 0 to 1 + + mReviewHref String? + + Game Game? @relation(fields: [gameId], references: [id], onDelete: Cascade) + gameId String? + + @@unique([metadataSource, metadataId], name: "metadataKey") +} + // A particular set of files that relate to the version model GameVersion { gameId String diff --git a/server/internal/metadata/giantbomb.ts b/server/internal/metadata/giantbomb.ts index 2441aa4..312fab5 100644 --- a/server/internal/metadata/giantbomb.ts +++ b/server/internal/metadata/giantbomb.ts @@ -207,8 +207,7 @@ export class GiantBombProvider implements MetadataProvider { tags: [], - reviewCount: 0, - reviewRating: 0, + reviews: [], publishers, developers, diff --git a/server/internal/metadata/igdb.ts b/server/internal/metadata/igdb.ts index 075ad38..57656d7 100644 --- a/server/internal/metadata/igdb.ts +++ b/server/internal/metadata/igdb.ts @@ -390,8 +390,15 @@ export class IGDBProvider implements MetadataProvider { ? DateTime.now().toJSDate() : 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: [], diff --git a/server/internal/metadata/index.ts b/server/internal/metadata/index.ts index 8721459..b62137c 100644 --- a/server/internal/metadata/index.ts +++ b/server/internal/metadata/index.ts @@ -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,6 +7,7 @@ import type { GameMetadataSearchResult, InternalGameMetadataResult, CompanyMetadata, + GameMetadataRating, } from "./types"; import { ObjectTransactionalHandler } from "../objects/transactional"; import { PriorityListIndexed } from "../utils/prioritylist"; @@ -135,6 +136,34 @@ export class MetadataHandler { return results; } + private parseRatings(ratings: GameMetadataRating[]) { + const results: { + where: { + metadataKey: { + metadataId: string; + metadataSource: MetadataSource; + }; + }; + create: Omit; + }[] = []; + + ratings.forEach((r) => { + results.push({ + where: { + metadataKey: { + metadataId: r.metadataId, + metadataSource: r.metadataSource, + }, + }, + create: { + ...r, + }, + }); + }); + + return results; + } + async createGame( result: InternalGameMetadataResult, libraryBasePath: string, @@ -181,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, @@ -198,6 +224,9 @@ export class MetadataHandler { connect: metadata.developers, }, + ratings: { + connectOrCreate: this.parseRatings(metadata.reviews), + }, tags: { connectOrCreate: this.parseTags(metadata.tags), }, diff --git a/server/internal/metadata/manual.ts b/server/internal/metadata/manual.ts index d7b00dc..551faf3 100644 --- a/server/internal/metadata/manual.ts +++ b/server/internal/metadata/manual.ts @@ -34,8 +34,7 @@ export class ManualMetadataProvider implements MetadataProvider { publishers: [], developers: [], tags: [], - reviewCount: 0, - reviewRating: 0, + reviews: [], icon: iconId, coverId: iconId, diff --git a/server/internal/metadata/pcgamingwiki.ts b/server/internal/metadata/pcgamingwiki.ts index 489e41a..251b0fe 100644 --- a/server/internal/metadata/pcgamingwiki.ts +++ b/server/internal/metadata/pcgamingwiki.ts @@ -7,12 +7,14 @@ 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: { @@ -31,11 +33,6 @@ interface PCGamingWikiParseRawPage { }; } -interface PCGamingWikiParsedPage { - shortIntro: string; - introduction: string; -} - interface PCGamingWikiPage { PageID: string; PageName: string; @@ -89,6 +86,10 @@ type StringArrayKeys = { [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 { @@ -115,7 +116,7 @@ export class PCGamingWikiProvider implements MetadataProvider { if (response.status !== 200) throw new Error( - `Error in pcgamingwiki \nStatus Code: ${response.status}`, + `Error in pcgamingwiki \nStatus Code: ${response.status}\n${response.data}`, ); return response; @@ -134,9 +135,13 @@ export class PCGamingWikiProvider implements MetadataProvider { return response; } - private async getPageContent( - pageID: string, - ): Promise { + /** + * 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", @@ -149,9 +154,116 @@ export class PCGamingWikiProvider implements MetadataProvider { // 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)); + } + + console.log(res.data.parse.title, receptionResults); + return { - shortIntro: introductionEle.find("p").first().text(), - introduction: introductionEle.text(), + shortIntro: introductionEle.find("p").first().text().trim(), + introduction: introductionEle.text().trim(), + reception: receptionResults, }; } @@ -318,9 +430,7 @@ export class PCGamingWikiProvider implements MetadataProvider { tags: this.compileTags(game), - reviewCount: 0, - reviewRating: 0, - + reviews: pageContent.reception.filter((v) => typeof v !== "undefined"), publishers, developers, diff --git a/server/internal/metadata/types.d.ts b/server/internal/metadata/types.d.ts index b627041..b5286a7 100644 --- a/server/internal/metadata/types.d.ts +++ b/server/internal/metadata/types.d.ts @@ -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; @@ -32,8 +41,7 @@ export interface GameMetadata { tags: string[]; - reviewCount: number; - reviewRating: number; + reviews: GameMetadataRating[]; // Created with another utility function icon: ObjectReference; From 9bf36c8737e40a23e8e2d386937d0a471c7bd902 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Wed, 14 May 2025 21:52:32 -0400 Subject: [PATCH 16/25] feat: pcgamgingwiki desc in searchstub --- server/internal/metadata/pcgamingwiki.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/server/internal/metadata/pcgamingwiki.ts b/server/internal/metadata/pcgamingwiki.ts index 251b0fe..c58ae36 100644 --- a/server/internal/metadata/pcgamingwiki.ts +++ b/server/internal/metadata/pcgamingwiki.ts @@ -258,8 +258,6 @@ export class PCGamingWikiProvider implements MetadataProvider { receptionResults.push(getRating(MetadataSource.OpenCritic)); } - console.log(res.data.parse.title, receptionResults); - return { shortIntro: introductionEle.find("p").first().text().trim(), introduction: introductionEle.text().trim(), @@ -277,26 +275,28 @@ export class PCGamingWikiProvider implements MetadataProvider { format: "json", }); - const res = await this.cargoQuery(searchParams); + const response = + await this.cargoQuery(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; } /** From a34f10d9b9033230bbfe5ab4526334367241d7e1 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Wed, 14 May 2025 22:03:32 -0400 Subject: [PATCH 17/25] feat: igdb tag support --- server/internal/metadata/igdb.ts | 45 +++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/server/internal/metadata/igdb.ts b/server/internal/metadata/igdb.ts index 57656d7..0fbbedc 100644 --- a/server/internal/metadata/igdb.ts +++ b/server/internal/metadata/igdb.ts @@ -32,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; @@ -250,8 +256,6 @@ export class IGDBProvider implements MetadataProvider { let result = ""; response.forEach((cover) => { - console.log(cover); - if (cover.url.startsWith("https:")) { result = cover.url; } else { @@ -279,6 +283,34 @@ 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("genres", body); + + let result = ""; + + response.forEach((genre) => { + result = genre.name; + }); + + return result; + } + + private async getGenres(genres: IGDBID[] | undefined): Promise { + if (genres === undefined) return []; + + const results: string[] = []; + for (const genre of genres) { + results.push(await this._getGenreInternal(genre)); + } + + console.log(results); + + return results; + } + name() { return "IGDB"; } @@ -297,7 +329,7 @@ export class IGDBProvider implements MetadataProvider { if (cover !== undefined) { icon = await this.getCoverURL(cover); } else { - icon = "/wallpapers/error-wallpaper.jpg"; + icon = ""; } const firstReleaseDate = response[i].first_release_date; @@ -308,7 +340,7 @@ export class IGDBProvider implements MetadataProvider { description: response[i].summary, year: firstReleaseDate === undefined - ? DateTime.now().year + ? 0 : DateTime.fromSeconds(firstReleaseDate).year, }); } @@ -387,7 +419,7 @@ export class IGDBProvider implements MetadataProvider { description: response[i].summary, released: firstReleaseDate === undefined - ? DateTime.now().toJSDate() + ? new Date() : DateTime.fromSeconds(firstReleaseDate).toJSDate(), reviews: [ @@ -403,8 +435,7 @@ export class IGDBProvider implements MetadataProvider { publishers: [], developers: [], - // TODO: support tags - tags: [], + tags: await this.getGenres(response[i].genres), icon, bannerId: banner, From 82b123a345977d35d952abac5307c56745177962 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Wed, 14 May 2025 22:13:53 -0400 Subject: [PATCH 18/25] fix: gamerating model --- .../migration.sql | 41 +++++++++++++++++++ prisma/models/content.prisma | 4 +- 2 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20250515021331_add_game_ratings/migration.sql diff --git a/prisma/migrations/20250515021331_add_game_ratings/migration.sql b/prisma/migrations/20250515021331_add_game_ratings/migration.sql new file mode 100644 index 0000000..52a2f5f --- /dev/null +++ b/prisma/migrations/20250515021331_add_game_ratings/migration.sql @@ -0,0 +1,41 @@ +/* + Warnings: + + - You are about to drop the column `mReviewCount` on the `Game` table. All the data in the column will be lost. + - You are about to drop the column `mReviewRating` on the `Game` table. All the data in the column will be lost. + +*/ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "MetadataSource" ADD VALUE 'Metacritic'; +ALTER TYPE "MetadataSource" ADD VALUE 'OpenCritic'; + +-- AlterTable +ALTER TABLE "Game" DROP COLUMN "mReviewCount", +DROP COLUMN "mReviewRating"; + +-- CreateTable +CREATE TABLE "GameRating" ( + "id" TEXT NOT NULL, + "metadataSource" "MetadataSource" NOT NULL, + "metadataId" TEXT NOT NULL, + "created" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "mReviewCount" INTEGER NOT NULL, + "mReviewRating" DOUBLE PRECISION NOT NULL, + "mReviewHref" TEXT, + "gameId" TEXT NOT NULL, + + CONSTRAINT "GameRating_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "GameRating_metadataSource_metadataId_key" ON "GameRating"("metadataSource", "metadataId"); + +-- AddForeignKey +ALTER TABLE "GameRating" ADD CONSTRAINT "GameRating_gameId_fkey" FOREIGN KEY ("gameId") REFERENCES "Game"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/models/content.prisma b/prisma/models/content.prisma index 4631b06..3ac08b1 100644 --- a/prisma/models/content.prisma +++ b/prisma/models/content.prisma @@ -55,8 +55,8 @@ model GameRating { mReviewHref String? - Game Game? @relation(fields: [gameId], references: [id], onDelete: Cascade) - gameId String? + Game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) + gameId String @@unique([metadataSource, metadataId], name: "metadataKey") } From 9d2aded70f8102a81134f90f0a0dae15b42d86e9 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Wed, 14 May 2025 22:53:09 -0400 Subject: [PATCH 19/25] feat: add acl to notifications not sure if i got all the acls of the different notifications down rn, but it seems to be about right --- .../migration.sql | 2 ++ prisma/models/user.prisma | 3 ++ server/api/v1/client/capability/index.post.ts | 1 + server/api/v1/notifications/index.get.ts | 33 ++++++++++++++++++- server/internal/acls/index.ts | 2 ++ server/internal/library/index.ts | 1 + server/internal/notifications/index.ts | 4 ++- server/tasks/check/update.ts | 1 + 8 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20250515024929_add_required_perms_to_notifications/migration.sql diff --git a/prisma/migrations/20250515024929_add_required_perms_to_notifications/migration.sql b/prisma/migrations/20250515024929_add_required_perms_to_notifications/migration.sql new file mode 100644 index 0000000..0af0368 --- /dev/null +++ b/prisma/migrations/20250515024929_add_required_perms_to_notifications/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Notification" ADD COLUMN "requiredPerms" TEXT[] DEFAULT ARRAY[]::TEXT[]; diff --git a/prisma/models/user.prisma b/prisma/models/user.prisma index 0b4f272..bb08844 100644 --- a/prisma/models/user.prisma +++ b/prisma/models/user.prisma @@ -34,6 +34,9 @@ model Notification { description String actions String[] + // ACL items + requiredPerms String[] @default([]) + read Boolean @default(false) @@unique([userId, nonce]) diff --git a/server/api/v1/client/capability/index.post.ts b/server/api/v1/client/capability/index.post.ts index 0123860..37f5a30 100644 --- a/server/api/v1/client/capability/index.post.ts +++ b/server/api/v1/client/capability/index.post.ts @@ -55,6 +55,7 @@ export default defineClientEventHandler( title: `"${client.name}" can now access ${capability}`, description: `A device called "${client.name}" now has access to your ${capability}.`, actions: ["Review|/account/devices"], + requiredPerms: ["clients:read"], }); return {}; diff --git a/server/api/v1/notifications/index.get.ts b/server/api/v1/notifications/index.get.ts index 8797aa8..98f43c6 100644 --- a/server/api/v1/notifications/index.get.ts +++ b/server/api/v1/notifications/index.get.ts @@ -1,4 +1,4 @@ -import aclManager from "~/server/internal/acls"; +import aclManager, { type SystemACL } from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; export default defineEventHandler(async (h3) => { @@ -22,5 +22,36 @@ export default defineEventHandler(async (h3) => { }, }); + let i = notifications.length; + while (i--) { + const notif = notifications[i]; + + const hasPermsForNotif = await aclManager.allowSystemACL( + h3, + notif.requiredPerms as SystemACL, + ); + + if (!hasPermsForNotif) { + // remove element + console.log( + userId, + "did not have perms to access", + notif.id, + "based on", + notif.requiredPerms, + ); + + notifications.splice(i, 1); + } else { + console.log( + userId, + "had perms to access", + notif.id, + "based on", + notif.requiredPerms, + ); + } + } + return notifications; }); diff --git a/server/internal/acls/index.ts b/server/internal/acls/index.ts index 6f42ddc..8198b28 100644 --- a/server/internal/acls/index.ts +++ b/server/internal/acls/index.ts @@ -70,6 +70,8 @@ const systemACLPrefix = "system:"; export type SystemACL = Array<(typeof systemACLs)[number]>; +export type ValidACLItems = Array; + class ACLManager { private getAuthorizationToken(request: MinimumRequestObject) { const [type, token] = diff --git a/server/internal/library/index.ts b/server/internal/library/index.ts index 6556702..b24925c 100644 --- a/server/internal/library/index.ts +++ b/server/internal/library/index.ts @@ -306,6 +306,7 @@ class LibraryManager { title: `'${game.mName}' ('${versionName}') finished importing.`, description: `Drop finished importing version ${versionName} for ${game.mName}.`, actions: [`View|/admin/library/${gameId}`], + requiredPerms: ["import:game:new"], }); progress(100); diff --git a/server/internal/notifications/index.ts b/server/internal/notifications/index.ts index 67570dc..16b224b 100644 --- a/server/internal/notifications/index.ts +++ b/server/internal/notifications/index.ts @@ -9,10 +9,12 @@ Design goals: import type { Notification } from "~/prisma/client"; import prisma from "../db/database"; +// type Optional = Pick, K> & Omit; + // TODO: document notification action format export type NotificationCreateArgs = Pick< Notification, - "title" | "description" | "actions" | "nonce" + "title" | "description" | "actions" | "nonce" | "requiredPerms" >; class NotificationSystem { diff --git a/server/tasks/check/update.ts b/server/tasks/check/update.ts index 6fbaa80..c0e5ec9 100644 --- a/server/tasks/check/update.ts +++ b/server/tasks/check/update.ts @@ -100,6 +100,7 @@ export default defineTask({ title: `Update available to v${latestVer}`, description: `A new version of Drop is available v${latestVer}`, actions: [`View|${body.html_url}`], + requiredPerms: [""], }); } else { console.log("[Task check:update]: no update available"); From bee3b0c5882a961a355df23aa9711d1389d264a9 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Thu, 15 May 2025 13:45:05 -0400 Subject: [PATCH 20/25] fix: drop update notifications --- server/tasks/check/update.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/tasks/check/update.ts b/server/tasks/check/update.ts index c0e5ec9..4823f74 100644 --- a/server/tasks/check/update.ts +++ b/server/tasks/check/update.ts @@ -95,12 +95,12 @@ export default defineTask({ // check if is newer version if (semver.gt(latestVer, currVer)) { console.log("[Task check:update]: Update available"); - notificationSystem.pushAllAdmins({ + notificationSystem.systemPush({ nonce: `drop-update-available-${currVer}-to-${latestVer}`, title: `Update available to v${latestVer}`, description: `A new version of Drop is available v${latestVer}`, actions: [`View|${body.html_url}`], - requiredPerms: [""], + acls: ["system:notifications:read"], }); } else { console.log("[Task check:update]: no update available"); From 59c3b9b76e4e2747a9a11fb0d26960c946d97b9c Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Thu, 15 May 2025 13:53:05 -0400 Subject: [PATCH 21/25] fix: don't send system notifications to all users --- server/internal/notifications/index.ts | 46 ++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/server/internal/notifications/index.ts b/server/internal/notifications/index.ts index b4a60ba..5780b8f 100644 --- a/server/internal/notifications/index.ts +++ b/server/internal/notifications/index.ts @@ -94,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[] = []; + 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" } }, @@ -102,16 +123,27 @@ class NotificationSystem { }, }); - const res: Promise[] = []; - for (const user of users) { - res.push(this.push(user.id, notificationCreateArgs)); - } - // wait for all notifications to pass - await Promise.all(res); + 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); } } From 831b20d7372786e859fef55f9027916d9b936f30 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Thu, 15 May 2025 14:21:12 -0400 Subject: [PATCH 22/25] fix: remove old requiredPerms field --- .../migration.sql | 2 -- prisma/models/user.prisma | 3 -- server/api/v1/notifications/index.get.ts | 33 +------------------ 3 files changed, 1 insertion(+), 37 deletions(-) delete mode 100644 prisma/migrations/20250515024929_add_required_perms_to_notifications/migration.sql diff --git a/prisma/migrations/20250515024929_add_required_perms_to_notifications/migration.sql b/prisma/migrations/20250515024929_add_required_perms_to_notifications/migration.sql deleted file mode 100644 index 0af0368..0000000 --- a/prisma/migrations/20250515024929_add_required_perms_to_notifications/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "Notification" ADD COLUMN "requiredPerms" TEXT[] DEFAULT ARRAY[]::TEXT[]; diff --git a/prisma/models/user.prisma b/prisma/models/user.prisma index 3445084..d880109 100644 --- a/prisma/models/user.prisma +++ b/prisma/models/user.prisma @@ -35,9 +35,6 @@ model Notification { description String actions String[] - // ACL items - requiredPerms String[] @default([]) - read Boolean @default(false) @@unique([userId, nonce]) diff --git a/server/api/v1/notifications/index.get.ts b/server/api/v1/notifications/index.get.ts index ea81524..982d520 100644 --- a/server/api/v1/notifications/index.get.ts +++ b/server/api/v1/notifications/index.get.ts @@ -1,4 +1,4 @@ -import aclManager, { type SystemACL } from "~/server/internal/acls"; +import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; export default defineEventHandler(async (h3) => { @@ -24,36 +24,5 @@ export default defineEventHandler(async (h3) => { }, }); - let i = notifications.length; - while (i--) { - const notif = notifications[i]; - - const hasPermsForNotif = await aclManager.allowSystemACL( - h3, - notif.requiredPerms as SystemACL, - ); - - if (!hasPermsForNotif) { - // remove element - console.log( - userId, - "did not have perms to access", - notif.id, - "based on", - notif.requiredPerms, - ); - - notifications.splice(i, 1); - } else { - console.log( - userId, - "had perms to access", - notif.id, - "based on", - notif.requiredPerms, - ); - } - } - return notifications; }); From a89c657fe1c1ccfb0f48467efb50168c2ebe6b50 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Thu, 15 May 2025 15:51:35 -0400 Subject: [PATCH 23/25] feat: very basic screenshot api --- prisma/models/content.prisma | 3 ++- .../api/v1/screenshots/[id]/index.delete.ts | 17 ++++++++++++ server/api/v1/screenshots/[id]/index.get.ts | 17 ++++++++++++ .../api/v1/screenshots/game/[id]/index.get.ts | 18 +++++++++++++ .../v1/screenshots/game/[id]/index.post.ts | 27 +++++++++++++++++++ server/api/v1/screenshots/index.get.ts | 11 ++++++++ server/internal/acls/descriptions.ts | 4 +++ server/internal/acls/index.ts | 10 +++++++ server/internal/screenshots/index.ts | 24 ++++++++++++++--- 9 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 server/api/v1/screenshots/[id]/index.delete.ts create mode 100644 server/api/v1/screenshots/[id]/index.get.ts create mode 100644 server/api/v1/screenshots/game/[id]/index.get.ts create mode 100644 server/api/v1/screenshots/game/[id]/index.post.ts create mode 100644 server/api/v1/screenshots/index.get.ts diff --git a/prisma/models/content.prisma b/prisma/models/content.prisma index 3ac08b1..4781ee3 100644 --- a/prisma/models/content.prisma +++ b/prisma/models/content.prisma @@ -116,11 +116,12 @@ model Screenshot { user User @relation(fields: [userId], references: [id], onDelete: Cascade) objectId String - private Boolean @default(true) + private Boolean // if other users can see createdAt DateTime @default(now()) @db.Timestamptz(0) @@index([gameId, userId]) + @@index([userId]) } model Company { diff --git a/server/api/v1/screenshots/[id]/index.delete.ts b/server/api/v1/screenshots/[id]/index.delete.ts new file mode 100644 index 0000000..360dbf4 --- /dev/null +++ b/server/api/v1/screenshots/[id]/index.delete.ts @@ -0,0 +1,17 @@ +// get a specific screenshot +import aclManager from "~/server/internal/acls"; +import screenshotManager from "~/server/internal/screenshots"; + +export default defineEventHandler(async (h3) => { + const userId = await aclManager.getUserIdACL(h3, ["screenshots:delete"]); + if (!userId) throw createError({ statusCode: 403 }); + + const screenshotId = getRouterParam(h3, "id"); + if (!screenshotId) + throw createError({ + statusCode: 400, + statusMessage: "Missing screenshot ID", + }); + + return await screenshotManager.delete(screenshotId); +}); diff --git a/server/api/v1/screenshots/[id]/index.get.ts b/server/api/v1/screenshots/[id]/index.get.ts new file mode 100644 index 0000000..da84f6a --- /dev/null +++ b/server/api/v1/screenshots/[id]/index.get.ts @@ -0,0 +1,17 @@ +// get a specific screenshot +import aclManager from "~/server/internal/acls"; +import screenshotManager from "~/server/internal/screenshots"; + +export default defineEventHandler(async (h3) => { + const userId = await aclManager.getUserIdACL(h3, ["screenshots:read"]); + if (!userId) throw createError({ statusCode: 403 }); + + const screenshotId = getRouterParam(h3, "id"); + if (!screenshotId) + throw createError({ + statusCode: 400, + statusMessage: "Missing screenshot ID", + }); + + return await screenshotManager.get(screenshotId); +}); diff --git a/server/api/v1/screenshots/game/[id]/index.get.ts b/server/api/v1/screenshots/game/[id]/index.get.ts new file mode 100644 index 0000000..71addae --- /dev/null +++ b/server/api/v1/screenshots/game/[id]/index.get.ts @@ -0,0 +1,18 @@ +// get all user screenshots by game +import aclManager from "~/server/internal/acls"; +import screenshotManager from "~/server/internal/screenshots"; + +export default defineEventHandler(async (h3) => { + const userId = await aclManager.getUserIdACL(h3, ["screenshots:read"]); + if (!userId) throw createError({ statusCode: 403 }); + + const gameId = getRouterParam(h3, "id"); + if (!gameId) + throw createError({ + statusCode: 400, + statusMessage: "Missing game ID", + }); + + const results = await screenshotManager.getUserAllByGame(userId, gameId); + return results; +}); diff --git a/server/api/v1/screenshots/game/[id]/index.post.ts b/server/api/v1/screenshots/game/[id]/index.post.ts new file mode 100644 index 0000000..c06b815 --- /dev/null +++ b/server/api/v1/screenshots/game/[id]/index.post.ts @@ -0,0 +1,27 @@ +// create new screenshot +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; +import screenshotManager from "~/server/internal/screenshots"; + +// TODO: make defineClientEventHandler instead? +// only clients will be upload screenshots yea?? +export default defineEventHandler(async (h3) => { + const userId = await aclManager.getUserIdACL(h3, ["screenshots:new"]); + if (!userId) throw createError({ statusCode: 403 }); + + const gameId = getRouterParam(h3, "id"); + if (!gameId) + throw createError({ + statusCode: 400, + statusMessage: "Missing game ID", + }); + + const game = await prisma.game.findUnique({ + where: { id: gameId }, + select: { id: true }, + }); + if (!game) + throw createError({ statusCode: 400, statusMessage: "Invalid game ID" }); + + await screenshotManager.upload(userId, gameId, h3.node.req); +}); diff --git a/server/api/v1/screenshots/index.get.ts b/server/api/v1/screenshots/index.get.ts new file mode 100644 index 0000000..97b9f93 --- /dev/null +++ b/server/api/v1/screenshots/index.get.ts @@ -0,0 +1,11 @@ +// get all user screenshots +import aclManager from "~/server/internal/acls"; +import screenshotManager from "~/server/internal/screenshots"; + +export default defineEventHandler(async (h3) => { + const userId = await aclManager.getUserIdACL(h3, ["screenshots:read"]); + if (!userId) throw createError({ statusCode: 403 }); + + const results = await screenshotManager.getUserAll(userId); + return results; +}); diff --git a/server/internal/acls/descriptions.ts b/server/internal/acls/descriptions.ts index d5ac10a..d9f21a6 100644 --- a/server/internal/acls/descriptions.ts +++ b/server/internal/acls/descriptions.ts @@ -22,6 +22,10 @@ export const userACLDescriptions: ObjectFromList = { "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.", diff --git a/server/internal/acls/index.ts b/server/internal/acls/index.ts index c2570c3..96c3708 100644 --- a/server/internal/acls/index.ts +++ b/server/internal/acls/index.ts @@ -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?"); diff --git a/server/internal/screenshots/index.ts b/server/internal/screenshots/index.ts index e8b4911..295e26d 100644 --- a/server/internal/screenshots/index.ts +++ b/server/internal/screenshots/index.ts @@ -13,7 +13,16 @@ class ScreenshotManager { }); } - async getAllByGame(gameId: string, userId: string) { + async getUserAll(userId: string) { + const results = await prisma.screenshot.findMany({ + where: { + userId, + }, + }); + return results; + } + + async getUserAllByGame(userId: string, gameId: string) { const results = await prisma.screenshot.findMany({ where: { gameId, @@ -31,9 +40,16 @@ class ScreenshotManager { }); } - async upload(gameId: string, userId: string, inputStream: IncomingMessage) { + 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 +59,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, }, }); } From 4fbc7304902932341c21f0f449e250d6e6956ebb Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Thu, 15 May 2025 17:29:43 -0400 Subject: [PATCH 24/25] chore: style --- server/internal/clients/handler.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/internal/clients/handler.ts b/server/internal/clients/handler.ts index ca63f7b..8978f24 100644 --- a/server/internal/clients/handler.ts +++ b/server/internal/clients/handler.ts @@ -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 { From 21eec081ee148a5225489b72c6d69cb67b68aaa5 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Thu, 15 May 2025 18:28:08 -0400 Subject: [PATCH 25/25] fix: missing user check in screenshot api endpoint --- server/api/v1/screenshots/[id]/index.delete.ts | 13 ++++++++++++- server/api/v1/screenshots/[id]/index.get.ts | 12 +++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/server/api/v1/screenshots/[id]/index.delete.ts b/server/api/v1/screenshots/[id]/index.delete.ts index 360dbf4..c09c332 100644 --- a/server/api/v1/screenshots/[id]/index.delete.ts +++ b/server/api/v1/screenshots/[id]/index.delete.ts @@ -13,5 +13,16 @@ export default defineEventHandler(async (h3) => { statusMessage: "Missing screenshot ID", }); - return await screenshotManager.delete(screenshotId); + const result = await screenshotManager.get(screenshotId); + if (!result) + throw createError({ + statusCode: 400, + statusMessage: "Incorrect screenshot ID", + }); + else if (result.userId !== userId) + throw createError({ + statusCode: 403, + }); + + await screenshotManager.delete(screenshotId); }); diff --git a/server/api/v1/screenshots/[id]/index.get.ts b/server/api/v1/screenshots/[id]/index.get.ts index da84f6a..7864a1b 100644 --- a/server/api/v1/screenshots/[id]/index.get.ts +++ b/server/api/v1/screenshots/[id]/index.get.ts @@ -13,5 +13,15 @@ export default defineEventHandler(async (h3) => { statusMessage: "Missing screenshot ID", }); - return await screenshotManager.get(screenshotId); + const result = await screenshotManager.get(screenshotId); + if (!result) + throw createError({ + statusCode: 400, + statusMessage: "Incorrect screenshot ID", + }); + else if (result.userId !== userId) + throw createError({ + statusCode: 403, + }); + return result; });