Merge branch 'Huskydog9988-more-fixes' into develop

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

View File

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

View File

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

View File

@ -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"]
CMD ["sh", "/app/startup/launch.sh"]

View File

@ -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
node /app/app/server/index.mjs

View File

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

View File

@ -196,7 +196,7 @@ const navigation: Array<NavigationItem & { icon: Component }> = [
},
{
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`;
},

View File

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

View File

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

View File

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

View File

@ -10,7 +10,8 @@
class="flex flex-col lg:flex-row lg:justify-between items-start lg:items-center gap-2"
>
<div class="inline-flex items-center gap-4">
<img :src="useObject(game.mIconObjectId)" class="size-20" />
<!-- cover image -->
<img :src="coreMetadataIconUrl" class="size-20" />
<div>
<h1 class="text-5xl font-bold font-display text-zinc-100">
{{ game.mName }}
@ -569,6 +570,8 @@ const descriptionSaving = ref<number>(0);
let savingTimeout: undefined | NodeJS.Timeout;
type PatchGameBody = Partial<Game>;
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(

View File

@ -103,9 +103,7 @@
'w-4 h-4',
]"
/>
<span class="text-zinc-600"
>({{ game.mReviewCount }} reviews)</span
>
<span class="text-zinc-600">({{ 0 }} reviews)</span>
</td>
</tr>
</tbody>
@ -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);

View File

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

View File

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

View File

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

View File

@ -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[]
}
}

View File

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

View File

@ -3,6 +3,7 @@ model Tag {
name String @unique
articles Article[]
games Game[]
}
model Article {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

4
server/h3.d.ts vendored
View File

@ -1 +1,5 @@
export type MinimumRequestObject = { headers: Headers };
export type TaskReturn<T = unknown> =
| { success: true; data: T; error?: never }
| { success: false; data?: never; error: { message: string } };

View File

@ -22,6 +22,10 @@ export const userACLDescriptions: ObjectFromList<typeof userACLs> = {
"notifications:listen": "Connect to a websocket to recieve notifications.",
"notifications:delete": "Delete this account's notifications.",
"screenshots:new": "Create screenshots for this account",
"screenshots:read": "Read all screenshots for this account",
"screenshots:delete": "Delete a screenshot for this account",
"collections:new": "Create collections for this account.",
"collections:read": "Fetch all collections (including library).",
"collections:delete": "Delete a collection for this account.",

View File

@ -17,6 +17,10 @@ export const userACLs = [
"notifications:listen",
"notifications:delete",
"screenshots:new",
"screenshots:read",
"screenshots:delete",
"collections:new",
"collections:read",
"collections:delete",
@ -83,6 +87,12 @@ class ACLManager {
return token;
}
/**
* Get userId and require one of the specified acls
* @param request
* @param acls
* @returns
*/
async getUserIdACL(request: MinimumRequestObject | undefined, acls: UserACL) {
if (!request)
throw new Error("Native web requests not available - weird deployment?");

View File

@ -2,6 +2,7 @@ import path from "path";
import fs from "fs";
import type { CertificateBundle } from "./ca";
import prisma from "../db/database";
import { systemConfig } from "../config/sys-conf";
export type CertificateStore = {
store(name: string, data: CertificateBundle): Promise<void>;
@ -10,7 +11,8 @@ export type CertificateStore = {
checkBlacklistCertificate(name: string): Promise<boolean>;
};
export const fsCertificateStore = (base: string) => {
export const fsCertificateStore = () => {
const base = path.join(systemConfig.getDataFolder(), "certs");
const blacklist = path.join(base, ".blacklist");
fs.mkdirSync(blacklist, { recursive: true });
const store: CertificateStore = {

View File

@ -10,6 +10,7 @@ export enum InternalClientCapability {
PeerAPI = "peerAPI",
UserStatus = "userStatus",
CloudSaves = "cloudSaves",
TrackPlaytime = "trackPlaytime",
}
export const validCapabilities = Object.values(InternalClientCapability);
@ -79,6 +80,7 @@ class CapabilityManager {
[InternalClientCapability.PeerAPI]: async () => true,
[InternalClientCapability.UserStatus]: async () => true, // No requirements for user status
[InternalClientCapability.CloudSaves]: async () => true, // No requirements for cloud saves
[InternalClientCapability.TrackPlaytime]: async () => true,
};
async validateCapabilityConfiguration(
@ -160,6 +162,28 @@ class CapabilityManager {
},
});
},
[InternalClientCapability.TrackPlaytime]: async function () {
const currentClient = await prisma.client.findUnique({
where: { id: clientId },
select: {
capabilities: true,
},
});
if (!currentClient) throw new Error("Invalid client ID");
if (
currentClient.capabilities.includes(ClientCapabilities.TrackPlaytime)
)
return;
await prisma.client.update({
where: { id: clientId },
data: {
capabilities: {
push: ClientCapabilities.TrackPlaytime,
},
},
});
},
};
await upsertFunctions[capability]();
}

View File

@ -2,7 +2,10 @@ import { randomUUID } from "node:crypto";
import prisma from "../db/database";
import type { Platform } from "~/prisma/client";
import { useCertificateAuthority } from "~/server/plugins/ca";
import type { CapabilityConfiguration, InternalClientCapability } from "./capabilities";
import type {
CapabilityConfiguration,
InternalClientCapability,
} from "./capabilities";
import capabilityManager from "./capabilities";
export interface ClientMetadata {

View File

@ -0,0 +1,42 @@
class SystemConfig {
private libraryFolder = process.env.LIBRARY ?? "./.data/library";
private dataFolder = process.env.DATA ?? "./.data/data";
private dropVersion;
private gitRef;
private checkForUpdates =
process.env.CHECK_FOR_UPDATES !== undefined &&
process.env.CHECK_FOR_UPDATES.toLocaleLowerCase() === "true"
? true
: false;
constructor() {
// get drop version and git ref from nuxt config
const config = useRuntimeConfig();
this.dropVersion = config.dropVersion;
this.gitRef = config.gitRef;
}
getLibraryFolder() {
return this.libraryFolder;
}
getDataFolder() {
return this.dataFolder;
}
getDropVersion() {
return this.dropVersion;
}
getGitRef() {
return this.gitRef;
}
shouldCheckForUpdates() {
return this.checkForUpdates;
}
}
export const systemConfig = new SystemConfig();

View File

@ -15,12 +15,13 @@ import taskHandler from "../tasks";
import { parsePlatform } from "../utils/parseplatform";
import droplet from "@drop-oss/droplet";
import notificationSystem from "../notifications";
import { systemConfig } from "../config/sys-conf";
class LibraryManager {
private basePath: string;
constructor() {
this.basePath = process.env.LIBRARY ?? "./.data/library";
this.basePath = systemConfig.getLibraryFolder();
fs.mkdirSync(this.basePath, { recursive: true });
}

View File

@ -1,5 +1,4 @@
import type { Company } from "~/prisma/client";
import { MetadataSource } from "~/prisma/client";
import { MetadataSource, type Company } from "~/prisma/client";
import type { MetadataProvider } from ".";
import { MissingMetadataProviderConfig } from ".";
import type {
@ -9,8 +8,7 @@ import type {
_FetchCompanyMetadataParams,
CompanyMetadata,
} from "./types";
import type { AxiosRequestConfig } from "axios";
import axios from "axios";
import axios, { type AxiosRequestConfig } from "axios";
import TurndownService from "turndown";
import { DateTime } from "luxon";
@ -207,8 +205,9 @@ export class GiantBombProvider implements MetadataProvider {
description: longDescription,
released: releaseDate,
reviewCount: 0,
reviewRating: 0,
tags: [],
reviews: [],
publishers,
developers,

View File

@ -12,6 +12,7 @@ import type {
import type { AxiosRequestConfig } from "axios";
import axios from "axios";
import { DateTime } from "luxon";
import * as jdenticon from "jdenticon";
type IGDBID = number;
@ -31,6 +32,12 @@ interface IGDBItem {
id: IGDBID;
}
interface IGDBGenre extends IGDBItem {
name: string;
slug: string;
url: string;
}
// denotes role a company had in a game
interface IGDBInvolvedCompany extends IGDBItem {
company: IGDBID;
@ -68,8 +75,8 @@ interface IGDBCover extends IGDBItem {
interface IGDBSearchStub extends IGDBItem {
name: string;
cover: IGDBID;
first_release_date: number; // unix timestamp
cover?: IGDBID;
first_release_date?: number; // unix timestamp
summary: string;
}
@ -155,7 +162,7 @@ export class IGDBProvider implements MetadataProvider {
}
private async authWithTwitch() {
console.log("authorizing with twitch");
console.log("IGDB authorizing with twitch");
const params = new URLSearchParams({
client_id: this.clientId,
client_secret: this.clientSecret,
@ -168,10 +175,17 @@ export class IGDBProvider implements MetadataProvider {
method: "POST",
});
if (response.status !== 200)
throw new Error(
`Error in IDGB \nStatus Code: ${response.status}\n${response.data}`,
);
this.accessToken = response.data.access_token;
this.accessTokenExpiry = DateTime.now().plus({
seconds: response.data.expires_in,
});
console.log("IDGB done authorizing with twitch");
}
private async refreshCredentials() {
@ -231,6 +245,11 @@ export class IGDBProvider implements MetadataProvider {
}
private async _getMediaInternal(mediaID: IGDBID, type: string) {
if (mediaID === undefined)
throw new Error(
`IGDB mediaID when getting item of type ${type} was undefined`,
);
const body = `where id = ${mediaID}; fields url;`;
const response = await this.request<IGDBCover>(type, body);
@ -244,6 +263,7 @@ export class IGDBProvider implements MetadataProvider {
result = `https:${cover.url}`;
}
});
return result;
}
@ -263,6 +283,32 @@ export class IGDBProvider implements MetadataProvider {
return msg.length > len ? msg.substring(0, 280) + "..." : msg;
}
private async _getGenreInternal(genreID: IGDBID) {
if (genreID === undefined) throw new Error(`IGDB genreID was undefined`);
const body = `where id = ${genreID}; fields slug,name,url;`;
const response = await this.request<IGDBGenre>("genres", body);
let result = "";
response.forEach((genre) => {
result = genre.name;
});
return result;
}
private async getGenres(genres: IGDBID[] | undefined): Promise<string[]> {
if (genres === undefined) return [];
const results: string[] = [];
for (const genre of genres) {
results.push(await this._getGenreInternal(genre));
}
return results;
}
name() {
return "IGDB";
}
@ -276,12 +322,24 @@ export class IGDBProvider implements MetadataProvider {
const results: GameMetadataSearchResult[] = [];
for (let i = 0; i < response.length; i++) {
let icon = "";
const cover = response[i].cover;
if (cover !== undefined) {
icon = await this.getCoverURL(cover);
} else {
icon = "";
}
const firstReleaseDate = response[i].first_release_date;
results.push({
id: "" + response[i].id,
name: response[i].name,
icon: await this.getCoverURL(response[i].cover),
icon,
description: response[i].summary,
year: DateTime.fromSeconds(response[i].first_release_date).year,
year:
firstReleaseDate === undefined
? 0
: DateTime.fromSeconds(firstReleaseDate).year,
});
}
@ -297,7 +355,14 @@ export class IGDBProvider implements MetadataProvider {
const response = await this.request<IGDBGameFull>("games", body);
for (let i = 0; i < response.length; i++) {
const icon = createObject(await this.getCoverURL(response[i].cover));
let iconRaw;
const cover = response[i].cover;
if (cover !== undefined) {
iconRaw = await this.getCoverURL(cover);
} else {
iconRaw = jdenticon.toPng(id, 512);
}
const icon = createObject(iconRaw);
let banner = "";
const images = [icon];
@ -343,21 +408,33 @@ export class IGDBProvider implements MetadataProvider {
}
}
const firstReleaseDate = response[i].first_release_date;
return {
id: "" + response[i].id,
name: response[i].name,
shortDescription: this.trimMessage(response[i].summary, 280),
description: response[i].summary,
released: DateTime.fromSeconds(
response[i].first_release_date,
).toJSDate(),
released:
firstReleaseDate === undefined
? new Date()
: DateTime.fromSeconds(firstReleaseDate).toJSDate(),
reviewCount: response[i]?.total_rating_count ?? 0,
reviewRating: (response[i]?.total_rating ?? 0) / 100,
reviews: [
{
metadataId: "" + response[i].id,
metadataSource: MetadataSource.IGDB,
mReviewCount: response[i]?.total_rating_count ?? 0,
mReviewRating: (response[i]?.total_rating ?? 0) / 100,
mReviewHref: response[i].url,
},
],
publishers: [],
developers: [],
tags: await this.getGenres(response[i].genres),
icon,
bannerId: banner,
coverId: icon,

View File

@ -1,4 +1,4 @@
import { MetadataSource } from "~/prisma/client";
import { MetadataSource, type GameRating } from "~/prisma/client";
import prisma from "../db/database";
import type {
_FetchGameMetadataParams,
@ -7,10 +7,11 @@ import type {
GameMetadataSearchResult,
InternalGameMetadataResult,
CompanyMetadata,
GameMetadataRating,
} from "./types";
import { ObjectTransactionalHandler } from "../objects/transactional";
import { PriorityListIndexed } from "../utils/prioritylist";
import { DROP_VERSION } from "../consts";
import { systemConfig } from "../config/sys-conf";
export class MissingMetadataProviderConfig extends Error {
private providerName: string;
@ -26,7 +27,7 @@ export class MissingMetadataProviderConfig extends Error {
}
// TODO: add useragent to all outbound api calls (best practice)
export const DropUserAgent = `Drop/${DROP_VERSION}`;
export const DropUserAgent = `Drop/${systemConfig.getDropVersion()}`;
export abstract class MetadataProvider {
abstract name(): string;
@ -111,6 +112,58 @@ export class MetadataHandler {
);
}
private parseTags(tags: string[]) {
const results: {
where: {
name: string;
};
create: {
name: string;
};
}[] = [];
tags.forEach((t) =>
results.push({
where: {
name: t,
},
create: {
name: t,
},
}),
);
return results;
}
private parseRatings(ratings: GameMetadataRating[]) {
const results: {
where: {
metadataKey: {
metadataId: string;
metadataSource: MetadataSource;
};
};
create: Omit<GameRating, "gameId" | "created" | "id">;
}[] = [];
ratings.forEach((r) => {
results.push({
where: {
metadataKey: {
metadataId: r.metadataId,
metadataSource: r.metadataSource,
},
},
create: {
...r,
},
});
});
return results;
}
async createGame(
result: InternalGameMetadataResult,
libraryBasePath: string,
@ -157,9 +210,6 @@ export class MetadataHandler {
mName: metadata.name,
mShortDescription: metadata.shortDescription,
mDescription: metadata.description,
mReviewCount: metadata.reviewCount,
mReviewRating: metadata.reviewRating,
mReleased: metadata.released,
mIconObjectId: metadata.icon,
@ -174,6 +224,13 @@ export class MetadataHandler {
connect: metadata.developers,
},
ratings: {
connectOrCreate: this.parseRatings(metadata.reviews),
},
tags: {
connectOrCreate: this.parseTags(metadata.tags),
},
libraryBasePath,
},
});
@ -216,11 +273,14 @@ export class MetadataHandler {
continue;
}
// If we're successful
await pullObjects();
const object = await prisma.company.create({
data: {
const object = await prisma.company.upsert({
where: {
metadataKey: {
metadataSource: provider.source(),
metadataId: result.id,
},
},
create: {
metadataSource: provider.source(),
metadataId: result.id,
metadataOriginalQuery: query,
@ -232,8 +292,15 @@ export class MetadataHandler {
mBannerObjectId: result.banner,
mWebsite: result.website,
},
update: {},
});
if (object.mLogoObjectId == result.logo) {
// We created, and didn't update
// So pull objects
await pullObjects();
}
return object;
}

View File

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

View File

@ -7,11 +7,31 @@ import type {
GameMetadata,
_FetchCompanyMetadataParams,
CompanyMetadata,
GameMetadataRating,
} from "./types";
import type { AxiosRequestConfig } from "axios";
import axios from "axios";
import * as jdenticon from "jdenticon";
import { DateTime } from "luxon";
import * as cheerio from "cheerio";
import { type } from "arktype";
interface PCGamingWikiParseRawPage {
parse: {
title: string;
pageid: number;
revid: number;
displaytitle: string;
// array of links
externallinks: string[];
// array of wiki file names
images: string[];
text: {
// rendered page contents
"*": string;
};
};
}
interface PCGamingWikiPage {
PageID: string;
@ -25,12 +45,19 @@ interface PCGamingWikiSearchStub extends PCGamingWikiPage {
}
interface PCGamingWikiGame extends PCGamingWikiSearchStub {
Developers: string | null;
Genres: string | null;
Publishers: string | null;
Themes: string | null;
Developers: string | string[] | null;
Publishers: string | string[] | null;
// TODO: save this somewhere, maybe a tag?
Series: string | null;
Modes: string | null;
// tags
Perspectives: string | string[] | null; // ie: First-person
Genres: string | string[] | null; // ie: Action, FPS
"Art styles": string | string[] | null; // ie: Stylized
Themes: string | string[] | null; // ie: Post-apocalyptic, Sci-fi, Space
Modes: string | string[] | null; // ie: Singleplayer, Multiplayer
Pacing: string | string[] | null; // ie: Real-time
}
interface PCGamingWikiCompany extends PCGamingWikiPage {
@ -55,6 +82,14 @@ interface PCGamingWikiCargoResult<T> {
};
}
type StringArrayKeys<T> = {
[K in keyof T]: T[K] extends string | string[] | null ? K : never;
}[keyof T];
const ratingProviderReview = type({
rating: "string.integer.parse",
});
// Api Docs: https://www.pcgamingwiki.com/wiki/PCGamingWiki:API
// Good tool for helping build cargo queries: https://www.pcgamingwiki.com/wiki/Special:CargoQuery
export class PCGamingWikiProvider implements MetadataProvider {
@ -75,20 +110,161 @@ export class PCGamingWikiProvider implements MetadataProvider {
url: finalURL,
baseURL: "",
};
const response = await axios.request<PCGamingWikiCargoResult<T>>(
const response = await axios.request<T>(
Object.assign({}, options, overlay),
);
if (response.status !== 200)
throw new Error(
`Error in pcgamingwiki \nStatus Code: ${response.status}`,
`Error in pcgamingwiki \nStatus Code: ${response.status}\n${response.data}`,
);
else if (response.data.error !== undefined)
throw new Error(`Error in pcgamingwiki, malformed query`);
return response;
}
private async cargoQuery<T>(
query: URLSearchParams,
options?: AxiosRequestConfig,
) {
const response = await this.request<PCGamingWikiCargoResult<T>>(
query,
options,
);
if (response.data.error !== undefined)
throw new Error(`Error in pcgamingwiki cargo query`);
return response;
}
/**
* Gets the raw wiki page for parsing,
* requested values are to be considered unstable as compared to cargo queries
* @param pageID
* @returns
*/
private async getPageContent(pageID: string) {
const searchParams = new URLSearchParams({
action: "parse",
format: "json",
pageid: pageID,
});
const res = await this.request<PCGamingWikiParseRawPage>(searchParams);
const $ = cheerio.load(res.data.parse.text["*"]);
// get intro based on 'introduction' class
const introductionEle = $(".introduction").first();
// remove citations from intro
introductionEle.find("sup").remove();
const infoBoxEle = $(".template-infobox").first();
const receptionEle = infoBoxEle
.find(".template-infobox-header")
.filter((_, el) => $(el).text().trim() === "Reception");
const receptionResults: (GameMetadataRating | undefined)[] = [];
if (receptionEle.length > 0) {
// we have a match!
const ratingElements = infoBoxEle.find(".template-infobox-type");
// TODO: cleanup this ratnest
const parseIdFromHref = (href: string): string | undefined => {
const url = new URL(href);
const opencriticRegex = /^\/game\/(\d+)\/.+$/;
switch (url.hostname.toLocaleLowerCase()) {
case "www.metacritic.com": {
// https://www.metacritic.com/game/elden-ring/critic-reviews/?platform=pc
return url.pathname
.replace("/game/", "")
.replace("/critic-reviews", "")
.replace(/\/$/, "");
}
case "opencritic.com": {
// https://opencritic.com/game/12090/elden-ring
let id = "unknown";
let matches;
if ((matches = opencriticRegex.exec(url.pathname)) !== null) {
matches.forEach((match, _groupIndex) => {
// console.log(`Found match, group ${_groupIndex}: ${match}`);
id = match;
});
}
if (id === "unknown") {
return undefined;
}
return id;
}
case "www.igdb.com": {
// https://www.igdb.com/games/elden-ring
return url.pathname.replace("/games/", "").replace(/\/$/, "");
}
default: {
console.warn("Pcgamingwiki, unknown host", url.hostname);
return undefined;
}
}
};
const getRating = (
source: MetadataSource,
): GameMetadataRating | undefined => {
const providerEle = ratingElements.filter(
(_, el) =>
$(el).text().trim().toLocaleLowerCase() ===
source.toLocaleLowerCase(),
);
if (providerEle.length > 0) {
// get info associated with provider
const reviewEle = providerEle
.first()
.parent()
.find(".template-infobox-info")
.find("a")
.first();
const href = reviewEle.attr("href");
if (!href) {
console.log(
`pcgamingwiki: failed to properly get review href for ${source}`,
);
return undefined;
}
const ratingObj = ratingProviderReview({
rating: reviewEle.text().trim(),
});
if (ratingObj instanceof type.errors) {
console.log(
"pcgamingwiki: failed to properly get review rating",
ratingObj.summary,
);
return undefined;
}
const id = parseIdFromHref(href);
if (!id) return undefined;
return {
mReviewHref: href,
metadataId: id,
metadataSource: source,
mReviewCount: 0,
// make float within 0 to 1
mReviewRating: ratingObj.rating / 100,
};
}
return undefined;
};
receptionResults.push(getRating(MetadataSource.Metacritic));
receptionResults.push(getRating(MetadataSource.IGDB));
receptionResults.push(getRating(MetadataSource.OpenCritic));
}
return {
shortIntro: introductionEle.find("p").first().text().trim(),
introduction: introductionEle.text().trim(),
reception: receptionResults,
};
}
async search(query: string) {
const searchParams = new URLSearchParams({
action: "cargoquery",
@ -99,43 +275,58 @@ export class PCGamingWikiProvider implements MetadataProvider {
format: "json",
});
const res = await this.request<PCGamingWikiSearchStub>(searchParams);
const response =
await this.cargoQuery<PCGamingWikiSearchStub>(searchParams);
const mapped = res.data.cargoquery.map((result) => {
const results: GameMetadataSearchResult[] = [];
for (const result of response.data.cargoquery) {
const game = result.title;
const pageContent = await this.getPageContent(game.PageID);
const metadata: GameMetadataSearchResult = {
results.push({
id: game.PageID,
name: game.PageName,
icon: game["Cover URL"] ?? "",
description: "", // TODO: need to render the `Introduction` template somehow (or we could just hardcode it)
description: pageContent.shortIntro,
year:
game.Released !== null && game.Released.length > 0
? // sometimes will provide multiple dates
this.parseTS(game.Released).year
: 0,
};
return metadata;
});
});
}
return mapped;
return results;
}
/**
* Parses the specific format that the wiki returns when specifying a company
* @param companyStr
* Parses the specific format that the wiki returns when specifying an array
* @param input string or array
* @returns
*/
private parseCompanyStr(companyStr: string): string[] {
const results: string[] = [];
// provides the string as a list of companies
// ie: "Company:Digerati Distribution,Company:Greylock Studio"
const items = companyStr.split(",");
private parseWikiStringArray(input: string | string[]): string[] {
const cleanStr = (str: string): string => {
// remove any dumb prefixes we don't care about
return str.replace("Company:", "").trim();
};
items.forEach((item) => {
// remove the `Company:` and trim and whitespace
results.push(item.replace("Company:", "").trim());
});
// input can provides the string as a list
// ie: "Company:Digerati Distribution,Company:Greylock Studio"
// or as an array, sometimes the array has empty values
const results: string[] = [];
if (Array.isArray(input)) {
input.forEach((c) => {
const clean = cleanStr(c);
if (clean !== "") results.push(clean);
});
} else {
const items = input.split(",");
items.forEach((item) => {
const clean = cleanStr(item);
if (clean !== "") results.push(clean);
});
}
return results;
}
@ -156,6 +347,28 @@ export class PCGamingWikiProvider implements MetadataProvider {
return websiteStr.replaceAll(/\[|]/g, "").split(" ")[0] ?? "";
}
private compileTags(game: PCGamingWikiGame): string[] {
const results: string[] = [];
const properties: StringArrayKeys<PCGamingWikiGame>[] = [
"Art styles",
"Genres",
"Modes",
"Pacing",
"Perspectives",
"Themes",
];
// loop through all above keys, get the tags they contain
properties.forEach((p) => {
if (game[p] === null) return;
results.push(...this.parseWikiStringArray(game[p]));
});
return results;
}
async fetchGame({
id,
name,
@ -167,12 +380,15 @@ export class PCGamingWikiProvider implements MetadataProvider {
action: "cargoquery",
tables: "Infobox_game",
fields:
"Infobox_game._pageID=PageID,Infobox_game._pageName=PageName,Infobox_game.Cover_URL,Infobox_game.Developers,Infobox_game.Released,Infobox_game.Genres,Infobox_game.Publishers,Infobox_game.Themes,Infobox_game.Series,Infobox_game.Modes",
"Infobox_game._pageID=PageID,Infobox_game._pageName=PageName,Infobox_game.Cover_URL,Infobox_game.Developers,Infobox_game.Released,Infobox_game.Genres,Infobox_game.Publishers,Infobox_game.Themes,Infobox_game.Series,Infobox_game.Modes,Infobox_game.Perspectives,Infobox_game.Art_styles,Infobox_game.Pacing",
where: `Infobox_game._pageID="${id}"`,
format: "json",
});
const res = await this.request<PCGamingWikiGame>(searchParams);
const [res, pageContent] = await Promise.all([
this.cargoQuery<PCGamingWikiGame>(searchParams),
this.getPageContent(id),
]);
if (res.data.cargoquery.length < 1)
throw new Error("Error in pcgamingwiki, no game");
@ -180,7 +396,7 @@ export class PCGamingWikiProvider implements MetadataProvider {
const publishers: Company[] = [];
if (game.Publishers !== null) {
const pubListClean = this.parseCompanyStr(game.Publishers);
const pubListClean = this.parseWikiStringArray(game.Publishers);
for (const pub of pubListClean) {
const res = await publisher(pub);
if (res === undefined) continue;
@ -190,7 +406,7 @@ export class PCGamingWikiProvider implements MetadataProvider {
const developers: Company[] = [];
if (game.Developers !== null) {
const devListClean = this.parseCompanyStr(game.Developers);
const devListClean = this.parseWikiStringArray(game.Developers);
for (const dev of devListClean) {
const res = await developer(dev);
if (res === undefined) continue;
@ -206,15 +422,15 @@ export class PCGamingWikiProvider implements MetadataProvider {
const metadata: GameMetadata = {
id: game.PageID,
name: game.PageName,
shortDescription: "", // TODO: (again) need to render the `Introduction` template somehow (or we could just hardcode it)
description: "",
shortDescription: pageContent.shortIntro,
description: pageContent.introduction,
released: game.Released
? DateTime.fromISO(game.Released.split(";")[0]).toJSDate()
: new Date(),
reviewCount: 0,
reviewRating: 0,
tags: this.compileTags(game),
reviews: pageContent.reception.filter((v) => typeof v !== "undefined"),
publishers,
developers,
@ -240,16 +456,16 @@ export class PCGamingWikiProvider implements MetadataProvider {
format: "json",
});
const res = await this.request<PCGamingWikiCompany>(searchParams);
const res = await this.cargoQuery<PCGamingWikiCompany>(searchParams);
// TODO: replace
// TODO: replace with company logo
const icon = createObject(jdenticon.toPng(query, 512));
for (let i = 0; i < res.data.cargoquery.length; i++) {
const company = res.data.cargoquery[i].title;
const fixedCompanyName =
this.parseCompanyStr(company.PageName)[0] ?? company.PageName;
this.parseWikiStringArray(company.PageName)[0] ?? company.PageName;
const metadata: CompanyMetadata = {
id: company.PageID,

View File

@ -1,4 +1,4 @@
import type { Company } from "~/prisma/client";
import type { Company, GameRating } from "~/prisma/client";
import type { TransactionDataType } from "../objects/transactional";
import type { ObjectReference } from "../objects/objectHandler";
@ -18,6 +18,15 @@ export interface GameMetadataSource {
export type InternalGameMetadataResult = GameMetadataSearchResult &
GameMetadataSource;
export type GameMetadataRating = Pick<
GameRating,
| "metadataSource"
| "metadataId"
| "mReviewCount"
| "mReviewHref"
| "mReviewRating"
>;
export interface GameMetadata {
id: string;
name: string;
@ -30,8 +39,9 @@ export interface GameMetadata {
publishers: Company[];
developers: Company[];
reviewCount: number;
reviewRating: number;
tags: string[];
reviews: GameMetadataRating[];
// Created with another utility function
icon: ObjectReference;

View File

@ -10,6 +10,9 @@ import type { Notification } from "~/prisma/client";
import prisma from "../db/database";
import type { GlobalACL } from "../acls";
// type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
// TODO: document notification action format
export type NotificationCreateArgs = Pick<
Notification,
"title" | "description" | "actions" | "nonce"
@ -72,14 +75,18 @@ class NotificationSystem {
throw new Error("No nonce in notificationCreateArgs");
const notification = await prisma.notification.upsert({
where: {
nonce: notificationCreateArgs.nonce,
userId_nonce: {
nonce: notificationCreateArgs.nonce,
userId,
},
},
update: {
userId: userId,
// we don't need to update the userid right?
// userId: userId,
...notificationCreateArgs,
},
create: {
userId: userId,
userId,
...notificationCreateArgs,
},
});
@ -87,6 +94,27 @@ class NotificationSystem {
await this.pushNotification(userId, notification);
}
/**
* Internal call to batch push notifications to many users
* @param notificationCreateArgs
* @param users
*/
private async _pushMany(
notificationCreateArgs: NotificationCreateArgs,
users: { id: string }[],
) {
const res: Promise<void>[] = [];
for (const user of users) {
res.push(this.push(user.id, notificationCreateArgs));
}
// wait for all notifications to pass
await Promise.all(res);
}
/**
* Send a notification to all users
* @param notificationCreateArgs
*/
async pushAll(notificationCreateArgs: NotificationCreateArgs) {
const users = await prisma.user.findMany({
where: { id: { not: "system" } },
@ -95,13 +123,27 @@ class NotificationSystem {
},
});
for (const user of users) {
await this.push(user.id, notificationCreateArgs);
}
await this._pushMany(notificationCreateArgs, users);
}
/**
* Send a notification to all system level users
* @param notificationCreateArgs
* @returns
*/
async systemPush(notificationCreateArgs: NotificationCreateArgs) {
return await this.pushAll(notificationCreateArgs);
const users = await prisma.user.findMany({
where: {
id: { not: "system" },
// no reason to send to any users other then admins rn
admin: true,
},
select: {
id: true,
},
});
await this._pushMany(notificationCreateArgs, users);
}
}

View File

@ -1,5 +1,5 @@
import type { ObjectMetadata, ObjectReference, Source } from "./objectHandler";
import { ObjectBackend } from "./objectHandler";
import { ObjectBackend, objectMetadata } from "./objectHandler";
import fs from "fs";
import path from "path";
@ -7,16 +7,20 @@ import { Readable } from "stream";
import { createHash } from "crypto";
import prisma from "../db/database";
import cacheHandler from "../cache";
import { systemConfig } from "../config/sys-conf";
import { type } from "arktype";
export class FsObjectBackend extends ObjectBackend {
private baseObjectPath: string;
private baseMetadataPath: string;
private hashStore = new FsHashStore();
private metadataCache =
cacheHandler.createCache<ObjectMetadata>("ObjectMetadata");
constructor() {
super();
const basePath = process.env.FS_BACKEND_PATH ?? "./.data/objects";
const basePath = path.join(systemConfig.getDataFolder(), "objects");
this.baseObjectPath = path.join(basePath, "objects");
this.baseMetadataPath = path.join(basePath, "metadata");
@ -98,17 +102,30 @@ export class FsObjectBackend extends ObjectBackend {
const objectPath = path.join(this.baseObjectPath, id);
if (!fs.existsSync(objectPath)) return true;
fs.rmSync(objectPath);
// remove item from cache
const metadataPath = path.join(this.baseMetadataPath, `${id}.json`);
if (!fs.existsSync(metadataPath)) return true;
fs.rmSync(metadataPath);
// remove item from caches
await this.metadataCache.remove(id);
await this.hashStore.delete(id);
return true;
}
async fetchMetadata(
id: ObjectReference,
): Promise<ObjectMetadata | undefined> {
const cacheResult = await this.metadataCache.get(id);
if (cacheResult !== null) return cacheResult;
const metadataPath = path.join(this.baseMetadataPath, `${id}.json`);
if (!fs.existsSync(metadataPath)) return undefined;
const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
return metadata as ObjectMetadata;
const metadataRaw = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
const metadata = objectMetadata(metadataRaw);
if (metadata instanceof type.errors) {
console.error("FsObjectBackend#fetchMetadata", metadata.summary);
return undefined;
}
await this.metadataCache.set(id, metadata);
return metadata;
}
async writeMetadata(
id: ObjectReference,
@ -117,6 +134,7 @@ export class FsObjectBackend extends ObjectBackend {
const metadataPath = path.join(this.baseMetadataPath, `${id}.json`);
if (!fs.existsSync(metadataPath)) return false;
fs.writeFileSync(metadataPath, JSON.stringify(metadata));
await this.metadataCache.set(id, metadata);
return true;
}
async fetchHash(id: ObjectReference): Promise<string | undefined> {
@ -152,9 +170,34 @@ export class FsObjectBackend extends ObjectBackend {
await store.save(id, hashResult);
return typeof hashResult;
}
async listAll(): Promise<string[]> {
return fs.readdirSync(this.baseObjectPath);
}
async cleanupMetadata() {
const metadataFiles = fs.readdirSync(this.baseMetadataPath);
const objects = await this.listAll();
const extraFiles = metadataFiles.filter(
(file) => !objects.includes(file.replace(/\.json$/, "")),
);
console.log(
`[FsObjectBackend#cleanupMetadata]: Found ${extraFiles.length} metadata files without corresponding objects.`,
);
for (const file of extraFiles) {
const filePath = path.join(this.baseMetadataPath, file);
try {
fs.rmSync(filePath);
console.log(`[FsObjectBackend#cleanupMetadata]: Removed ${file}`);
} catch (error) {
console.error(
`[FsObjectBackend#cleanupMetadata]: Failed to remove ${file}`,
error,
);
}
}
}
}
class FsHashStore {

View File

@ -14,17 +14,22 @@
* anotherUserId:write
*/
import { type } from "arktype";
import { parse as getMimeTypeBuffer } from "file-type-mime";
import type { Writable } from "stream";
import { Readable } from "stream";
import { getMimeType as getMimeTypeStream } from "stream-mime-type";
export type ObjectReference = string;
export type ObjectMetadata = {
mime: string;
permissions: string[];
userMetadata: { [key: string]: string };
};
export const objectMetadata = type({
mime: "string",
permissions: "string[]",
userMetadata: {
"[string]": "string",
},
});
export type ObjectMetadata = typeof objectMetadata.infer;
export enum ObjectPermission {
Read = "read",
@ -66,6 +71,7 @@ export abstract class ObjectBackend {
): Promise<boolean>;
abstract fetchHash(id: ObjectReference): Promise<string | undefined>;
abstract listAll(): Promise<string[]>;
abstract cleanupMetadata(): Promise<void>;
}
export class ObjectHandler {
@ -252,4 +258,13 @@ export class ObjectHandler {
async listAll() {
return await this.backend.listAll();
}
/**
* Purges metadata for objects that no longer exist
* This is useful for cleaning up metadata files that are left behinds
* @returns
*/
async cleanupMetadata() {
return await this.backend.cleanupMetadata();
}
}

View File

@ -0,0 +1,50 @@
import prisma from "../db/database";
class PlaytimeManager {
/**
* Get a user's playtime on a game
* @param gameId
* @param userId
* @returns
*/
async get(gameId: string, userId: string) {
return await prisma.playtime.findUnique({
where: {
gameId_userId: {
gameId,
userId,
},
},
});
}
/**
* Add time to a user's playtime
* @param gameId
* @param userId
* @param seconds seconds played
*/
async add(gameId: string, userId: string, seconds: number) {
await prisma.playtime.upsert({
where: {
gameId_userId: {
gameId,
userId,
},
},
create: {
gameId,
userId,
seconds,
},
update: {
seconds: {
increment: seconds,
},
},
});
}
}
export const playtimeManager = new PlaytimeManager();
export default playtimeManager;

View File

@ -5,6 +5,11 @@ import stream from "node:stream/promises";
import prisma from "../db/database";
class ScreenshotManager {
/**
* Gets a specific screenshot
* @param id
* @returns
*/
async get(id: string) {
return await prisma.screenshot.findUnique({
where: {
@ -13,7 +18,27 @@ class ScreenshotManager {
});
}
async getAllByGame(gameId: string, userId: string) {
/**
* Get all user screenshots
* @param userId
* @returns
*/
async getUserAll(userId: string) {
const results = await prisma.screenshot.findMany({
where: {
userId,
},
});
return results;
}
/**
* Get all user screenshots in a specific game
* @param userId
* @param gameId
* @returns
*/
async getUserAllByGame(userId: string, gameId: string) {
const results = await prisma.screenshot.findMany({
where: {
gameId,
@ -23,6 +48,10 @@ class ScreenshotManager {
return results;
}
/**
* Delete a specific screenshot
* @param id
*/
async delete(id: string) {
await prisma.screenshot.delete({
where: {
@ -31,9 +60,22 @@ class ScreenshotManager {
});
}
async upload(gameId: string, userId: string, inputStream: IncomingMessage) {
/**
* Allows a user to upload a screenshot
* @param userId
* @param gameId
* @param inputStream
*/
async upload(userId: string, gameId: string, inputStream: IncomingMessage) {
const objectId = randomUUID();
const saveStream = await objectHandler.createWithStream(objectId, {}, []);
const saveStream = await objectHandler.createWithStream(
objectId,
{
// TODO: set createAt to the time screenshot was taken
createdAt: new Date().toISOString(),
},
[`${userId}:read`, `${userId}:delete`],
);
if (!saveStream)
throw createError({
statusCode: 500,
@ -43,12 +85,12 @@ class ScreenshotManager {
// pipe into object store
await stream.pipeline(inputStream, saveStream);
// TODO: set createAt to the time screenshot was taken
await prisma.screenshot.create({
data: {
gameId,
userId,
objectId,
private: true,
},
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<TaskReturn>({
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<boolean>[] = [];
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,

254
yarn.lock
View File

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