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