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/.github/workflows/release.yml b/.github/workflows/release.yml index 2422087..109f033 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,8 +20,33 @@ jobs: uses: actions/checkout@v4 with: submodules: true + fetch-tags: true token: ${{ secrets.GITHUB_TOKEN }} + - name: Get base tag + id: get_base_tag + run: | + BASE_TAG=$(git describe --tags --abbrev=0) + echo "base_tag=$BASE_TAG" >> $GITHUB_OUTPUT + + - name: Determine final tag + id: get_final_tag + run: | + BASE_TAG=${{ steps.get_base_tag.outputs.base_tag }} + TODAY=$(date +'%Y.%m.%d') + + echo "Today will be: $TODAY" + echo "today=$TODAY" >> $GITHUB_OUTPUT + + if [[ "${{ github.event_name }}" == "release" ]]; then + FINAL_TAG="$BASE_TAG" + else + FINAL_TAG="${BASE_TAG}-nightly.$TODAY" + fi + + echo "Drop's release tag will be: $FINAL_TAG" + echo "final_tag=$FINAL_TAG" >> $GITHUB_OUTPUT + - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -46,6 +71,7 @@ jobs: ghcr.io/drop-OSS/drop tags: | type=schedule,pattern=nightly + type=schedule,pattern=nightly.${{ steps.get_final_tag.outputs.today }} type=semver,pattern=v{{version}} type=semver,pattern=v{{major}}.{{minor}} type=semver,pattern=v{{major}} @@ -61,8 +87,12 @@ jobs: with: context: . push: true + provenance: mode=max + sbom: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} platforms: linux/amd64,linux/arm64 cache-from: type=gha cache-to: type=gha,mode=max + build-args: | + BUILD_DROP_VERSION=${{ steps.get_final_tag.outputs.final_tag }} diff --git a/Dockerfile b/Dockerfile index 128e0d0..3ac6b7a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,30 +1,48 @@ -# pull pre-configured and updated build environment -FROM debian:testing-20250317-slim AS build-system +# syntax=docker/dockerfile:1 +# 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 + +# add git so drop can determine its git ref at build +RUN apk add --no-cache git + +# 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 + +ARG BUILD_DROP_VERSION="v0.0.0-unknown.1" +ARG BUILD_GIT_REF + +# build +RUN yarn postinstall +RUN yarn build # create run environment for Drop -FROM node:lts-slim AS run-system - +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 8d34e30..f8ea344 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,10 @@ services: - POSTGRES_USER=drop - POSTGRES_DB=drop drop: - image: decduck/drop-oss:v0.2.0-beta + image: ghcr.io/drop-oss/drop:latest + stdin_open: true + tty: true + init: true depends_on: postgres: condition: service_healthy @@ -24,11 +28,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 diff --git a/layouts/admin.vue b/layouts/admin.vue index 0b6d810..cea7e92 100644 --- a/layouts/admin.vue +++ b/layouts/admin.vue @@ -196,7 +196,7 @@ const navigation: Array = [ }, { label: "Back", - route: "/", + route: "/store", prefix: ".", icon: ArrowLeftIcon, }, @@ -219,13 +219,7 @@ useHead({ htmlAttrs: { lang: "en", }, - link: [ - { - rel: "icon", - type: "image/png", - href: "/favicon.png", - }, - ], + link: [], titleTemplate(title) { return title ? `${title} | Admin | Drop` : `Admin Dashboard | Drop`; }, diff --git a/layouts/default.vue b/layouts/default.vue index 4e00849..11303ea 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -19,13 +19,7 @@ useHead({ htmlAttrs: { lang: "en", }, - link: [ - { - rel: "icon", - type: "image/png", - href: "/favicon.png", - }, - ], + link: [], titleTemplate(title) { if (title) return `${title} | Drop`; return `Drop`; diff --git a/nuxt.config.ts b/nuxt.config.ts index e4e014d..bd2ffe2 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,7 +1,34 @@ import tailwindcss from "@tailwindcss/vite"; +import { execSync } from "node:child_process"; + +// get drop version +const dropVersion = + process.env.BUILD_DROP_VERSION === undefined + ? "v0.3.0-alpha.1" + : process.env.BUILD_DROP_VERSION; +// example nightly: "v0.3.0-nightly.2025.05.28" + +// get git ref or supply during build +const commitHash = + process.env.BUILD_GIT_REF === undefined + ? execSync("git rev-parse --short HEAD").toString().trim() + : process.env.BUILD_GIT_REF; + +console.log(`Building Drop ${dropVersion} #${commitHash}`); // 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", @@ -21,10 +48,19 @@ export default defineNuxtConfig({ viewTransition: true, }, + // future: { + // compatibilityVersion: 4, + // }, + vite: { plugins: [tailwindcss()], }, + runtimeConfig: { + gitRef: commitHash, + dropVersion: dropVersion, + }, + app: { head: { link: [{ rel: "icon", href: "/favicon.ico" }], @@ -37,18 +73,28 @@ export default defineNuxtConfig({ 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", @@ -76,17 +122,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/package.json b/package.json index 14c2030..b9a52f9 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,7 @@ "nuxt": "^3.16.2", "nuxt-security": "2.2.0", "prisma": "^6.7.0", - "sharp": "^0.33.5", + "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/pages/admin/metadata/games/[id]/index.vue b/pages/admin/metadata/games/[id]/index.vue index df83e5e..2512ff7 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 }} @@ -569,6 +570,8 @@ const descriptionSaving = ref(0); let savingTimeout: undefined | NodeJS.Timeout; +type PatchGameBody = Partial; + watch(descriptionHTML, (_v) => { console.log(game.value.mDescription); descriptionSaving.value = 1; @@ -581,7 +584,7 @@ watch(descriptionHTML, (_v) => { body: { id: gameId, mDescription: game.value.mDescription, - }, + } satisfies PatchGameBody, }); descriptionSaving.value = 0; } catch (e) { @@ -625,8 +628,8 @@ async function updateBannerImage(id: string) { method: "PATCH", body: { id: gameId, - mBannerId: id, - }, + mBannerObjectId: id, + } satisfies PatchGameBody, }); game.value.mBannerObjectId = mBannerObjectId; } catch (e) { @@ -652,10 +655,11 @@ async function updateCoverImage(id: string) { method: "PATCH", body: { id: gameId, - mCoverId: id, - }, + mCoverObjectId: id, + } satisfies PatchGameBody, }); game.value.mCoverObjectId = mCoverObjectId; + coreMetadataIconUrl.value = useObject(mCoverObjectId); } catch (e) { createModal( ModalType.Notification, @@ -727,8 +731,8 @@ async function updateImageCarousel() { method: "PATCH", body: { id: gameId, - mImageCarousel: game.value.mImageCarouselObjectIds, - }, + mImageCarouselObjectIds: game.value.mImageCarouselObjectIds, + } satisfies PatchGameBody, }); } catch (e) { createModal( 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/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/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/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/client.prisma b/prisma/models/client.prisma index e01787a..414fcbb 100644 --- a/prisma/models/client.prisma +++ b/prisma/models/client.prisma @@ -1,7 +1,8 @@ enum ClientCapabilities { - PeerAPI @map("peerAPI") // other clients can use the HTTP API to P2P with this client - UserStatus @map("userStatus") // this client can report this user's status (playing, online, etc etc) - CloudSaves @map("cloudSaves") // ability to save to save slots + PeerAPI @map("peerAPI") // other clients can use the HTTP API to P2P with this client + UserStatus @map("userStatus") // this client can report this user's status (playing, online, etc etc) + CloudSaves @map("cloudSaves") // ability to save to save slots + TrackPlaytime @map("trackPlaytime") // ability to track user playtime } // References a device @@ -18,4 +19,4 @@ model Client { lastAccessedSaves SaveSlot[] tokens APIToken[] -} \ No newline at end of file +} diff --git a/prisma/models/content.prisma b/prisma/models/content.prisma index daaada3..ab9fff3 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 @@ -34,6 +35,8 @@ model Game { collections CollectionEntry[] saves SaveSlot[] screenshots Screenshot[] + tags Tag[] + playtime Playtime[] developers Company[] @relation(name: "developers") publishers Company[] @relation(name: "publishers") @@ -41,6 +44,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 @@ -96,11 +117,27 @@ 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 Playtime { + gameId String + game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + seconds Int // seconds user has spent playing the game + + updatedAt DateTime @updatedAt @db.Timestamptz(6) + createdAt DateTime @default(now()) @db.Timestamptz(6) + + @@id([gameId, userId]) + @@index([userId]) } model Company { 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/prisma/models/user.prisma b/prisma/models/user.prisma index bb68b91..30cecb8 100644 --- a/prisma/models/user.prisma +++ b/prisma/models/user.prisma @@ -19,12 +19,13 @@ model User { saves SaveSlot[] screenshots Screenshot[] + playtime Playtime[] } model Notification { id String @id @default(uuid()) - nonce String? @unique + nonce String? userId String user User @relation(fields: [userId], references: [id]) @@ -36,4 +37,6 @@ model Notification { actions String[] read Boolean @default(false) + + @@unique([userId, nonce]) } 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; + const body = CreateInvite(await readBody(h3)); + if (body instanceof type.errors) { + // hover out.summary to see validation errors + console.error(body.summary); - 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 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..70c7817 100644 --- a/server/api/v1/admin/game/image/index.delete.ts +++ b/server/api/v1/admin/game/image/index.delete.ts @@ -1,20 +1,29 @@ 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 DeleteGameImage = type({ + gameId: "string", + imageId: "string", +}); + +export default defineEventHandler<{ + body: typeof DeleteGameImage.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; - - if (!gameId || !imageId) + const body = DeleteGameImage(await readBody(h3)); + if (body instanceof type.errors) { + console.error(h3.path, body.summary); 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/admin/game/metadata.post.ts b/server/api/v1/admin/game/metadata.post.ts index 59b7dd2..e46dedc 100644 --- a/server/api/v1/admin/game/metadata.post.ts +++ b/server/api/v1/admin/game/metadata.post.ts @@ -1,3 +1,4 @@ +import type { Prisma } from "~/prisma/client"; import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; import { handleFileUpload } from "~/server/internal/utils/handlefileupload"; @@ -27,25 +28,24 @@ export default defineEventHandler(async (h3) => { const description = options.description; const gameId = options.id; - if (!id || !name || !description) { - dump(); + const changes: Prisma.GameUpdateInput = { + mName: name, + mShortDescription: description, + }; - throw createError({ - statusCode: 400, - statusMessage: "Nothing has changed", - }); + // handle if user uploaded new icon + if (id) { + changes.mIconObjectId = id; + await pull(); + } else { + dump(); } - await pull(); const newObject = await prisma.game.update({ where: { id: gameId, }, - data: { - mIconObjectId: id, - mName: name, - mShortDescription: description, - }, + data: changes, }); return newObject; 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/api/v1/index.get.ts b/server/api/v1/index.get.ts index 37b2372..322ba50 100644 --- a/server/api/v1/index.get.ts +++ b/server/api/v1/index.get.ts @@ -1,5 +1,9 @@ +import { systemConfig } from "~/server/internal/config/sys-conf"; + export default defineEventHandler((_h3) => { return { appName: "Drop", + version: systemConfig.getDropVersion(), + ref: systemConfig.getGitRef(), }; }); 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..b4c5e7c --- /dev/null +++ b/server/api/v1/screenshots/[id]/index.delete.ts @@ -0,0 +1,27 @@ +// 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", + }); + + const result = await screenshotManager.get(screenshotId); + if (!result) + throw createError({ + statusCode: 404, + }); + else if (result.userId !== userId) + throw createError({ + statusCode: 404, + }); + + 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..5944103 --- /dev/null +++ b/server/api/v1/screenshots/[id]/index.get.ts @@ -0,0 +1,26 @@ +// 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", + }); + + const result = await screenshotManager.get(screenshotId); + if (!result) + throw createError({ + statusCode: 404, + }); + else if (result.userId !== userId) + throw createError({ + statusCode: 404, + }); + return result; +}); 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/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/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/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/clients/capabilities.ts b/server/internal/clients/capabilities.ts index 5e30e00..639e835 100644 --- a/server/internal/clients/capabilities.ts +++ b/server/internal/clients/capabilities.ts @@ -10,6 +10,7 @@ export enum InternalClientCapability { PeerAPI = "peerAPI", UserStatus = "userStatus", CloudSaves = "cloudSaves", + TrackPlaytime = "trackPlaytime", } export const validCapabilities = Object.values(InternalClientCapability); @@ -79,6 +80,7 @@ class CapabilityManager { [InternalClientCapability.PeerAPI]: async () => true, [InternalClientCapability.UserStatus]: async () => true, // No requirements for user status [InternalClientCapability.CloudSaves]: async () => true, // No requirements for cloud saves + [InternalClientCapability.TrackPlaytime]: async () => true, }; async validateCapabilityConfiguration( @@ -160,6 +162,28 @@ class CapabilityManager { }, }); }, + [InternalClientCapability.TrackPlaytime]: async function () { + const currentClient = await prisma.client.findUnique({ + where: { id: clientId }, + select: { + capabilities: true, + }, + }); + if (!currentClient) throw new Error("Invalid client ID"); + if ( + currentClient.capabilities.includes(ClientCapabilities.TrackPlaytime) + ) + return; + + await prisma.client.update({ + where: { id: clientId }, + data: { + capabilities: { + push: ClientCapabilities.TrackPlaytime, + }, + }, + }); + }, }; await upsertFunctions[capability](); } 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 { diff --git a/server/internal/config/sys-conf.ts b/server/internal/config/sys-conf.ts new file mode 100644 index 0000000..acf5865 --- /dev/null +++ b/server/internal/config/sys-conf.ts @@ -0,0 +1,42 @@ +class SystemConfig { + private libraryFolder = process.env.LIBRARY ?? "./.data/library"; + private dataFolder = process.env.DATA ?? "./.data/data"; + + private dropVersion; + private gitRef; + + private checkForUpdates = + process.env.CHECK_FOR_UPDATES !== undefined && + process.env.CHECK_FOR_UPDATES.toLocaleLowerCase() === "true" + ? true + : false; + + constructor() { + // get drop version and git ref from nuxt config + const config = useRuntimeConfig(); + this.dropVersion = config.dropVersion; + this.gitRef = config.gitRef; + } + + getLibraryFolder() { + return this.libraryFolder; + } + + getDataFolder() { + return this.dataFolder; + } + + getDropVersion() { + return this.dropVersion; + } + + getGitRef() { + return this.gitRef; + } + + shouldCheckForUpdates() { + return this.checkForUpdates; + } +} + +export const systemConfig = new SystemConfig(); diff --git a/server/internal/library/index.ts b/server/internal/library/index.ts index f6941e4..c288174 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/metadata/giantbomb.ts b/server/internal/metadata/giantbomb.ts index d617f0c..312fab5 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"; @@ -207,8 +205,9 @@ export class GiantBombProvider implements MetadataProvider { description: longDescription, released: releaseDate, - reviewCount: 0, - reviewRating: 0, + tags: [], + + reviews: [], publishers, developers, diff --git a/server/internal/metadata/igdb.ts b/server/internal/metadata/igdb.ts index 1970d89..3427c9f 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; @@ -31,6 +32,12 @@ interface IGDBItem { id: IGDBID; } +interface IGDBGenre extends IGDBItem { + name: string; + slug: string; + url: string; +} + // denotes role a company had in a game interface IGDBInvolvedCompany extends IGDBItem { company: IGDBID; @@ -68,8 +75,8 @@ interface IGDBCover extends IGDBItem { interface IGDBSearchStub extends IGDBItem { name: string; - cover: IGDBID; - first_release_date: number; // unix timestamp + cover?: IGDBID; + first_release_date?: number; // unix timestamp summary: string; } @@ -155,7 +162,7 @@ export class IGDBProvider implements MetadataProvider { } private async authWithTwitch() { - console.log("authorizing with twitch"); + console.log("IGDB authorizing with twitch"); const params = new URLSearchParams({ client_id: this.clientId, client_secret: this.clientSecret, @@ -168,10 +175,17 @@ export class IGDBProvider implements MetadataProvider { method: "POST", }); + if (response.status !== 200) + throw new Error( + `Error in IDGB \nStatus Code: ${response.status}\n${response.data}`, + ); + this.accessToken = response.data.access_token; this.accessTokenExpiry = DateTime.now().plus({ seconds: response.data.expires_in, }); + + console.log("IDGB done authorizing with twitch"); } private async refreshCredentials() { @@ -231,6 +245,11 @@ export class IGDBProvider implements MetadataProvider { } private async _getMediaInternal(mediaID: IGDBID, type: string) { + if (mediaID === undefined) + throw new Error( + `IGDB mediaID when getting item of type ${type} was undefined`, + ); + const body = `where id = ${mediaID}; fields url;`; const response = await this.request(type, body); @@ -244,6 +263,7 @@ export class IGDBProvider implements MetadataProvider { result = `https:${cover.url}`; } }); + return result; } @@ -263,6 +283,32 @@ export class IGDBProvider implements MetadataProvider { return msg.length > len ? msg.substring(0, 280) + "..." : msg; } + private async _getGenreInternal(genreID: IGDBID) { + if (genreID === undefined) throw new Error(`IGDB genreID was undefined`); + + const body = `where id = ${genreID}; fields slug,name,url;`; + const response = await this.request("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)); + } + + return results; + } + name() { return "IGDB"; } @@ -276,12 +322,24 @@ export class IGDBProvider implements MetadataProvider { const results: GameMetadataSearchResult[] = []; for (let i = 0; i < response.length; i++) { + let icon = ""; + const cover = response[i].cover; + if (cover !== undefined) { + icon = await this.getCoverURL(cover); + } else { + icon = ""; + } + + const firstReleaseDate = response[i].first_release_date; results.push({ id: "" + response[i].id, name: response[i].name, - icon: await this.getCoverURL(response[i].cover), + icon, description: response[i].summary, - year: DateTime.fromSeconds(response[i].first_release_date).year, + year: + firstReleaseDate === undefined + ? 0 + : DateTime.fromSeconds(firstReleaseDate).year, }); } @@ -297,7 +355,14 @@ export class IGDBProvider implements MetadataProvider { const response = await this.request("games", body); for (let i = 0; i < response.length; i++) { - const icon = createObject(await this.getCoverURL(response[i].cover)); + let iconRaw; + const cover = response[i].cover; + if (cover !== undefined) { + iconRaw = await this.getCoverURL(cover); + } else { + iconRaw = jdenticon.toPng(id, 512); + } + const icon = createObject(iconRaw); let banner = ""; const images = [icon]; @@ -343,21 +408,33 @@ export class IGDBProvider implements MetadataProvider { } } + const firstReleaseDate = response[i].first_release_date; + return { id: "" + response[i].id, name: response[i].name, shortDescription: this.trimMessage(response[i].summary, 280), description: response[i].summary, - released: DateTime.fromSeconds( - response[i].first_release_date, - ).toJSDate(), + released: + firstReleaseDate === undefined + ? new Date() + : DateTime.fromSeconds(firstReleaseDate).toJSDate(), - reviewCount: response[i]?.total_rating_count ?? 0, - reviewRating: (response[i]?.total_rating ?? 0) / 100, + reviews: [ + { + metadataId: "" + response[i].id, + metadataSource: MetadataSource.IGDB, + mReviewCount: response[i]?.total_rating_count ?? 0, + mReviewRating: (response[i]?.total_rating ?? 0) / 100, + mReviewHref: response[i].url, + }, + ], publishers: [], developers: [], + tags: await this.getGenres(response[i].genres), + icon, bannerId: banner, coverId: icon, diff --git a/server/internal/metadata/index.ts b/server/internal/metadata/index.ts index 76f7074..ad23e94 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,10 +7,11 @@ import type { GameMetadataSearchResult, InternalGameMetadataResult, CompanyMetadata, + GameMetadataRating, } from "./types"; import { ObjectTransactionalHandler } from "../objects/transactional"; import { PriorityListIndexed } from "../utils/prioritylist"; -import { DROP_VERSION } from "../consts"; +import { systemConfig } from "../config/sys-conf"; export class MissingMetadataProviderConfig extends Error { private providerName: string; @@ -26,7 +27,7 @@ export class MissingMetadataProviderConfig extends Error { } // TODO: add useragent to all outbound api calls (best practice) -export const DropUserAgent = `Drop/${DROP_VERSION}`; +export const DropUserAgent = `Drop/${systemConfig.getDropVersion()}`; export abstract class MetadataProvider { abstract name(): string; @@ -111,6 +112,58 @@ export class MetadataHandler { ); } + private parseTags(tags: string[]) { + const results: { + where: { + name: string; + }; + create: { + name: string; + }; + }[] = []; + + tags.forEach((t) => + results.push({ + where: { + name: t, + }, + create: { + name: t, + }, + }), + ); + + return results; + } + + private parseRatings(ratings: GameMetadataRating[]) { + const results: { + where: { + metadataKey: { + metadataId: string; + metadataSource: MetadataSource; + }; + }; + create: Omit; + }[] = []; + + ratings.forEach((r) => { + results.push({ + where: { + metadataKey: { + metadataId: r.metadataId, + metadataSource: r.metadataSource, + }, + }, + create: { + ...r, + }, + }); + }); + + return results; + } + async createGame( result: InternalGameMetadataResult, libraryBasePath: string, @@ -157,9 +210,6 @@ export class MetadataHandler { mName: metadata.name, mShortDescription: metadata.shortDescription, mDescription: metadata.description, - - mReviewCount: metadata.reviewCount, - mReviewRating: metadata.reviewRating, mReleased: metadata.released, mIconObjectId: metadata.icon, @@ -174,6 +224,13 @@ export class MetadataHandler { connect: metadata.developers, }, + ratings: { + connectOrCreate: this.parseRatings(metadata.reviews), + }, + tags: { + connectOrCreate: this.parseTags(metadata.tags), + }, + libraryBasePath, }, }); @@ -216,11 +273,14 @@ export class MetadataHandler { continue; } - // If we're successful - await pullObjects(); - - const object = await prisma.company.create({ - data: { + const object = await prisma.company.upsert({ + where: { + metadataKey: { + metadataSource: provider.source(), + metadataId: result.id, + }, + }, + create: { metadataSource: provider.source(), metadataId: result.id, metadataOriginalQuery: query, @@ -232,8 +292,15 @@ export class MetadataHandler { mBannerObjectId: result.banner, mWebsite: result.website, }, + update: {}, }); + if (object.mLogoObjectId == result.logo) { + // We created, and didn't update + // So pull objects + await pullObjects(); + } + return object; } diff --git a/server/internal/metadata/manual.ts b/server/internal/metadata/manual.ts index fb3786c..551faf3 100644 --- a/server/internal/metadata/manual.ts +++ b/server/internal/metadata/manual.ts @@ -33,8 +33,8 @@ export class ManualMetadataProvider implements MetadataProvider { released: new Date(), publishers: [], developers: [], - reviewCount: 0, - reviewRating: 0, + tags: [], + reviews: [], icon: iconId, coverId: iconId, diff --git a/server/internal/metadata/pcgamingwiki.ts b/server/internal/metadata/pcgamingwiki.ts index 5ebce0c..c58ae36 100644 --- a/server/internal/metadata/pcgamingwiki.ts +++ b/server/internal/metadata/pcgamingwiki.ts @@ -7,11 +7,31 @@ import type { GameMetadata, _FetchCompanyMetadataParams, CompanyMetadata, + GameMetadataRating, } from "./types"; import type { AxiosRequestConfig } from "axios"; import axios from "axios"; import * as jdenticon from "jdenticon"; import { DateTime } from "luxon"; +import * as cheerio from "cheerio"; +import { type } from "arktype"; + +interface PCGamingWikiParseRawPage { + parse: { + title: string; + pageid: number; + revid: number; + displaytitle: string; + // array of links + externallinks: string[]; + // array of wiki file names + images: string[]; + text: { + // rendered page contents + "*": string; + }; + }; +} interface PCGamingWikiPage { PageID: string; @@ -25,12 +45,19 @@ interface PCGamingWikiSearchStub extends PCGamingWikiPage { } interface PCGamingWikiGame extends PCGamingWikiSearchStub { - Developers: string | null; - Genres: string | null; - Publishers: string | null; - Themes: string | null; + Developers: string | string[] | null; + Publishers: string | string[] | null; + + // TODO: save this somewhere, maybe a tag? Series: string | null; - Modes: string | null; + + // tags + Perspectives: string | string[] | null; // ie: First-person + Genres: string | string[] | null; // ie: Action, FPS + "Art styles": string | string[] | null; // ie: Stylized + Themes: string | string[] | null; // ie: Post-apocalyptic, Sci-fi, Space + Modes: string | string[] | null; // ie: Singleplayer, Multiplayer + Pacing: string | string[] | null; // ie: Real-time } interface PCGamingWikiCompany extends PCGamingWikiPage { @@ -55,6 +82,14 @@ interface PCGamingWikiCargoResult { }; } +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 { @@ -75,20 +110,161 @@ export class PCGamingWikiProvider implements MetadataProvider { url: finalURL, baseURL: "", }; - const response = await axios.request>( + const response = await axios.request( Object.assign({}, options, overlay), ); if (response.status !== 200) throw new Error( - `Error in pcgamingwiki \nStatus Code: ${response.status}`, + `Error in pcgamingwiki \nStatus Code: ${response.status}\n${response.data}`, ); - else if (response.data.error !== undefined) - throw new Error(`Error in pcgamingwiki, malformed query`); return response; } + private async cargoQuery( + 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; + } + + /** + * Gets the raw wiki page for parsing, + * requested values are to be considered unstable as compared to cargo queries + * @param pageID + * @returns + */ + private async getPageContent(pageID: string) { + const searchParams = new URLSearchParams({ + action: "parse", + format: "json", + pageid: pageID, + }); + const res = await this.request(searchParams); + const $ = cheerio.load(res.data.parse.text["*"]); + // get intro based on 'introduction' class + const introductionEle = $(".introduction").first(); + // remove citations from intro + introductionEle.find("sup").remove(); + + const infoBoxEle = $(".template-infobox").first(); + const receptionEle = infoBoxEle + .find(".template-infobox-header") + .filter((_, el) => $(el).text().trim() === "Reception"); + + const receptionResults: (GameMetadataRating | undefined)[] = []; + if (receptionEle.length > 0) { + // we have a match! + + const ratingElements = infoBoxEle.find(".template-infobox-type"); + + // TODO: cleanup this ratnest + const parseIdFromHref = (href: string): string | undefined => { + const url = new URL(href); + const opencriticRegex = /^\/game\/(\d+)\/.+$/; + switch (url.hostname.toLocaleLowerCase()) { + case "www.metacritic.com": { + // https://www.metacritic.com/game/elden-ring/critic-reviews/?platform=pc + return url.pathname + .replace("/game/", "") + .replace("/critic-reviews", "") + .replace(/\/$/, ""); + } + case "opencritic.com": { + // https://opencritic.com/game/12090/elden-ring + let id = "unknown"; + let matches; + if ((matches = opencriticRegex.exec(url.pathname)) !== null) { + matches.forEach((match, _groupIndex) => { + // console.log(`Found match, group ${_groupIndex}: ${match}`); + id = match; + }); + } + + if (id === "unknown") { + return undefined; + } + return id; + } + case "www.igdb.com": { + // https://www.igdb.com/games/elden-ring + return url.pathname.replace("/games/", "").replace(/\/$/, ""); + } + default: { + console.warn("Pcgamingwiki, unknown host", url.hostname); + return undefined; + } + } + }; + const getRating = ( + source: MetadataSource, + ): GameMetadataRating | undefined => { + const providerEle = ratingElements.filter( + (_, el) => + $(el).text().trim().toLocaleLowerCase() === + source.toLocaleLowerCase(), + ); + if (providerEle.length > 0) { + // get info associated with provider + const reviewEle = providerEle + .first() + .parent() + .find(".template-infobox-info") + .find("a") + .first(); + + const href = reviewEle.attr("href"); + if (!href) { + console.log( + `pcgamingwiki: failed to properly get review href for ${source}`, + ); + return undefined; + } + const ratingObj = ratingProviderReview({ + rating: reviewEle.text().trim(), + }); + if (ratingObj instanceof type.errors) { + console.log( + "pcgamingwiki: failed to properly get review rating", + ratingObj.summary, + ); + return undefined; + } + + const id = parseIdFromHref(href); + if (!id) return undefined; + + return { + mReviewHref: href, + metadataId: id, + metadataSource: source, + mReviewCount: 0, + // make float within 0 to 1 + mReviewRating: ratingObj.rating / 100, + }; + } + + return undefined; + }; + receptionResults.push(getRating(MetadataSource.Metacritic)); + receptionResults.push(getRating(MetadataSource.IGDB)); + receptionResults.push(getRating(MetadataSource.OpenCritic)); + } + + return { + shortIntro: introductionEle.find("p").first().text().trim(), + introduction: introductionEle.text().trim(), + reception: receptionResults, + }; + } + async search(query: string) { const searchParams = new URLSearchParams({ action: "cargoquery", @@ -99,43 +275,58 @@ export class PCGamingWikiProvider implements MetadataProvider { format: "json", }); - const res = await this.request(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; } /** - * Parses the specific format that the wiki returns when specifying a company - * @param companyStr + * Parses the specific format that the wiki returns when specifying an array + * @param input string or array * @returns */ - private parseCompanyStr(companyStr: string): string[] { - const results: string[] = []; - // provides the string as a list of companies - // ie: "Company:Digerati Distribution,Company:Greylock Studio" - const items = companyStr.split(","); + private parseWikiStringArray(input: string | string[]): string[] { + const cleanStr = (str: string): string => { + // remove any dumb prefixes we don't care about + return str.replace("Company:", "").trim(); + }; - items.forEach((item) => { - // remove the `Company:` and trim and whitespace - results.push(item.replace("Company:", "").trim()); - }); + // input can provides the string as a list + // ie: "Company:Digerati Distribution,Company:Greylock Studio" + // or as an array, sometimes the array has empty values + + const results: string[] = []; + if (Array.isArray(input)) { + input.forEach((c) => { + const clean = cleanStr(c); + if (clean !== "") results.push(clean); + }); + } else { + const items = input.split(","); + items.forEach((item) => { + const clean = cleanStr(item); + if (clean !== "") results.push(clean); + }); + } return results; } @@ -156,6 +347,28 @@ export class PCGamingWikiProvider implements MetadataProvider { return websiteStr.replaceAll(/\[|]/g, "").split(" ")[0] ?? ""; } + private compileTags(game: PCGamingWikiGame): string[] { + const results: string[] = []; + + const properties: StringArrayKeys[] = [ + "Art styles", + "Genres", + "Modes", + "Pacing", + "Perspectives", + "Themes", + ]; + + // loop through all above keys, get the tags they contain + properties.forEach((p) => { + if (game[p] === null) return; + + results.push(...this.parseWikiStringArray(game[p])); + }); + + return results; + } + async fetchGame({ id, name, @@ -167,12 +380,15 @@ export class PCGamingWikiProvider implements MetadataProvider { action: "cargoquery", tables: "Infobox_game", fields: - "Infobox_game._pageID=PageID,Infobox_game._pageName=PageName,Infobox_game.Cover_URL,Infobox_game.Developers,Infobox_game.Released,Infobox_game.Genres,Infobox_game.Publishers,Infobox_game.Themes,Infobox_game.Series,Infobox_game.Modes", + "Infobox_game._pageID=PageID,Infobox_game._pageName=PageName,Infobox_game.Cover_URL,Infobox_game.Developers,Infobox_game.Released,Infobox_game.Genres,Infobox_game.Publishers,Infobox_game.Themes,Infobox_game.Series,Infobox_game.Modes,Infobox_game.Perspectives,Infobox_game.Art_styles,Infobox_game.Pacing", where: `Infobox_game._pageID="${id}"`, format: "json", }); - const res = await this.request(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"); @@ -180,7 +396,7 @@ export class PCGamingWikiProvider implements MetadataProvider { const publishers: Company[] = []; if (game.Publishers !== null) { - const pubListClean = this.parseCompanyStr(game.Publishers); + const pubListClean = this.parseWikiStringArray(game.Publishers); for (const pub of pubListClean) { const res = await publisher(pub); if (res === undefined) continue; @@ -190,7 +406,7 @@ export class PCGamingWikiProvider implements MetadataProvider { const developers: Company[] = []; if (game.Developers !== null) { - const devListClean = this.parseCompanyStr(game.Developers); + const devListClean = this.parseWikiStringArray(game.Developers); for (const dev of devListClean) { const res = await developer(dev); if (res === undefined) continue; @@ -206,15 +422,15 @@ export class PCGamingWikiProvider implements MetadataProvider { const metadata: GameMetadata = { id: game.PageID, name: game.PageName, - shortDescription: "", // TODO: (again) need to render the `Introduction` template somehow (or we could just hardcode it) - description: "", + shortDescription: pageContent.shortIntro, + description: pageContent.introduction, released: game.Released ? DateTime.fromISO(game.Released.split(";")[0]).toJSDate() : new Date(), - reviewCount: 0, - reviewRating: 0, + tags: this.compileTags(game), + reviews: pageContent.reception.filter((v) => typeof v !== "undefined"), publishers, developers, @@ -240,16 +456,16 @@ export class PCGamingWikiProvider implements MetadataProvider { format: "json", }); - const res = await this.request(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++) { 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..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; @@ -30,8 +39,9 @@ export interface GameMetadata { publishers: Company[]; developers: Company[]; - reviewCount: number; - reviewRating: number; + tags: string[]; + + reviews: GameMetadataRating[]; // Created with another utility function icon: ObjectReference; diff --git a/server/internal/notifications/index.ts b/server/internal/notifications/index.ts index c6ae0a3..5780b8f 100644 --- a/server/internal/notifications/index.ts +++ b/server/internal/notifications/index.ts @@ -10,6 +10,9 @@ import type { Notification } from "~/prisma/client"; import prisma from "../db/database"; import type { GlobalACL } from "../acls"; +// type Optional = Pick, K> & Omit; + +// TODO: document notification action format export type NotificationCreateArgs = Pick< Notification, "title" | "description" | "actions" | "nonce" @@ -72,14 +75,18 @@ class NotificationSystem { throw new Error("No nonce in notificationCreateArgs"); const notification = await prisma.notification.upsert({ where: { - nonce: notificationCreateArgs.nonce, + userId_nonce: { + nonce: notificationCreateArgs.nonce, + userId, + }, }, update: { - userId: userId, + // we don't need to update the userid right? + // userId: userId, ...notificationCreateArgs, }, create: { - userId: userId, + userId, ...notificationCreateArgs, }, }); @@ -87,6 +94,27 @@ class NotificationSystem { await this.pushNotification(userId, notification); } + /** + * Internal call to batch push notifications to many users + * @param notificationCreateArgs + * @param users + */ + private async _pushMany( + notificationCreateArgs: NotificationCreateArgs, + users: { id: string }[], + ) { + const res: Promise[] = []; + for (const user of users) { + res.push(this.push(user.id, notificationCreateArgs)); + } + // wait for all notifications to pass + await Promise.all(res); + } + + /** + * Send a notification to all users + * @param notificationCreateArgs + */ async pushAll(notificationCreateArgs: NotificationCreateArgs) { const users = await prisma.user.findMany({ where: { id: { not: "system" } }, @@ -95,13 +123,27 @@ class NotificationSystem { }, }); - for (const user of users) { - await this.push(user.id, notificationCreateArgs); - } + await this._pushMany(notificationCreateArgs, users); } + /** + * Send a notification to all system level users + * @param notificationCreateArgs + * @returns + */ async systemPush(notificationCreateArgs: NotificationCreateArgs) { - return await this.pushAll(notificationCreateArgs); + const users = await prisma.user.findMany({ + where: { + id: { not: "system" }, + // no reason to send to any users other then admins rn + admin: true, + }, + select: { + id: true, + }, + }); + + await this._pushMany(notificationCreateArgs, users); } } diff --git a/server/internal/objects/fsBackend.ts b/server/internal/objects/fsBackend.ts index 3f0b865..e8b070a 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"; @@ -7,16 +7,20 @@ import { Readable } from "stream"; import { createHash } from "crypto"; import prisma from "../db/database"; import cacheHandler from "../cache"; +import { systemConfig } from "../config/sys-conf"; +import { type } from "arktype"; export class FsObjectBackend extends ObjectBackend { private baseObjectPath: string; private baseMetadataPath: string; private hashStore = new FsHashStore(); + private metadataCache = + cacheHandler.createCache("ObjectMetadata"); constructor() { super(); - const basePath = process.env.FS_BACKEND_PATH ?? "./.data/objects"; + const basePath = path.join(systemConfig.getDataFolder(), "objects"); this.baseObjectPath = path.join(basePath, "objects"); this.baseMetadataPath = path.join(basePath, "metadata"); @@ -98,17 +102,30 @@ export class FsObjectBackend extends ObjectBackend { const objectPath = path.join(this.baseObjectPath, id); if (!fs.existsSync(objectPath)) return true; fs.rmSync(objectPath); - // remove item from cache + const metadataPath = path.join(this.baseMetadataPath, `${id}.json`); + if (!fs.existsSync(metadataPath)) return true; + fs.rmSync(metadataPath); + // remove item from caches + await this.metadataCache.remove(id); await this.hashStore.delete(id); return true; } async fetchMetadata( id: ObjectReference, ): Promise { + const cacheResult = await this.metadataCache.get(id); + if (cacheResult !== null) return cacheResult; + const metadataPath = path.join(this.baseMetadataPath, `${id}.json`); if (!fs.existsSync(metadataPath)) return undefined; - const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8")); - return metadata as ObjectMetadata; + const metadataRaw = JSON.parse(fs.readFileSync(metadataPath, "utf-8")); + const metadata = objectMetadata(metadataRaw); + if (metadata instanceof type.errors) { + console.error("FsObjectBackend#fetchMetadata", metadata.summary); + return undefined; + } + await this.metadataCache.set(id, metadata); + return metadata; } async writeMetadata( id: ObjectReference, @@ -117,6 +134,7 @@ export class FsObjectBackend extends ObjectBackend { const metadataPath = path.join(this.baseMetadataPath, `${id}.json`); if (!fs.existsSync(metadataPath)) return false; fs.writeFileSync(metadataPath, JSON.stringify(metadata)); + await this.metadataCache.set(id, metadata); return true; } async fetchHash(id: ObjectReference): Promise { @@ -152,9 +170,34 @@ export class FsObjectBackend extends ObjectBackend { await store.save(id, hashResult); return typeof hashResult; } + async listAll(): Promise { return fs.readdirSync(this.baseObjectPath); } + + async cleanupMetadata() { + const metadataFiles = fs.readdirSync(this.baseMetadataPath); + const objects = await this.listAll(); + + const extraFiles = metadataFiles.filter( + (file) => !objects.includes(file.replace(/\.json$/, "")), + ); + console.log( + `[FsObjectBackend#cleanupMetadata]: Found ${extraFiles.length} metadata files without corresponding objects.`, + ); + for (const file of extraFiles) { + const filePath = path.join(this.baseMetadataPath, file); + try { + fs.rmSync(filePath); + console.log(`[FsObjectBackend#cleanupMetadata]: Removed ${file}`); + } catch (error) { + console.error( + `[FsObjectBackend#cleanupMetadata]: Failed to remove ${file}`, + error, + ); + } + } + } } class FsHashStore { diff --git a/server/internal/objects/objectHandler.ts b/server/internal/objects/objectHandler.ts index edbf8ad..0506f41 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", @@ -66,6 +71,7 @@ export abstract class ObjectBackend { ): Promise; abstract fetchHash(id: ObjectReference): Promise; abstract listAll(): Promise; + abstract cleanupMetadata(): Promise; } export class ObjectHandler { @@ -252,4 +258,13 @@ export class ObjectHandler { async listAll() { return await this.backend.listAll(); } + + /** + * Purges metadata for objects that no longer exist + * This is useful for cleaning up metadata files that are left behinds + * @returns + */ + async cleanupMetadata() { + return await this.backend.cleanupMetadata(); + } } diff --git a/server/internal/playtime/index.ts b/server/internal/playtime/index.ts new file mode 100644 index 0000000..3ae1497 --- /dev/null +++ b/server/internal/playtime/index.ts @@ -0,0 +1,50 @@ +import prisma from "../db/database"; + +class PlaytimeManager { + /** + * Get a user's playtime on a game + * @param gameId + * @param userId + * @returns + */ + async get(gameId: string, userId: string) { + return await prisma.playtime.findUnique({ + where: { + gameId_userId: { + gameId, + userId, + }, + }, + }); + } + + /** + * Add time to a user's playtime + * @param gameId + * @param userId + * @param seconds seconds played + */ + async add(gameId: string, userId: string, seconds: number) { + await prisma.playtime.upsert({ + where: { + gameId_userId: { + gameId, + userId, + }, + }, + create: { + gameId, + userId, + seconds, + }, + update: { + seconds: { + increment: seconds, + }, + }, + }); + } +} + +export const playtimeManager = new PlaytimeManager(); +export default playtimeManager; diff --git a/server/internal/screenshots/index.ts b/server/internal/screenshots/index.ts index e8b4911..4179ebc 100644 --- a/server/internal/screenshots/index.ts +++ b/server/internal/screenshots/index.ts @@ -5,6 +5,11 @@ import stream from "node:stream/promises"; import prisma from "../db/database"; class ScreenshotManager { + /** + * Gets a specific screenshot + * @param id + * @returns + */ async get(id: string) { return await prisma.screenshot.findUnique({ where: { @@ -13,7 +18,27 @@ class ScreenshotManager { }); } - async getAllByGame(gameId: string, userId: string) { + /** + * Get all user screenshots + * @param userId + * @returns + */ + async getUserAll(userId: string) { + const results = await prisma.screenshot.findMany({ + where: { + userId, + }, + }); + return results; + } + + /** + * Get all user screenshots in a specific game + * @param userId + * @param gameId + * @returns + */ + async getUserAllByGame(userId: string, gameId: string) { const results = await prisma.screenshot.findMany({ where: { gameId, @@ -23,6 +48,10 @@ class ScreenshotManager { return results; } + /** + * Delete a specific screenshot + * @param id + */ async delete(id: string) { await prisma.screenshot.delete({ where: { @@ -31,9 +60,22 @@ class ScreenshotManager { }); } - async upload(gameId: string, userId: string, inputStream: IncomingMessage) { + /** + * Allows a user to upload a screenshot + * @param userId + * @param gameId + * @param inputStream + */ + async upload(userId: string, gameId: string, inputStream: IncomingMessage) { const objectId = randomUUID(); - const saveStream = await objectHandler.createWithStream(objectId, {}, []); + const saveStream = await objectHandler.createWithStream( + objectId, + { + // TODO: set createAt to the time screenshot was taken + createdAt: new Date().toISOString(), + }, + [`${userId}:read`, `${userId}:delete`], + ); if (!saveStream) throw createError({ statusCode: 500, @@ -43,12 +85,12 @@ class ScreenshotManager { // pipe into object store await stream.pipeline(inputStream, saveStream); - // TODO: set createAt to the time screenshot was taken await prisma.screenshot.create({ data: { gameId, userId, objectId, + private: true, }, }); } 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()); }); 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/routes/auth/callback/oidc.get.ts b/server/routes/auth/callback/oidc.get.ts index 0bedf34..65c56c1 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 a2b89e9..db79fa2 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) => { const redirect = getQuery(h3).redirect?.toString(); 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); diff --git a/server/tasks/check/update.ts b/server/tasks/check/update.ts new file mode 100644 index 0000000..5a35e51 --- /dev/null +++ b/server/tasks/check/update.ts @@ -0,0 +1,150 @@ +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"); + + 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, + body: response.body, + }); + + return { + result: { + success: false, + error: { + message: "" + response.status, + }, + }, + }; + } + + // parse and validate response + 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, + }, + }, + }; + } + + // parse remote version + const latestVer = semver.coerce(body.tag_name); + if (latestVer === null) { + const msg = "Github Api returned invalid semver tag"; + console.log("[Task check:update]:", msg); + return { + result: { + success: false, + error: { + message: msg, + }, + }, + }; + } + + // TODO: handle prerelease identifiers https://github.com/npm/node-semver#prerelease-identifiers + // check if is newer version + if (semver.gt(latestVer, currVer)) { + console.log("[Task check:update]: Update available"); + 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}`], + acls: ["system:notifications:read"], + }); + } 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/server/tasks/cleanup/objects.ts b/server/tasks/cleanup/objects.ts index 62955dc..44cd7f3 100644 --- a/server/tasks/cleanup/objects.ts +++ b/server/tasks/cleanup/objects.ts @@ -1,5 +1,6 @@ import prisma from "~/server/internal/db/database"; import objectHandler from "~/server/internal/objects"; +import type { TaskReturn } from "../../h3"; type FieldReferenceMap = { [modelName: string]: { @@ -9,29 +10,57 @@ type FieldReferenceMap = { }; }; -export default defineTask({ +export default defineTask({ meta: { name: "cleanup:objects", }, async run() { console.log("[Task cleanup:objects]: Cleaning unreferenced objects"); + // get all objects const objects = await objectHandler.listAll(); console.log( `[Task cleanup:objects]: searching for ${objects.length} objects`, ); - console.log(objects); - const results = await findUnreferencedStrings(objects, buildRefMap()); + + // find unreferenced objects + const refMap = buildRefMap(); + console.log("[Task cleanup:objects]: Building reference map"); console.log( - `[Task cleanup:objects]: found ${results.length} Unreferenced objects`, + `[Task cleanup:objects]: Found ${Object.keys(refMap).length} models with reference fields`, ); - console.log(results); + console.log("[Task cleanup:objects]: Searching for unreferenced objects"); + const unrefedObjects = await findUnreferencedStrings(objects, refMap); + console.log( + `[Task cleanup:objects]: found ${unrefedObjects.length} Unreferenced objects`, + ); + // console.log(unrefedObjects); + + // remove objects + const deletePromises: Promise[] = []; + for (const obj of unrefedObjects) { + console.log(`[Task cleanup:objects]: Deleting object ${obj}`); + deletePromises.push(objectHandler.deleteAsSystem(obj)); + } + await Promise.all(deletePromises); + + // Remove any possible leftover metadata + objectHandler.cleanupMetadata(); console.log("[Task cleanup:objects]: Done"); - return { result: true }; + return { + result: { + success: true, + data: unrefedObjects, + }, + }; }, }); +/** + * Builds a map of Prisma models and their fields that may contain object IDs + * @returns + */ function buildRefMap(): FieldReferenceMap { const tables = Object.keys(prisma).filter( (v) => !(v.startsWith("$") || v.startsWith("_") || v === "constructor"), @@ -59,6 +88,12 @@ function buildRefMap(): FieldReferenceMap { return result; } +/** + * Searches all models for a given id in their fields + * @param id + * @param fieldRefMap + * @returns + */ async function isReferencedInModelFields( id: string, fieldRefMap: FieldReferenceMap, @@ -111,6 +146,12 @@ async function isReferencedInModelFields( return false; } +/** + * Takes a list of objects and checks if they are referenced in any model fields + * @param objects + * @param fieldRefMap + * @returns + */ async function findUnreferencedStrings( objects: string[], fieldRefMap: FieldReferenceMap, diff --git a/yarn.lock b/yarn.lock index 73d1c63..634ac27 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" @@ -1943,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" @@ -2805,6 +2697,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 +3265,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 +3335,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 +3363,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 +4190,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 +4234,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 +5711,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 +6428,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 +6530,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 +7117,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 +7535,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"