From c6bb21d9eefa1e5615f8daa5e8b57a36974d7cf5 Mon Sep 17 00:00:00 2001 From: quexeky <6-quexeky@users.noreply.lab.deepcore.dev> Date: Wed, 8 Jan 2025 22:43:30 +0000 Subject: [PATCH 001/156] fix: Update README.md with discord link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4916f96..33d1533 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![GitHub License](https://img.shields.io/github/license/Drop-OSS/drop-app)](LICENSE) [![Gitlab Pipeline Status](https://img.shields.io/gitlab/pipeline-status/drop-oss%2Fdrop?gitlab_url=https%3A%2F%2Flab.deepcore.dev)](https://lab.deepcore.dev/drop-oss/drop/-/pipelines) -[![Discord](https://img.shields.io/discord/1291622805124812871?label=discord)](https://discord.gg/ZVGggfXN) +[![Discord](https://img.shields.io/discord/1291622805124812871?label=discord)](https://discord.gg/ACq4qZp4a9) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-%23FE5196?logo=conventionalcommits&logoColor=white)](https://conventionalcommits.org) # Drop From 9f456cec9d8a7e958a94e4bc76977edea2c5a2a9 Mon Sep 17 00:00:00 2001 From: quexeky <116044207+quexeky@users.noreply.github.com> Date: Sat, 25 Jan 2025 22:49:06 +1100 Subject: [PATCH 002/156] chore: Update changelog.md --- changelog.md | 192 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) diff --git a/changelog.md b/changelog.md index 73ad7c9..c362858 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,197 @@ +## Release 0.2.0-beta + +### Fixes +- fix recursive dirs util #02d6346 +- Fix username length requirement #0a5a649 +- remove dynamic imports #0f10626 +- fix for missing developers or publishers #25fc957 +- split prisma schemas #2859005 +- results are returned alphabetically #33d3770 +- update prisma schemas #36776cc +- removed global flag #43e32b4 +- properly disconnect websockets from task handler #5358f1f +- follow best practices #54c5d55 +- future lenience #5c78b20 +- fix width of token breaking things #61d88c3 +- fixed websocket authentication #62ea9a1 +- fix delta manifest generation #6df560c +- admin invitation w/ system user #8463e35 +- properly import icons #8945196 +- prisma create footprint #952ece8 +- game panel now always shows 3 lines exactly #9c2249e +- remove unnecessary import #a361c38 +- fix disconnect code #a8f2106 +- fix types #b511b40 +- add drop-base as git submodule #b75ebd1 +- Update README.md with discord link #c6bb21d +- fix expires requirement in the admin endpoint #c7b675f +- fix always being created as admin #c7eb11a +- moved icons and created PlatformClient so we can use the enum on the frontend #cada630 +- recurse submodules #db103de +- fix FATAL: "root"... message #dbb315a +- only show versions that are directories #ef8f3ae + + +### Features +- update prisma & delete games #089c3e0 +- manual handshake #12e3125 +- fetch game endpoint #1f4d075 +- under the hood organisation and consolidation #26a31f6 +- 'no images' slide on image carousel #28baabc +- improve feedback when metadata fails #2c19e13 +- introduction of 'system user' #2c21a23 +- change name, description and icon #2cfe75a +- 'manual' metadata provider #2f52a16 +- add disabled state #38fc6b8 +- overhauled version importing #39d7ce7 +- automatically create library folder if it doesn't exist #39fe9d5 +- smoother bar in admin task ui #4488ae2 +- add noWrapper option #4f9b949 +- add version metadata route #5393db3 +- completed admin UI, with minor changes to backend #599da0e +- adjust gradient #5a1f841 +- keep track of last connected #69e4c25 +- added notification system w/ interwoven refactoring #6e6f09d +- content length header for chunk downloads #76bceb1 +- add title to tab #7b0756c +- add button to open in admin panel #7b3b919 +- client capability framework + peer API configuration #7d72a86 +- customisable image carousel and new layout #937954f +- support more types #9b12d45 +- generate a server certificate for mtls APIs #9c4b6f3 +- new endpoints, ui and beginnings of main store page #9cbdcbc +- backend #a309651 +- more subtle design improvements #a815542 +- add aden's carousel pagination design #a86045c +- add header #a8a152e +- client side search #b50e27f +- new ws handler #bc0c47c +- user widget now redirects to actual page #bfafe02 +- require lowercase usernames #d7160ab +- more ui improvements #e408ac5 +- add modifying game descriptions #e505e58 +- mobile nav #e5cf13f +- slightly improved game page #e796b46 +- game carousel #ecc819e +- add enum dictionary type #f2e0182 +- improved ux #f3ed0f6 +- cleanup and raw accessors #f7d767d +- add support for overriding UMU id #fd4a7d1 +- add .sh for linux #fe9373a + + +### Other Changes +- quexeky +- fixed manifest generation #03a37f7 +- manual ci/cd #03b0b0c +- ability to fetch client certs for p2p #0a715fe +- disable tls in build #0f80fcd +- Updated README.md #17971e0 +- Merge pull request #18 from Drop-OSS/develop +- initial work on metadata system #196f87c +- more ui #1bd19ad +- remove log statements #1d5e1bd +- small fixes & SSR disabled #1f575b2 +- update information and setup guide #2236622 +- metadata engine #22ac7f6 +- Update CONTRIBUTING.md #2309407 +- slight bug fixes and clean up #24a0d11 +- almst complete admin ui and initial store designs #27070b6 +- handshakes #2b4382d +- user mobile header #2e44ef3 +- more consistent naming for globals #305de9f +- replaced markdown-it with micromark #31e8359 +- fixes to store page for mobile clients #328b9ba +- game version re-ordering #329c74d +- verbose yarn install #36568c3 +- patch for no version check in manifest generation #395219d +- migrate bcrypt to bcryptjs #3a51c9c +- added download chunk endpoint #3dd6062 +- Update README.md #425934d +- build only ci #4273a20 +- object storage + full permission system + testing #435551c +- rename admin socket session map #44c6028 +- bump droplet and add vue carousel #46551f9 +- version importing #46c8f0c +- back to yarn, with nuxt telemetry force disabled #46d35ad +- finished object endpoints #486bce8 +- update dependencies and add note about optional dependencies #4fa771a +- use configuration from docs for ci/cd #52315d0 +- slight fixes to register logic #583301f +- removed yarn.lock #584bcf1 +- Version bump #5f29c28 +- immutable application settings framework #5fe2036 +- fixed docker daemon location #62a111b +- copy autodevops configuration #6328c24 +- Delete .gitlab-ci.yml #69f341b +- admin ui shell #6b5e48d +- bump @drop/droplet version for windows developers #6ba5cdd +- Add LICENSE #6e2dc89 +- custom dind #716eac7 +- task API #718f5ba +- use gitlab ci variable declaration #7194d35 +- move icons into dedicated folder #74fa671 +- another stage of client authentication #7523e53 +- refactoring #7869043 +- moved windows logo into logos dir #789d3ba +- updated text colours across app #7a88f4c +- starting docs infra #7d2a1c6 +- more cleaning #7e17626 +- slight patch to rename query to be more consistent #7f4db0c +- move to raw docker #803752e +- server side and user client side completed for registration #848a611 +- beginnings of download implementation #8674ac7 +- more consistent naming for object handler #87230fb +- use autodevops build stage #886beb6 +- Updated tailwind config #88c95d6 +- change name of store file #8999303 +- split prisma schemas #9011cf5 +- client initiate #909432a +- more client routes to support Drop app update #91b7e10 +- additional polish and QoL features #93bc143 +- upload images to games #9b7ee4e +- migrate to pnpm due to ci/cd issues with yarn #9cb2d6d +- run yarn install in CI/CD non interactively #a208fbe +- completed game importing; partial work on version importing #a7c33e7 +- remove canvas from dependencies #a8f58eb +- fix registry authentication #ad25d3e +- consolidate type utils #adb4b73 +- Updated README.md #b0ef675 +- add proper carousel to store page #b2ab827 +- move to yarn v2 #b744671 +- remove client API deadweight #b9ae26c +- add expires field #be6c30d +- ca groundwork #bfafd2a +- cleanup & polish #c355f6f +- remove bcrypt (debug) #c3914cc +- non rounded bottom #c4391d3 +- failed gracefully on invalid chunk index #c4a3e4e +- update deploy template #c4a419f +- migrate to new droplet ca system #c4d8113 +- docker based deployment #c5d00b4 +- updated CONTRIBUTING.md #cd0d2bf +- update prisma version #ce0a9ab +- README update #ceacd84 +- patch metadata handler #cf578bd +- Added SECURITY.md #d3d93b0 +- finalised client APIs and authentication method #d4e2dc8 +- Update README.md #db916bf +- object storage interface + utility functions #de388a9 +- initial commit #e1a789f +- fixed task system #e1c1d7e +- Update file chunk.get.ts #e4339c3 +- ui groundwork #e52f072 +- Update changelog #eadcaa1 +- check for no version in manifest generation #eb3f9f9 +- break into single column store on lg devices #ecb381e +- better server side signin redirects #ef13b68 +- patch signin #f3672f8 + + +_changelog generated by_ [go-conventional-commits](https://github.com/joselitofilho/go-conventional-commits) + ## Release 0.1.0-beta ### Fixes From aad5c23f452294906d43b8ade1371c541f112c52 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Tue, 28 Jan 2025 15:20:06 +1100 Subject: [PATCH 003/156] fix: import ui setup autocomplete --- drop-base | 2 +- pages/admin/library/[id]/import.vue | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/drop-base b/drop-base index 01fd41c..de0d1b4 160000 --- a/drop-base +++ b/drop-base @@ -1 +1 @@ -Subproject commit 01fd41c65ae288eb19bbc92b2625733afe51c101 +Subproject commit de0d1b46603a4140ad0ff2c149e4fd91277c2fff diff --git a/pages/admin/library/[id]/import.vue b/pages/admin/library/[id]/import.vue index 1d94a1c..3e688b1 100644 --- a/pages/admin/library/[id]/import.vue +++ b/pages/admin/library/[id]/import.vue @@ -104,7 +104,7 @@ :placeholder="'setup.exe'" />
  • - "{{ launchProcessQuery }}" + "{{ setupProcessQuery }}" Date: Wed, 12 Feb 2025 12:05:47 +1100 Subject: [PATCH 004/156] fix: null strings in setup versions --- .../migration.sql | 20 +++++++++++++++++++ prisma/schema/content.prisma | 4 ++-- 2 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20250211230021_ensure_non_null_launch_and_setup_commands/migration.sql diff --git a/prisma/migrations/20250211230021_ensure_non_null_launch_and_setup_commands/migration.sql b/prisma/migrations/20250211230021_ensure_non_null_launch_and_setup_commands/migration.sql new file mode 100644 index 0000000..79a655d --- /dev/null +++ b/prisma/migrations/20250211230021_ensure_non_null_launch_and_setup_commands/migration.sql @@ -0,0 +1,20 @@ +/* + Warnings: + + - Made the column `launchCommand` on table `GameVersion` required. This step will fail if there are existing NULL values in that column. + - Made the column `setupCommand` on table `GameVersion` required. This step will fail if there are existing NULL values in that column. + +*/ +UPDATE "GameVersion" +SET "launchCommand" = '' +WHERE "launchCommand" is NULL; + +UPDATE "GameVersion" +SET "setupCommand" = '' +WHERE "launchCommand" is NULL; + +-- AlterTable +ALTER TABLE "GameVersion" ALTER COLUMN "launchCommand" SET NOT NULL, +ALTER COLUMN "launchCommand" SET DEFAULT '', +ALTER COLUMN "setupCommand" SET NOT NULL, +ALTER COLUMN "setupCommand" SET DEFAULT ''; diff --git a/prisma/schema/content.prisma b/prisma/schema/content.prisma index dc278a1..f64fdac 100644 --- a/prisma/schema/content.prisma +++ b/prisma/schema/content.prisma @@ -46,9 +46,9 @@ model GameVersion { platform Platform - launchCommand String? // Command to run to start. Platform-specific. Windows games on Linux will wrap this command in Proton/Wine + launchCommand String @default("") // Command to run to start. Platform-specific. Windows games on Linux will wrap this command in Proton/Wine launchArgs String[] - setupCommand String? // Command to setup game (dependencies and such) + setupCommand String @default("") // Command to setup game (dependencies and such) setupArgs String[] onlySetup Boolean @default(false) From 3df6818ffeadb89334bb57f334a16ce645b6f833 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Sat, 10 May 2025 15:16:26 -0400 Subject: [PATCH 005/156] feat: openapi support plus more api validation --- nuxt.config.ts | 20 ++++++++- .../v1/admin/auth/invitation/index.delete.ts | 22 +++++++--- .../v1/admin/auth/invitation/index.post.ts | 41 ++++++++----------- .../api/v1/admin/game/image/index.delete.ts | 23 ++++++++--- server/api/v1/auth/signin/simple.post.ts | 4 +- server/api/v1/auth/signup/simple.post.ts | 5 ++- server/routes/auth/callback/oidc.get.ts | 8 ++++ server/routes/auth/oidc.get.ts | 8 ++++ server/routes/auth/signout.get.ts | 8 ++++ 9 files changed, 100 insertions(+), 39 deletions(-) diff --git a/nuxt.config.ts b/nuxt.config.ts index e4e014d..440e1b5 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,5 +1,7 @@ import tailwindcss from "@tailwindcss/vite"; +const dropVersion = "0.3"; + // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ // Nuxt-only config @@ -31,24 +33,38 @@ export default defineNuxtConfig({ }, }, + appConfig: { + dropVersion: dropVersion, + }, + routeRules: { "/api/**": { cors: true }, }, nitro: { minify: true, + compressPublicAssets: true, experimental: { websocket: true, tasks: true, + openAPI: true, + }, + + openAPI: { + // tracking for dynamic openapi schema https://github.com/nitrojs/nitro/issues/2974 + meta: { + title: "Drop", + description: + "Drop is an open-source, self-hosted game distribution platform, creating a Steam-like experience for DRM-free games.", + version: dropVersion, + }, }, scheduledTasks: { "0 * * * *": ["cleanup:invitations", "cleanup:sessions"], }, - compressPublicAssets: true, - storage: { appCache: { driver: "lru-cache", diff --git a/server/api/v1/admin/auth/invitation/index.delete.ts b/server/api/v1/admin/auth/invitation/index.delete.ts index 381dea3..34d24bd 100644 --- a/server/api/v1/admin/auth/invitation/index.delete.ts +++ b/server/api/v1/admin/auth/invitation/index.delete.ts @@ -1,20 +1,30 @@ +import { type } from "arktype"; import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; -export default defineEventHandler(async (h3) => { +const DeleteInvite = type({ + id: "string", +}); + +export default defineEventHandler<{ + body: typeof DeleteInvite.infer; +}>(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, [ "auth:simple:invitation:delete", ]); if (!allowed) throw createError({ statusCode: 403 }); - const body = await readBody(h3); - const id = body.id; - if (!id) + const body = DeleteInvite(await readBody(h3)); + if (body instanceof type.errors) { + // hover out.summary to see validation errors + console.error(body.summary); + throw createError({ statusCode: 400, - statusMessage: "id required for deletion", + statusMessage: body.summary, }); + } - await prisma.invitation.delete({ where: { id: id } }); + await prisma.invitation.delete({ where: { id: body.id } }); return {}; }); diff --git a/server/api/v1/admin/auth/invitation/index.post.ts b/server/api/v1/admin/auth/invitation/index.post.ts index 015557e..58305c2 100644 --- a/server/api/v1/admin/auth/invitation/index.post.ts +++ b/server/api/v1/admin/auth/invitation/index.post.ts @@ -1,40 +1,35 @@ +import { type } from "arktype"; import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; -export default defineEventHandler(async (h3) => { +const CreateInvite = type({ + isAdmin: "boolean", + username: "string", + email: "string.email", + expires: "string.date.iso.parse", +}); + +export default defineEventHandler<{ + body: typeof CreateInvite.infer; +}>(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, [ "auth:simple:invitation:new", ]); if (!allowed) throw createError({ statusCode: 403 }); - const body = await readBody(h3); - const isAdmin = body.isAdmin; - const username = body.username; - const email = body.email; - const expires = body.expires; + const body = CreateInvite(await readBody(h3)); + if (body instanceof type.errors) { + // hover out.summary to see validation errors + console.error(body.summary); - if (!expires) - throw createError({ statusCode: 400, statusMessage: "No expires field." }); - if (isAdmin !== undefined && typeof isAdmin !== "boolean") throw createError({ statusCode: 400, - statusMessage: "isAdmin must be a boolean", - }); - - const expiresDate = new Date(expires); - if (!(expiresDate instanceof Date && !isNaN(expiresDate.getTime()))) - throw createError({ - statusCode: 400, - statusMessage: "Invalid expires date", + statusMessage: body.summary, }); + } const invitation = await prisma.invitation.create({ - data: { - isAdmin: isAdmin, - username: username, - email: email, - expires: expiresDate, - }, + data: body, }); return invitation; diff --git a/server/api/v1/admin/game/image/index.delete.ts b/server/api/v1/admin/game/image/index.delete.ts index 3558584..9d9754e 100644 --- a/server/api/v1/admin/game/image/index.delete.ts +++ b/server/api/v1/admin/game/image/index.delete.ts @@ -1,20 +1,31 @@ import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; import objectHandler from "~/server/internal/objects"; +import { type } from "arktype"; -export default defineEventHandler(async (h3) => { +const ModifyGameImage = type({ + gameId: "string", + imageId: "string", +}); + +export default defineEventHandler<{ + body: typeof ModifyGameImage.infer; +}>(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, ["game:image:delete"]); if (!allowed) throw createError({ statusCode: 403 }); - const body = await readBody(h3); - const gameId = body.gameId; - const imageId = body.imageId; + const body = ModifyGameImage(await readBody(h3)); + if (body instanceof type.errors) { + // hover out.summary to see validation errors + console.error(body.summary); - if (!gameId || !imageId) throw createError({ statusCode: 400, - statusMessage: "Missing gameId or imageId in body", + statusMessage: body.summary, }); + } + const gameId = body.gameId; + const imageId = body.imageId; const game = await prisma.game.findUnique({ where: { diff --git a/server/api/v1/auth/signin/simple.post.ts b/server/api/v1/auth/signin/simple.post.ts index 42eacdf..f184ca8 100644 --- a/server/api/v1/auth/signin/simple.post.ts +++ b/server/api/v1/auth/signin/simple.post.ts @@ -15,7 +15,9 @@ const signinValidator = type({ "rememberMe?": "boolean | undefined", }); -export default defineEventHandler(async (h3) => { +export default defineEventHandler<{ + body: typeof signinValidator.infer; +}>(async (h3) => { if (!enabledAuthManagers.Simple) throw createError({ statusCode: 403, diff --git a/server/api/v1/auth/signup/simple.post.ts b/server/api/v1/auth/signup/simple.post.ts index ade27c1..5541ec3 100644 --- a/server/api/v1/auth/signup/simple.post.ts +++ b/server/api/v1/auth/signup/simple.post.ts @@ -7,13 +7,16 @@ import { type } from "arktype"; import { randomUUID } from "node:crypto"; const userValidator = type({ + invitation: "string", username: "string >= 5", email: "string.email", password: "string >= 14", "displayName?": "string | undefined", }); -export default defineEventHandler(async (h3) => { +export default defineEventHandler<{ + body: typeof userValidator.infer; +}>(async (h3) => { const body = await readBody(h3); const invitationId = body.invitation; diff --git a/server/routes/auth/callback/oidc.get.ts b/server/routes/auth/callback/oidc.get.ts index e2a6854..3d7d27f 100644 --- a/server/routes/auth/callback/oidc.get.ts +++ b/server/routes/auth/callback/oidc.get.ts @@ -1,6 +1,14 @@ import sessionHandler from "~/server/internal/session"; import { enabledAuthManagers } from "~/server/plugins/04.auth-init"; +defineRouteMeta({ + openAPI: { + tags: ["Auth"], + description: "OIDC Signin callback", + parameters: [], + }, +}); + export default defineEventHandler(async (h3) => { if (!enabledAuthManagers.OpenID) return sendRedirect(h3, "/auth/signin"); diff --git a/server/routes/auth/oidc.get.ts b/server/routes/auth/oidc.get.ts index be3cf94..d655fce 100644 --- a/server/routes/auth/oidc.get.ts +++ b/server/routes/auth/oidc.get.ts @@ -1,5 +1,13 @@ import { enabledAuthManagers } from "~/server/plugins/04.auth-init"; +defineRouteMeta({ + openAPI: { + tags: ["Auth"], + description: "OIDC Signin redirect", + parameters: [], + }, +}); + export default defineEventHandler((h3) => { if (!enabledAuthManagers.OpenID) return sendRedirect(h3, "/auth/signin"); diff --git a/server/routes/auth/signout.get.ts b/server/routes/auth/signout.get.ts index ebeb6f7..3f0b277 100644 --- a/server/routes/auth/signout.get.ts +++ b/server/routes/auth/signout.get.ts @@ -1,5 +1,13 @@ import sessionHandler from "../../internal/session"; +defineRouteMeta({ + openAPI: { + tags: ["Auth"], + description: "Tells server to deauthorize this session", + parameters: [], + }, +}); + export default defineEventHandler(async (h3) => { await sessionHandler.signout(h3); From 60d22ea280047826e239f94be083ca08ba8b1b9d Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Sat, 10 May 2025 16:02:44 -0400 Subject: [PATCH 006/156] fix: back button link in admin dash --- layouts/admin.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/layouts/admin.vue b/layouts/admin.vue index 0b6d810..91c2713 100644 --- a/layouts/admin.vue +++ b/layouts/admin.vue @@ -196,7 +196,7 @@ const navigation: Array = [ }, { label: "Back", - route: "/", + route: "/store", prefix: ".", icon: ArrowLeftIcon, }, From fc7473864325dfa8f806e8468a07898a49343bb1 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Sat, 10 May 2025 16:18:28 -0400 Subject: [PATCH 007/156] feat: new unified data folder --- nuxt.config.ts | 26 +++++++++++++++----------- server/internal/clients/ca-store.ts | 4 +++- server/internal/config/sys-conf.ts | 14 ++++++++++++++ server/internal/library/index.ts | 3 ++- server/internal/objects/fsBackend.ts | 3 ++- server/plugins/ca.ts | 4 +--- 6 files changed, 37 insertions(+), 17 deletions(-) create mode 100644 server/internal/config/sys-conf.ts diff --git a/nuxt.config.ts b/nuxt.config.ts index 440e1b5..b531bcd 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -4,6 +4,17 @@ const dropVersion = "0.3"; // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ + extends: ["./drop-base"], + + // Module config from here down + modules: [ + "vue3-carousel-nuxt", + "nuxt-security", + // "@nuxt/image", + "@nuxt/fonts", + "@nuxt/eslint", + ], + // Nuxt-only config telemetry: false, compatibilityDate: "2024-04-03", @@ -23,6 +34,10 @@ export default defineNuxtConfig({ viewTransition: true, }, + // future: { + // compatibilityVersion: 4, + // }, + vite: { plugins: [tailwindcss()], }, @@ -92,17 +107,6 @@ export default defineNuxtConfig({ }, }, - extends: ["./drop-base"], - - // Module config from here down - modules: [ - "vue3-carousel-nuxt", - "nuxt-security", - // "@nuxt/image", - "@nuxt/fonts", - "@nuxt/eslint", - ], - carousel: { prefix: "Vue", }, diff --git a/server/internal/clients/ca-store.ts b/server/internal/clients/ca-store.ts index 317515b..a424731 100644 --- a/server/internal/clients/ca-store.ts +++ b/server/internal/clients/ca-store.ts @@ -2,6 +2,7 @@ import path from "path"; import fs from "fs"; import type { CertificateBundle } from "./ca"; import prisma from "../db/database"; +import { systemConfig } from "../config/sys-conf"; export type CertificateStore = { store(name: string, data: CertificateBundle): Promise; @@ -10,7 +11,8 @@ export type CertificateStore = { checkBlacklistCertificate(name: string): Promise; }; -export const fsCertificateStore = (base: string) => { +export const fsCertificateStore = () => { + const base = path.join(systemConfig.getDataFolder(), "certs"); const blacklist = path.join(base, ".blacklist"); fs.mkdirSync(blacklist, { recursive: true }); const store: CertificateStore = { diff --git a/server/internal/config/sys-conf.ts b/server/internal/config/sys-conf.ts new file mode 100644 index 0000000..306d81e --- /dev/null +++ b/server/internal/config/sys-conf.ts @@ -0,0 +1,14 @@ +class SystemConfig { + private libraryFolder = process.env.LIBRARY ?? "./.data/library"; + private dataFolder = process.env.DATA ?? "./.data/data"; + + getLibraryFolder() { + return this.libraryFolder; + } + + getDataFolder() { + return this.dataFolder; + } +} + +export const systemConfig = new SystemConfig(); diff --git a/server/internal/library/index.ts b/server/internal/library/index.ts index 91d3b21..6556702 100644 --- a/server/internal/library/index.ts +++ b/server/internal/library/index.ts @@ -15,12 +15,13 @@ import taskHandler from "../tasks"; import { parsePlatform } from "../utils/parseplatform"; import droplet from "@drop-oss/droplet"; import notificationSystem from "../notifications"; +import { systemConfig } from "../config/sys-conf"; class LibraryManager { private basePath: string; constructor() { - this.basePath = process.env.LIBRARY ?? "./.data/library"; + this.basePath = systemConfig.getLibraryFolder(); fs.mkdirSync(this.basePath, { recursive: true }); } diff --git a/server/internal/objects/fsBackend.ts b/server/internal/objects/fsBackend.ts index 3f0b865..34e0800 100644 --- a/server/internal/objects/fsBackend.ts +++ b/server/internal/objects/fsBackend.ts @@ -7,6 +7,7 @@ import { Readable } from "stream"; import { createHash } from "crypto"; import prisma from "../db/database"; import cacheHandler from "../cache"; +import { systemConfig } from "../config/sys-conf"; export class FsObjectBackend extends ObjectBackend { private baseObjectPath: string; @@ -16,7 +17,7 @@ export class FsObjectBackend extends ObjectBackend { constructor() { super(); - const basePath = process.env.FS_BACKEND_PATH ?? "./.data/objects"; + const basePath = path.join(systemConfig.getDataFolder(), "objects"); this.baseObjectPath = path.join(basePath, "objects"); this.baseMetadataPath = path.join(basePath, "metadata"); diff --git a/server/plugins/ca.ts b/server/plugins/ca.ts index 0650f51..4f2aff2 100644 --- a/server/plugins/ca.ts +++ b/server/plugins/ca.ts @@ -9,9 +9,7 @@ export const useCertificateAuthority = () => { }; export default defineNitroPlugin(async () => { - // const basePath = process.env.CLIENT_CERTIFICATES ?? "./certs"; - // fs.mkdirSync(basePath, { recursive: true }); - // const store = fsCertificateStore(basePath); + // const store = fsCertificateStore(); ca = await CertificateAuthority.new(dbCertificateStore()); }); From 1bbdf46a0e039670c207364217956c10154813ad Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Sat, 10 May 2025 17:27:39 -0400 Subject: [PATCH 008/156] feat: better docker builds --- .dockerignore | 7 ++++++- Dockerfile | 42 +++++++++++++++++++++++-------------- build/launch.sh | 5 ++--- deploy-template/compose.yml | 12 ++++------- 4 files changed, 38 insertions(+), 28 deletions(-) diff --git a/.dockerignore b/.dockerignore index 4abae2b..4078721 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,6 +8,7 @@ dist # Node dependencies node_modules +.yarn # Logs logs @@ -23,4 +24,8 @@ logs .env.* !.env.example -.data +# deploy template +deploy-template/ + +# generated prisma client +/prisma/client diff --git a/Dockerfile b/Dockerfile index 128e0d0..73ba58f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,30 +1,40 @@ -# pull pre-configured and updated build environment -FROM debian:testing-20250317-slim AS build-system +# Unified deps builder +FROM node:lts-alpine AS deps +WORKDIR /app +COPY package.json yarn.lock ./ +RUN yarn install --network-timeout 1000000 --ignore-scripts +# Build for app +FROM node:lts-alpine AS build-system # setup workdir - has to be the same filepath as app because fuckin' Prisma WORKDIR /app -# install dependencies and build -RUN apt-get update -y -RUN apt-get install node-corepack -y -RUN corepack enable +ENV NODE_ENV=production +ENV NUXT_TELEMETRY_DISABLED=1 + +# copy deps and rest of project files +COPY --from=deps /app/node_modules ./node_modules COPY . . -RUN NUXT_TELEMETRY_DISABLED=1 yarn install --network-timeout 1000000 -RUN NUXT_TELEMETRY_DISABLED=1 yarn prisma generate -RUN NUXT_TELEMETRY_DISABLED=1 yarn build + +# build +RUN yarn postinstall +RUN yarn build # create run environment for Drop -FROM node:lts-slim AS run-system - +FROM node:lts-alpine AS run-system WORKDIR /app +ENV NODE_ENV=production +ENV NUXT_TELEMETRY_DISABLED=1 + +RUN yarn add --network-timeout 1000000 --no-lockfile prisma@6.7.0 + +COPY --from=build-system /app/package.json ./ COPY --from=build-system /app/.output ./app COPY --from=build-system /app/prisma ./prisma -COPY --from=build-system /app/package.json ./ COPY --from=build-system /app/build ./startup -# OpenSSL as a dependency for Drop (TODO: seperate build environment) -RUN apt-get update -y && apt-get install -y openssl -RUN yarn global add prisma@6.7.0 +ENV LIBRARY="/library" +ENV DATA="/data" -CMD ["/app/startup/launch.sh"] \ No newline at end of file +CMD ["sh", "/app/startup/launch.sh"] diff --git a/build/launch.sh b/build/launch.sh index 0925938..173b311 100755 --- a/build/launch.sh +++ b/build/launch.sh @@ -2,8 +2,7 @@ # This file starts up the Drop server by running migrations and then starting the executable echo "[Drop] performing migrations..." -ls ./prisma/migrations/ -prisma migrate deploy +yarn prisma migrate deploy # Actually start the application -node /app/app/server/index.mjs \ No newline at end of file +node /app/app/server/index.mjs diff --git a/deploy-template/compose.yml b/deploy-template/compose.yml index d3011a6..5573191 100644 --- a/deploy-template/compose.yml +++ b/deploy-template/compose.yml @@ -1,6 +1,7 @@ services: postgres: - image: postgres:14-alpine + # using alpine image to reduce image size + image: postgres:alpine ports: - 5432:5432 healthcheck: @@ -16,7 +17,7 @@ services: - POSTGRES_USER=drop - POSTGRES_DB=drop drop: - image: registry.deepcore.dev/drop-oss/drop/main:latest + image: ghcr.io/drop-oss/drop depends_on: postgres: condition: service_healthy @@ -24,11 +25,6 @@ services: - 3000:3000 volumes: - ./library:/library - - ./certs:/certs - - ./objects:/objects + - ./data:/data environment: - DATABASE_URL=postgres://drop:drop@postgres:5432/drop - - FS_BACKEND_PATH=/objects - - CLIENT_CERTIFICATES=/certs - - LIBRARY=/library - - GIANT_BOMB_API_KEY=REPLACE_WITH_YOUR_KEY From a8ee27eea9758fac419391593f20f4ba07329c45 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Sun, 11 May 2025 00:35:16 -0400 Subject: [PATCH 009/156] feat: pcgamgingwiki now provides a description --- package.json | 2 +- prisma/models/content.prisma | 1 + prisma/models/news.prisma | 1 + server/internal/metadata/giantbomb.ts | 6 +- server/internal/metadata/pcgamingwiki.ts | 74 ++++++- yarn.lock | 249 ++++++++++------------- 6 files changed, 175 insertions(+), 158 deletions(-) diff --git a/package.json b/package.json index 14c2030..398a063 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "arktype": "^2.1.10", "axios": "^1.7.7", "bcryptjs": "^3.0.2", + "cheerio": "^1.0.0", "cookie-es": "^2.0.0", "fast-fuzzy": "^1.12.0", "file-type-mime": "^0.4.3", @@ -36,7 +37,6 @@ "nuxt": "^3.16.2", "nuxt-security": "2.2.0", "prisma": "^6.7.0", - "sharp": "^0.33.5", "stream-mime-type": "^2.0.0", "turndown": "^7.2.0", "unstorage": "^1.15.0", diff --git a/prisma/models/content.prisma b/prisma/models/content.prisma index daaada3..ee3b267 100644 --- a/prisma/models/content.prisma +++ b/prisma/models/content.prisma @@ -34,6 +34,7 @@ model Game { collections CollectionEntry[] saves SaveSlot[] screenshots Screenshot[] + tags Tag[] developers Company[] @relation(name: "developers") publishers Company[] @relation(name: "publishers") diff --git a/prisma/models/news.prisma b/prisma/models/news.prisma index dcc25ec..1878194 100644 --- a/prisma/models/news.prisma +++ b/prisma/models/news.prisma @@ -3,6 +3,7 @@ model Tag { name String @unique articles Article[] + games Game[] } model Article { diff --git a/server/internal/metadata/giantbomb.ts b/server/internal/metadata/giantbomb.ts index d617f0c..0a9922d 100644 --- a/server/internal/metadata/giantbomb.ts +++ b/server/internal/metadata/giantbomb.ts @@ -1,5 +1,4 @@ -import type { Company } from "~/prisma/client"; -import { MetadataSource } from "~/prisma/client"; +import { MetadataSource, type Company } from "~/prisma/client"; import type { MetadataProvider } from "."; import { MissingMetadataProviderConfig } from "."; import type { @@ -9,8 +8,7 @@ import type { _FetchCompanyMetadataParams, CompanyMetadata, } from "./types"; -import type { AxiosRequestConfig } from "axios"; -import axios from "axios"; +import axios, { type AxiosRequestConfig } from "axios"; import TurndownService from "turndown"; import { DateTime } from "luxon"; diff --git a/server/internal/metadata/pcgamingwiki.ts b/server/internal/metadata/pcgamingwiki.ts index 5ebce0c..504c617 100644 --- a/server/internal/metadata/pcgamingwiki.ts +++ b/server/internal/metadata/pcgamingwiki.ts @@ -12,6 +12,29 @@ import type { AxiosRequestConfig } from "axios"; import axios from "axios"; import * as jdenticon from "jdenticon"; import { DateTime } from "luxon"; +import * as cheerio from "cheerio"; + +interface PCGamingWikiParseRawPage { + parse: { + title: string; + pageid: number; + revid: number; + displaytitle: string; + // array of links + externallinks: string[]; + // array of wiki file names + images: string[]; + text: { + // rendered page contents + "*": string; + }; + }; +} + +interface PCGamingWikiParsedPage { + shortIntro: string; + introduction: string; +} interface PCGamingWikiPage { PageID: string; @@ -75,7 +98,7 @@ export class PCGamingWikiProvider implements MetadataProvider { url: finalURL, baseURL: "", }; - const response = await axios.request>( + const response = await axios.request( Object.assign({}, options, overlay), ); @@ -83,12 +106,42 @@ export class PCGamingWikiProvider implements MetadataProvider { throw new Error( `Error in pcgamingwiki \nStatus Code: ${response.status}`, ); - else if (response.data.error !== undefined) - throw new Error(`Error in pcgamingwiki, malformed query`); return response; } + private async cargoQuery( + query: URLSearchParams, + options?: AxiosRequestConfig, + ) { + const response = await this.request>( + query, + options, + ); + if (response.data.error !== undefined) + throw new Error(`Error in pcgamingwiki cargo query`); + return response; + } + + private async getPageContent( + pageID: string, + ): Promise { + const searchParams = new URLSearchParams({ + action: "parse", + format: "json", + pageid: pageID, + }); + const res = await this.request(searchParams); + const $ = cheerio.load(res.data.parse.text["*"]); + // get intro based on 'introduction' class + const introductionEle = $(".introduction").first(); + + return { + shortIntro: introductionEle.find("p").first().text(), + introduction: introductionEle.text(), + }; + } + async search(query: string) { const searchParams = new URLSearchParams({ action: "cargoquery", @@ -99,7 +152,7 @@ export class PCGamingWikiProvider implements MetadataProvider { format: "json", }); - const res = await this.request(searchParams); + const res = await this.cargoQuery(searchParams); const mapped = res.data.cargoquery.map((result) => { const game = result.title; @@ -172,7 +225,10 @@ export class PCGamingWikiProvider implements MetadataProvider { format: "json", }); - const res = await this.request(searchParams); + const [res, pageContent] = await Promise.all([ + this.cargoQuery(searchParams), + this.getPageContent(id), + ]); if (res.data.cargoquery.length < 1) throw new Error("Error in pcgamingwiki, no game"); @@ -206,8 +262,8 @@ export class PCGamingWikiProvider implements MetadataProvider { const metadata: GameMetadata = { id: game.PageID, name: game.PageName, - shortDescription: "", // TODO: (again) need to render the `Introduction` template somehow (or we could just hardcode it) - description: "", + shortDescription: pageContent.shortIntro, + description: pageContent.introduction, released: game.Released ? DateTime.fromISO(game.Released.split(";")[0]).toJSDate() : new Date(), @@ -240,9 +296,9 @@ export class PCGamingWikiProvider implements MetadataProvider { format: "json", }); - const res = await this.request(searchParams); + const res = await this.cargoQuery(searchParams); - // TODO: replace + // TODO: replace with company logo const icon = createObject(jdenticon.toPng(query, 512)); for (let i = 0; i < res.data.cargoquery.length; i++) { diff --git a/yarn.lock b/yarn.lock index 73d1c63..99ff8c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -379,7 +379,7 @@ "@emnapi/wasi-threads" "1.0.1" tslib "^2.4.0" -"@emnapi/runtime@^1.2.0", "@emnapi/runtime@^1.4.0": +"@emnapi/runtime@^1.4.0": version "1.4.0" resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.4.0.tgz#8f509bf1059a5551c8fe829a1c4e91db35fdfbee" integrity sha512-64WYIf4UYcdLnbKn/umDlNjQDSS8AgZrI/R9+x5ilkUVFxXcA1Ebl+gQLc/6mERA4407Xof0R7wEyEuj091CVw== @@ -671,119 +671,6 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.2.tgz#1860473de7dfa1546767448f333db80cb0ff2161" integrity sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ== -"@img/sharp-darwin-arm64@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz#ef5b5a07862805f1e8145a377c8ba6e98813ca08" - integrity sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ== - optionalDependencies: - "@img/sharp-libvips-darwin-arm64" "1.0.4" - -"@img/sharp-darwin-x64@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz#e03d3451cd9e664faa72948cc70a403ea4063d61" - integrity sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q== - optionalDependencies: - "@img/sharp-libvips-darwin-x64" "1.0.4" - -"@img/sharp-libvips-darwin-arm64@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz#447c5026700c01a993c7804eb8af5f6e9868c07f" - integrity sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg== - -"@img/sharp-libvips-darwin-x64@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz#e0456f8f7c623f9dbfbdc77383caa72281d86062" - integrity sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ== - -"@img/sharp-libvips-linux-arm64@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz#979b1c66c9a91f7ff2893556ef267f90ebe51704" - integrity sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA== - -"@img/sharp-libvips-linux-arm@1.0.5": - version "1.0.5" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz#99f922d4e15216ec205dcb6891b721bfd2884197" - integrity sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g== - -"@img/sharp-libvips-linux-s390x@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz#f8a5eb1f374a082f72b3f45e2fb25b8118a8a5ce" - integrity sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA== - -"@img/sharp-libvips-linux-x64@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz#d4c4619cdd157774906e15770ee119931c7ef5e0" - integrity sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw== - -"@img/sharp-libvips-linuxmusl-arm64@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz#166778da0f48dd2bded1fa3033cee6b588f0d5d5" - integrity sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA== - -"@img/sharp-libvips-linuxmusl-x64@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz#93794e4d7720b077fcad3e02982f2f1c246751ff" - integrity sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw== - -"@img/sharp-linux-arm64@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz#edb0697e7a8279c9fc829a60fc35644c4839bb22" - integrity sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA== - optionalDependencies: - "@img/sharp-libvips-linux-arm64" "1.0.4" - -"@img/sharp-linux-arm@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz#422c1a352e7b5832842577dc51602bcd5b6f5eff" - integrity sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ== - optionalDependencies: - "@img/sharp-libvips-linux-arm" "1.0.5" - -"@img/sharp-linux-s390x@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz#f5c077926b48e97e4a04d004dfaf175972059667" - integrity sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q== - optionalDependencies: - "@img/sharp-libvips-linux-s390x" "1.0.4" - -"@img/sharp-linux-x64@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz#d806e0afd71ae6775cc87f0da8f2d03a7c2209cb" - integrity sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA== - optionalDependencies: - "@img/sharp-libvips-linux-x64" "1.0.4" - -"@img/sharp-linuxmusl-arm64@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz#252975b915894fb315af5deea174651e208d3d6b" - integrity sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g== - optionalDependencies: - "@img/sharp-libvips-linuxmusl-arm64" "1.0.4" - -"@img/sharp-linuxmusl-x64@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz#3f4609ac5d8ef8ec7dadee80b560961a60fd4f48" - integrity sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw== - optionalDependencies: - "@img/sharp-libvips-linuxmusl-x64" "1.0.4" - -"@img/sharp-wasm32@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz#6f44f3283069d935bb5ca5813153572f3e6f61a1" - integrity sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg== - dependencies: - "@emnapi/runtime" "^1.2.0" - -"@img/sharp-win32-ia32@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz#1a0c839a40c5351e9885628c85f2e5dfd02b52a9" - integrity sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ== - -"@img/sharp-win32-x64@0.33.5": - version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz#56f00962ff0c4e0eb93d34a047d29fa995e3e342" - integrity sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg== - "@ioredis/commands@^1.1.1": version "1.2.0" resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11" @@ -2805,6 +2692,35 @@ character-entities@^2.0.0: resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-2.0.2.tgz#2d09c2e72cd9523076ccb21157dff66ad43fcc22" integrity sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ== +cheerio-select@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-2.1.0.tgz#4d8673286b8126ca2a8e42740d5e3c4884ae21b4" + integrity sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g== + dependencies: + boolbase "^1.0.0" + css-select "^5.1.0" + css-what "^6.1.0" + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.0.1" + +cheerio@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0.tgz#1ede4895a82f26e8af71009f961a9b8cb60d6a81" + integrity sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww== + dependencies: + cheerio-select "^2.1.0" + dom-serializer "^2.0.0" + domhandler "^5.0.3" + domutils "^3.1.0" + encoding-sniffer "^0.2.0" + htmlparser2 "^9.1.0" + parse5 "^7.1.2" + parse5-htmlparser2-tree-adapter "^7.0.0" + parse5-parser-stream "^7.1.2" + undici "^6.19.5" + whatwg-mimetype "^4.0.0" + chokidar@^4.0.0, chokidar@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" @@ -3344,7 +3260,7 @@ domhandler@^5.0.2, domhandler@^5.0.3: dependencies: domelementtype "^2.3.0" -domutils@^3.0.1: +domutils@^3.0.1, domutils@^3.1.0: version "3.2.2" resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78" integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw== @@ -3414,6 +3330,14 @@ encodeurl@~2.0.0: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== +encoding-sniffer@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz#799569d66d443babe82af18c9f403498365ef1d5" + integrity sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg== + dependencies: + iconv-lite "^0.6.3" + whatwg-encoding "^3.1.1" + end-of-stream@^1.1.0, end-of-stream@^1.4.1: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -3434,6 +3358,11 @@ entities@^4.2.0, entities@^4.5.0: resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== +entities@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-6.0.0.tgz#09c9e29cb79b0a6459a9b9db9efb418ac5bb8e51" + integrity sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw== + error-stack-parser-es@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz#e6a1655dd12f39bb3a85bf4c7088187d78740327" @@ -4256,6 +4185,16 @@ hosted-git-info@^7.0.0: dependencies: lru-cache "^10.0.1" +htmlparser2@^9.1.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-9.1.0.tgz#cdb498d8a75a51f739b61d3f718136c369bc8c23" + integrity sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.1.0" + entities "^4.5.0" + http-errors@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" @@ -4290,6 +4229,13 @@ human-signals@^5.0.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-5.0.0.tgz#42665a284f9ae0dade3ba41ebc37eb4b852f3a28" integrity sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ== +iconv-lite@0.6.3, iconv-lite@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + ieee754@^1.1.13, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -5760,6 +5706,28 @@ parse-url@^9.2.0: "@types/parse-path" "^7.0.0" parse-path "^7.0.0" +parse5-htmlparser2-tree-adapter@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz#b5a806548ed893a43e24ccb42fbb78069311e81b" + integrity sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g== + dependencies: + domhandler "^5.0.3" + parse5 "^7.0.0" + +parse5-parser-stream@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz#d7c20eadc37968d272e2c02660fff92dd27e60e1" + integrity sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow== + dependencies: + parse5 "^7.0.0" + +parse5@^7.0.0, parse5@^7.1.2: + version "7.3.0" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.3.0.tgz#d7e224fa72399c7a175099f45fc2ad024b05ec05" + integrity sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw== + dependencies: + entities "^6.0.0" + parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" @@ -6455,6 +6423,11 @@ safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@~5.2.0: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== +"safer-buffer@>= 2.1.2 < 3.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + sass@^1.79.4: version "1.86.0" resolved "https://registry.yarnpkg.com/sass/-/sass-1.86.0.tgz#f49464fb6237a903a93f4e8760ef6e37a5030114" @@ -6552,35 +6525,6 @@ sharp@^0.32.6: tar-fs "^3.0.4" tunnel-agent "^0.6.0" -sharp@^0.33.5: - version "0.33.5" - resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.33.5.tgz#13e0e4130cc309d6a9497596715240b2ec0c594e" - integrity sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw== - dependencies: - color "^4.2.3" - detect-libc "^2.0.3" - semver "^7.6.3" - optionalDependencies: - "@img/sharp-darwin-arm64" "0.33.5" - "@img/sharp-darwin-x64" "0.33.5" - "@img/sharp-libvips-darwin-arm64" "1.0.4" - "@img/sharp-libvips-darwin-x64" "1.0.4" - "@img/sharp-libvips-linux-arm" "1.0.5" - "@img/sharp-libvips-linux-arm64" "1.0.4" - "@img/sharp-libvips-linux-s390x" "1.0.4" - "@img/sharp-libvips-linux-x64" "1.0.4" - "@img/sharp-libvips-linuxmusl-arm64" "1.0.4" - "@img/sharp-libvips-linuxmusl-x64" "1.0.4" - "@img/sharp-linux-arm" "0.33.5" - "@img/sharp-linux-arm64" "0.33.5" - "@img/sharp-linux-s390x" "0.33.5" - "@img/sharp-linux-x64" "0.33.5" - "@img/sharp-linuxmusl-arm64" "0.33.5" - "@img/sharp-linuxmusl-x64" "0.33.5" - "@img/sharp-wasm32" "0.33.5" - "@img/sharp-win32-ia32" "0.33.5" - "@img/sharp-win32-x64" "0.33.5" - shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -7168,6 +7112,11 @@ undici-types@~6.20.0: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433" integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== +undici@^6.19.5: + version "6.21.2" + resolved "https://registry.yarnpkg.com/undici/-/undici-6.21.2.tgz#49c5884e8f9039c65a89ee9018ef3c8e2f1f4928" + integrity sha512-uROZWze0R0itiAKVPsYhFov9LxrPMHLMEQFszeI2gCN6bnIIZ8twzBCJcN2LJrBBLfrP0t1FW0g+JmKVl8Vk1g== + unenv@^2.0.0-rc.15: version "2.0.0-rc.15" resolved "https://registry.yarnpkg.com/unenv/-/unenv-2.0.0-rc.15.tgz#7fe427b6634f00bda1ade4fecdbc6b2dd7af63be" @@ -7581,6 +7530,18 @@ webpack-virtual-modules@^0.6.2: resolved "https://registry.yarnpkg.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz#057faa9065c8acf48f24cb57ac0e77739ab9a7e8" integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ== +whatwg-encoding@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz#d0f4ef769905d426e1688f3e34381a99b60b76e5" + integrity sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ== + dependencies: + iconv-lite "0.6.3" + +whatwg-mimetype@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz#bc1bf94a985dc50388d54a9258ac405c3ca2fc0a" + integrity sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg== + whatwg-url@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" From dad21617541c78233b01fd235e2c0c56993f1fb8 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Sun, 11 May 2025 12:52:00 -0400 Subject: [PATCH 010/156] feat: games now have tag support --- .../migration.sql | 16 ++++ .../api/v1/admin/game/image/index.delete.ts | 6 +- server/internal/metadata/giantbomb.ts | 2 + server/internal/metadata/igdb.ts | 3 + server/internal/metadata/index.ts | 28 ++++++ server/internal/metadata/manual.ts | 1 + server/internal/metadata/pcgamingwiki.ts | 90 ++++++++++++++----- server/internal/metadata/types.d.ts | 2 + 8 files changed, 125 insertions(+), 23 deletions(-) create mode 100644 prisma/migrations/20250511154134_add_tags_to_games/migration.sql diff --git a/prisma/migrations/20250511154134_add_tags_to_games/migration.sql b/prisma/migrations/20250511154134_add_tags_to_games/migration.sql new file mode 100644 index 0000000..fb10131 --- /dev/null +++ b/prisma/migrations/20250511154134_add_tags_to_games/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "_GameToTag" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_GameToTag_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE INDEX "_GameToTag_B_index" ON "_GameToTag"("B"); + +-- AddForeignKey +ALTER TABLE "_GameToTag" ADD CONSTRAINT "_GameToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Game"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_GameToTag" ADD CONSTRAINT "_GameToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/api/v1/admin/game/image/index.delete.ts b/server/api/v1/admin/game/image/index.delete.ts index 9d9754e..9ef00ac 100644 --- a/server/api/v1/admin/game/image/index.delete.ts +++ b/server/api/v1/admin/game/image/index.delete.ts @@ -3,18 +3,18 @@ import prisma from "~/server/internal/db/database"; import objectHandler from "~/server/internal/objects"; import { type } from "arktype"; -const ModifyGameImage = type({ +const DeleteGameImage = type({ gameId: "string", imageId: "string", }); export default defineEventHandler<{ - body: typeof ModifyGameImage.infer; + body: typeof DeleteGameImage.infer; }>(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, ["game:image:delete"]); if (!allowed) throw createError({ statusCode: 403 }); - const body = ModifyGameImage(await readBody(h3)); + const body = DeleteGameImage(await readBody(h3)); if (body instanceof type.errors) { // hover out.summary to see validation errors console.error(body.summary); diff --git a/server/internal/metadata/giantbomb.ts b/server/internal/metadata/giantbomb.ts index 0a9922d..2441aa4 100644 --- a/server/internal/metadata/giantbomb.ts +++ b/server/internal/metadata/giantbomb.ts @@ -205,6 +205,8 @@ export class GiantBombProvider implements MetadataProvider { description: longDescription, released: releaseDate, + tags: [], + reviewCount: 0, reviewRating: 0, diff --git a/server/internal/metadata/igdb.ts b/server/internal/metadata/igdb.ts index 1970d89..22ae57e 100644 --- a/server/internal/metadata/igdb.ts +++ b/server/internal/metadata/igdb.ts @@ -358,6 +358,9 @@ export class IGDBProvider implements MetadataProvider { publishers: [], developers: [], + // TODO: support tags + tags: [], + icon, bannerId: banner, coverId: icon, diff --git a/server/internal/metadata/index.ts b/server/internal/metadata/index.ts index 9c1f03f..f50e8e4 100644 --- a/server/internal/metadata/index.ts +++ b/server/internal/metadata/index.ts @@ -110,6 +110,30 @@ export class MetadataHandler { ); } + private parseTags(tags: string[]) { + const results: { + where: { + name: string; + }; + create: { + name: string; + }; + }[] = []; + + tags.forEach((t) => + results.push({ + where: { + name: t, + }, + create: { + name: t, + }, + }), + ); + + return results; + } + async createGame( result: InternalGameMetadataResult, libraryBasePath: string, @@ -173,6 +197,10 @@ export class MetadataHandler { connect: metadata.developers, }, + tags: { + connectOrCreate: this.parseTags(metadata.tags), + }, + libraryBasePath, }, }); diff --git a/server/internal/metadata/manual.ts b/server/internal/metadata/manual.ts index fb3786c..d7b00dc 100644 --- a/server/internal/metadata/manual.ts +++ b/server/internal/metadata/manual.ts @@ -33,6 +33,7 @@ export class ManualMetadataProvider implements MetadataProvider { released: new Date(), publishers: [], developers: [], + tags: [], reviewCount: 0, reviewRating: 0, diff --git a/server/internal/metadata/pcgamingwiki.ts b/server/internal/metadata/pcgamingwiki.ts index 504c617..489e41a 100644 --- a/server/internal/metadata/pcgamingwiki.ts +++ b/server/internal/metadata/pcgamingwiki.ts @@ -48,12 +48,19 @@ interface PCGamingWikiSearchStub extends PCGamingWikiPage { } interface PCGamingWikiGame extends PCGamingWikiSearchStub { - Developers: string | null; - Genres: string | null; - Publishers: string | null; - Themes: string | null; + Developers: string | string[] | null; + Publishers: string | string[] | null; + + // TODO: save this somewhere, maybe a tag? Series: string | null; - Modes: string | null; + + // tags + Perspectives: string | string[] | null; // ie: First-person + Genres: string | string[] | null; // ie: Action, FPS + "Art styles": string | string[] | null; // ie: Stylized + Themes: string | string[] | null; // ie: Post-apocalyptic, Sci-fi, Space + Modes: string | string[] | null; // ie: Singleplayer, Multiplayer + Pacing: string | string[] | null; // ie: Real-time } interface PCGamingWikiCompany extends PCGamingWikiPage { @@ -78,6 +85,10 @@ interface PCGamingWikiCargoResult { }; } +type StringArrayKeys = { + [K in keyof T]: T[K] extends string | string[] | null ? K : never; +}[keyof T]; + // Api Docs: https://www.pcgamingwiki.com/wiki/PCGamingWiki:API // Good tool for helping build cargo queries: https://www.pcgamingwiki.com/wiki/Special:CargoQuery export class PCGamingWikiProvider implements MetadataProvider { @@ -135,6 +146,8 @@ export class PCGamingWikiProvider implements MetadataProvider { const $ = cheerio.load(res.data.parse.text["*"]); // get intro based on 'introduction' class const introductionEle = $(".introduction").first(); + // remove citations from intro + introductionEle.find("sup").remove(); return { shortIntro: introductionEle.find("p").first().text(), @@ -175,20 +188,33 @@ export class PCGamingWikiProvider implements MetadataProvider { } /** - * Parses the specific format that the wiki returns when specifying a company - * @param companyStr + * Parses the specific format that the wiki returns when specifying an array + * @param input string or array * @returns */ - private parseCompanyStr(companyStr: string): string[] { - const results: string[] = []; - // provides the string as a list of companies - // 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; } @@ -209,6 +235,28 @@ export class PCGamingWikiProvider implements MetadataProvider { return websiteStr.replaceAll(/\[|]/g, "").split(" ")[0] ?? ""; } + private compileTags(game: PCGamingWikiGame): string[] { + const results: string[] = []; + + const properties: StringArrayKeys[] = [ + "Art styles", + "Genres", + "Modes", + "Pacing", + "Perspectives", + "Themes", + ]; + + // loop through all above keys, get the tags they contain + properties.forEach((p) => { + if (game[p] === null) return; + + results.push(...this.parseWikiStringArray(game[p])); + }); + + return results; + } + async fetchGame({ id, name, @@ -220,7 +268,7 @@ export class PCGamingWikiProvider implements MetadataProvider { action: "cargoquery", tables: "Infobox_game", fields: - "Infobox_game._pageID=PageID,Infobox_game._pageName=PageName,Infobox_game.Cover_URL,Infobox_game.Developers,Infobox_game.Released,Infobox_game.Genres,Infobox_game.Publishers,Infobox_game.Themes,Infobox_game.Series,Infobox_game.Modes", + "Infobox_game._pageID=PageID,Infobox_game._pageName=PageName,Infobox_game.Cover_URL,Infobox_game.Developers,Infobox_game.Released,Infobox_game.Genres,Infobox_game.Publishers,Infobox_game.Themes,Infobox_game.Series,Infobox_game.Modes,Infobox_game.Perspectives,Infobox_game.Art_styles,Infobox_game.Pacing", where: `Infobox_game._pageID="${id}"`, format: "json", }); @@ -236,7 +284,7 @@ export class PCGamingWikiProvider implements MetadataProvider { const publishers: Company[] = []; if (game.Publishers !== null) { - const pubListClean = this.parseCompanyStr(game.Publishers); + const pubListClean = this.parseWikiStringArray(game.Publishers); for (const pub of pubListClean) { const res = await publisher(pub); if (res === undefined) continue; @@ -246,7 +294,7 @@ export class PCGamingWikiProvider implements MetadataProvider { const developers: Company[] = []; if (game.Developers !== null) { - const devListClean = this.parseCompanyStr(game.Developers); + const devListClean = this.parseWikiStringArray(game.Developers); for (const dev of devListClean) { const res = await developer(dev); if (res === undefined) continue; @@ -268,6 +316,8 @@ export class PCGamingWikiProvider implements MetadataProvider { ? DateTime.fromISO(game.Released.split(";")[0]).toJSDate() : new Date(), + tags: this.compileTags(game), + reviewCount: 0, reviewRating: 0, @@ -305,7 +355,7 @@ export class PCGamingWikiProvider implements MetadataProvider { const company = res.data.cargoquery[i].title; const fixedCompanyName = - this.parseCompanyStr(company.PageName)[0] ?? company.PageName; + this.parseWikiStringArray(company.PageName)[0] ?? company.PageName; const metadata: CompanyMetadata = { id: company.PageID, diff --git a/server/internal/metadata/types.d.ts b/server/internal/metadata/types.d.ts index 22f30f8..b627041 100644 --- a/server/internal/metadata/types.d.ts +++ b/server/internal/metadata/types.d.ts @@ -30,6 +30,8 @@ export interface GameMetadata { publishers: Company[]; developers: Company[]; + tags: string[]; + reviewCount: number; reviewRating: number; From efbc86e73e6505855a8d6150e8a9766f9ab358b0 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Fri, 9 May 2025 14:53:37 +1000 Subject: [PATCH 011/156] feat: add new HeadscaleManager --- package.json | 1 + server/internal/p2p/headscale.ts | 25 +++++++ yarn.lock | 125 ++++++++++++++++++++++++++++++- 3 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 server/internal/p2p/headscale.ts diff --git a/package.json b/package.json index 14c2030..a46c996 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@drop-oss/droplet": "^0.7.2", + "@drop-oss/headscalez": "^0.0.3", "@headlessui/vue": "^1.7.23", "@heroicons/vue": "^2.1.5", "@nuxt/fonts": "^0.11.0", diff --git a/server/internal/p2p/headscale.ts b/server/internal/p2p/headscale.ts new file mode 100644 index 0000000..f8847ce --- /dev/null +++ b/server/internal/p2p/headscale.ts @@ -0,0 +1,25 @@ +import type { HeadscaleService} from "@drop-oss/headscalez"; +import { startHeadscale } from "@drop-oss/headscalez"; + +export class HeadscaleManager { + private headscaleService?: HeadscaleService; + + constructor() { + this.setup(); + } + + async setup() { + const externalUrl = process.env.CONTROL_URL; + if (externalUrl) { + const headscale = await startHeadscale({ externalUrl }); + this.headscaleService = headscale; + } + } + + enabled() { + return !!this.headscaleService; + } +} + +export const headscaleManager = new HeadscaleManager(); +export default headscaleManager; diff --git a/yarn.lock b/yarn.lock index 73d1c63..9928e3c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -371,6 +371,16 @@ "@drop-oss/droplet-win32-arm64-msvc" "0.7.2" "@drop-oss/droplet-win32-x64-msvc" "0.7.2" +"@drop-oss/headscalez@^0.0.3": + version "0.0.3" + resolved "https://registry.yarnpkg.com/@drop-oss/headscalez/-/headscalez-0.0.3.tgz#deb6eae13b9b65cf225bb2e0e84bc47159b4a91e" + integrity sha512-Zsl0T/pwJyw7vbLwEUVAf9zXKKTG3qAbUVQEDiRe88bwhzauPG9/MgBBHA2N/9GHXMUFRRDcchPbwGjCcmcJ3Q== + dependencies: + execa "^9.5.3" + fs-extra "^11.3.0" + node-graceful-shutdown "^1.1.5" + ofetch "^1.4.1" + "@emnapi/core@^1.4.0": version "1.4.0" resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.4.0.tgz#8844b02d799198158ac1fea21ae2bc81b881da9a" @@ -1705,6 +1715,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.38.0.tgz#38197da22c1af7b6b5d1cc7541757379193b8e83" integrity sha512-jjqy3uWlecfB98Psxb5cD6Fny9Fupv9LrDSPTQZUROqjvZmcCqNu4UMl7qqhlUUGpwiAkotj6GYu4SZdcr/nLw== +"@sec-ant/readable-stream@^0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz#60de891bb126abfdc5410fdc6166aca065f10a0c" + integrity sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg== + "@sindresorhus/is@^7.0.1": version "7.0.1" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-7.0.1.tgz#693cd0bfa7fdc71a3386b72088b660fb70851927" @@ -1715,6 +1730,11 @@ resolved "https://registry.yarnpkg.com/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz#719df7fb41766bc143369eaa0dd56d8dc87c9958" integrity sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg== +"@sindresorhus/merge-streams@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz#abb11d99aeb6d27f1b563c38147a72d50058e339" + integrity sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ== + "@speed-highlight/core@^1.2.7": version "1.2.7" resolved "https://registry.yarnpkg.com/@speed-highlight/core/-/core-1.2.7.tgz#eeaa7c1e7198559abbb98e4acbc93d108d35f2d3" @@ -3803,6 +3823,24 @@ execa@^8.0.1: signal-exit "^4.1.0" strip-final-newline "^3.0.0" +execa@^9.5.3: + version "9.5.3" + resolved "https://registry.yarnpkg.com/execa/-/execa-9.5.3.tgz#aa9b6e92ea6692b88a240efc260ca30489b33e2a" + integrity sha512-QFNnTvU3UjgWFy8Ef9iDHvIdcgZ344ebkwYx4/KLbR+CKQA4xBaHzv+iRpp86QfMHP8faFQLh8iOc57215y4Rg== + dependencies: + "@sindresorhus/merge-streams" "^4.0.0" + cross-spawn "^7.0.3" + figures "^6.1.0" + get-stream "^9.0.0" + human-signals "^8.0.0" + is-plain-obj "^4.1.0" + is-stream "^4.0.1" + npm-run-path "^6.0.0" + pretty-ms "^9.0.0" + signal-exit "^4.1.0" + strip-final-newline "^4.0.0" + yoctocolors "^2.0.0" + expand-template@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" @@ -3878,6 +3916,13 @@ fdir@^6.2.0, fdir@^6.4.3: resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.4.3.tgz#011cdacf837eca9b811c89dbb902df714273db72" integrity sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw== +figures@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-6.1.0.tgz#935479f51865fa7479f6fa94fc6fc7ac14e62c4a" + integrity sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg== + dependencies: + is-unicode-supported "^2.0.0" + file-entry-cache@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" @@ -4012,6 +4057,15 @@ fs-constants@^1.0.0: resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== +fs-extra@^11.3.0: + version "11.3.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.3.0.tgz#0daced136bbaf65a555a326719af931adc7a314d" + integrity sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + fsevents@2.3.3, fsevents@~2.3.2, fsevents@~2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" @@ -4071,6 +4125,14 @@ get-stream@^8.0.1: resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-8.0.1.tgz#def9dfd71742cd7754a7761ed43749a27d02eca2" integrity sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA== +get-stream@^9.0.0: + version "9.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-9.0.1.tgz#95157d21df8eb90d1647102b63039b1df60ebd27" + integrity sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA== + dependencies: + "@sec-ant/readable-stream" "^0.4.1" + is-stream "^4.0.1" + get-tsconfig@^4.10.0: version "4.10.0" resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.10.0.tgz#403a682b373a823612475a4c2928c7326fc0f6bb" @@ -4175,7 +4237,7 @@ gopd@^1.2.0: resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== -graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.9: +graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.9: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== @@ -4290,6 +4352,11 @@ human-signals@^5.0.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-5.0.0.tgz#42665a284f9ae0dade3ba41ebc37eb4b852f3a28" integrity sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ== +human-signals@^8.0.0: + version "8.0.1" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-8.0.1.tgz#f08bb593b6d1db353933d06156cedec90abe51fb" + integrity sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ== + ieee754@^1.1.13, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -4482,6 +4549,11 @@ is-path-inside@^4.0.0: resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-4.0.0.tgz#805aeb62c47c1b12fc3fd13bfb3ed1e7430071db" integrity sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA== +is-plain-obj@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz#d65025edec3657ce032fd7db63c97883eaed71f0" + integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg== + is-reference@1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-1.2.1.tgz#8b2dac0b371f4bc994fdeaba9eb542d03002d0b7" @@ -4506,6 +4578,16 @@ is-stream@^3.0.0: resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-3.0.0.tgz#e6bfd7aa6bef69f4f472ce9bb681e3e57b4319ac" integrity sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA== +is-stream@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-4.0.1.tgz#375cf891e16d2e4baec250b85926cffc14720d9b" + integrity sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A== + +is-unicode-supported@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz#09f0ab0de6d3744d48d265ebb98f65d11f2a9b3a" + integrity sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ== + is-what@^4.1.8: version "4.1.16" resolved "https://registry.yarnpkg.com/is-what/-/is-what-4.1.16.tgz#1ad860a19da8b4895ad5495da3182ce2acdd7a6f" @@ -4633,6 +4715,15 @@ json5@^2.2.3: resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + keyv@^4.5.4: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" @@ -5412,6 +5503,11 @@ node-forge@^1.3.1: resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3" integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA== +node-graceful-shutdown@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/node-graceful-shutdown/-/node-graceful-shutdown-1.1.5.tgz#2c5b405a27d712a409e69b67dfe215d30154faff" + integrity sha512-tlz8XpPr+pqrEGWFNLtMwd0HdFsCAKp5NCmMvwcTZTA0hyrVd7gkKbivDcSM5uYB1331/cEjNTtmVtqKy8OSBw== + node-gyp-build@^4.2.2, node-gyp-build@^4.8.1: version "4.8.4" resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz#8a70ee85464ae52327772a90d66c6077a900cfc8" @@ -5745,6 +5841,11 @@ parse-json@^8.0.0: index-to-position "^1.0.0" type-fest "^4.37.0" +parse-ms@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/parse-ms/-/parse-ms-4.0.0.tgz#c0c058edd47c2a590151a718990533fd62803df4" + integrity sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw== + parse-path@^7.0.0: version "7.0.1" resolved "https://registry.yarnpkg.com/parse-path/-/parse-path-7.0.1.tgz#ae548cd36315fd8881a3610eae99fa08123ee0e2" @@ -6136,6 +6237,13 @@ pretty-bytes@^6.1.1: resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-6.1.1.tgz#38cd6bb46f47afbf667c202cfc754bffd2016a3b" integrity sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ== +pretty-ms@^9.0.0: + version "9.2.0" + resolved "https://registry.yarnpkg.com/pretty-ms/-/pretty-ms-9.2.0.tgz#e14c0aad6493b69ed63114442a84133d7e560ef0" + integrity sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg== + dependencies: + parse-ms "^4.0.0" + prisma@^6.7.0: version "6.7.0" resolved "https://registry.yarnpkg.com/prisma/-/prisma-6.7.0.tgz#7890e352bde11f6ec3018802d3fc1bcbe0027bd2" @@ -6842,6 +6950,11 @@ strip-final-newline@^3.0.0: resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-3.0.0.tgz#52894c313fbff318835280aed60ff71ebf12b8fd" integrity sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw== +strip-final-newline@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-4.0.0.tgz#35a369ec2ac43df356e3edd5dcebb6429aa1fa5c" + integrity sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw== + strip-indent@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-4.0.0.tgz#b41379433dd06f5eae805e21d631e07ee670d853" @@ -7240,6 +7353,11 @@ unimport@^4.1.2, unimport@^4.1.3: unplugin "^2.2.2" unplugin-utils "^0.2.4" +universalify@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d" + integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== + unplugin-remove@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/unplugin-remove/-/unplugin-remove-1.0.3.tgz#daf24110c2a0f5f679bb72aed9418078b1008043" @@ -7706,6 +7824,11 @@ yocto-queue@^1.0.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.2.1.tgz#36d7c4739f775b3cbc28e6136e21aa057adec418" integrity sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg== +yoctocolors@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/yoctocolors/-/yoctocolors-2.1.1.tgz#e0167474e9fbb9e8b3ecca738deaa61dd12e56fc" + integrity sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ== + youch-core@^0.3.1, youch-core@^0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/youch-core/-/youch-core-0.3.2.tgz#b8d473857cceb06f9ef5a9fd6c1038c80f132570" From ac7ef6303b8df482aa27ad8b27a4842c84be2551 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Sat, 10 May 2025 16:18:28 -0400 Subject: [PATCH 012/156] feat: new unified data folder --- nuxt.config.ts | 26 +++++++++++++++----------- server/internal/clients/ca-store.ts | 4 +++- server/internal/config/sys-conf.ts | 14 ++++++++++++++ server/internal/library/index.ts | 3 ++- server/internal/objects/fsBackend.ts | 3 ++- server/plugins/ca.ts | 4 +--- 6 files changed, 37 insertions(+), 17 deletions(-) create mode 100644 server/internal/config/sys-conf.ts diff --git a/nuxt.config.ts b/nuxt.config.ts index e4e014d..b5aac82 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -2,6 +2,17 @@ import tailwindcss from "@tailwindcss/vite"; // 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,6 +32,10 @@ export default defineNuxtConfig({ viewTransition: true, }, + // future: { + // compatibilityVersion: 4, + // }, + vite: { plugins: [tailwindcss()], }, @@ -76,17 +91,6 @@ export default defineNuxtConfig({ }, }, - extends: ["./drop-base"], - - // Module config from here down - modules: [ - "vue3-carousel-nuxt", - "nuxt-security", - // "@nuxt/image", - "@nuxt/fonts", - "@nuxt/eslint", - ], - carousel: { prefix: "Vue", }, diff --git a/server/internal/clients/ca-store.ts b/server/internal/clients/ca-store.ts index 317515b..a424731 100644 --- a/server/internal/clients/ca-store.ts +++ b/server/internal/clients/ca-store.ts @@ -2,6 +2,7 @@ import path from "path"; import fs from "fs"; import type { CertificateBundle } from "./ca"; import prisma from "../db/database"; +import { systemConfig } from "../config/sys-conf"; export type CertificateStore = { store(name: string, data: CertificateBundle): Promise; @@ -10,7 +11,8 @@ export type CertificateStore = { checkBlacklistCertificate(name: string): Promise; }; -export const fsCertificateStore = (base: string) => { +export const fsCertificateStore = () => { + const base = path.join(systemConfig.getDataFolder(), "certs"); const blacklist = path.join(base, ".blacklist"); fs.mkdirSync(blacklist, { recursive: true }); const store: CertificateStore = { diff --git a/server/internal/config/sys-conf.ts b/server/internal/config/sys-conf.ts new file mode 100644 index 0000000..306d81e --- /dev/null +++ b/server/internal/config/sys-conf.ts @@ -0,0 +1,14 @@ +class SystemConfig { + private libraryFolder = process.env.LIBRARY ?? "./.data/library"; + private dataFolder = process.env.DATA ?? "./.data/data"; + + getLibraryFolder() { + return this.libraryFolder; + } + + getDataFolder() { + return this.dataFolder; + } +} + +export const systemConfig = new SystemConfig(); diff --git a/server/internal/library/index.ts b/server/internal/library/index.ts index 91d3b21..6556702 100644 --- a/server/internal/library/index.ts +++ b/server/internal/library/index.ts @@ -15,12 +15,13 @@ import taskHandler from "../tasks"; import { parsePlatform } from "../utils/parseplatform"; import droplet from "@drop-oss/droplet"; import notificationSystem from "../notifications"; +import { systemConfig } from "../config/sys-conf"; class LibraryManager { private basePath: string; constructor() { - this.basePath = process.env.LIBRARY ?? "./.data/library"; + this.basePath = systemConfig.getLibraryFolder(); fs.mkdirSync(this.basePath, { recursive: true }); } diff --git a/server/internal/objects/fsBackend.ts b/server/internal/objects/fsBackend.ts index 3f0b865..34e0800 100644 --- a/server/internal/objects/fsBackend.ts +++ b/server/internal/objects/fsBackend.ts @@ -7,6 +7,7 @@ import { Readable } from "stream"; import { createHash } from "crypto"; import prisma from "../db/database"; import cacheHandler from "../cache"; +import { systemConfig } from "../config/sys-conf"; export class FsObjectBackend extends ObjectBackend { private baseObjectPath: string; @@ -16,7 +17,7 @@ export class FsObjectBackend extends ObjectBackend { constructor() { super(); - const basePath = process.env.FS_BACKEND_PATH ?? "./.data/objects"; + const basePath = path.join(systemConfig.getDataFolder(), "objects"); this.baseObjectPath = path.join(basePath, "objects"); this.baseMetadataPath = path.join(basePath, "metadata"); diff --git a/server/plugins/ca.ts b/server/plugins/ca.ts index 0650f51..4f2aff2 100644 --- a/server/plugins/ca.ts +++ b/server/plugins/ca.ts @@ -9,9 +9,7 @@ export const useCertificateAuthority = () => { }; export default defineNitroPlugin(async () => { - // const basePath = process.env.CLIENT_CERTIFICATES ?? "./certs"; - // fs.mkdirSync(basePath, { recursive: true }); - // const store = fsCertificateStore(basePath); + // const store = fsCertificateStore(); ca = await CertificateAuthority.new(dbCertificateStore()); }); From 70db79b50f7c07f712daaa0565ac268d67c0a154 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Mon, 12 May 2025 09:44:08 +1000 Subject: [PATCH 013/156] feat: set data dir for headscale --- .gitmodules | 2 +- package.json | 3 ++- server/internal/config/sys-conf.ts | 7 +++++ server/internal/p2p/headscale.ts | 8 ++++-- yarn.lock | 41 +++++++++++++++++++++++++++--- 5 files changed, 53 insertions(+), 8 deletions(-) diff --git a/.gitmodules b/.gitmodules index e24bb0c..222a086 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "drop-base"] path = drop-base - url = https://github.com/Drop-OSS/drop-base.git + url = https://github.com/Drop-OSS/drop-base.git \ No newline at end of file diff --git a/package.json b/package.json index a46c996..ca073bb 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,8 @@ "@types/turndown": "^5.0.5", "autoprefixer": "^10.4.20", "eslint": "^9.24.0", - "eslint-config-prettier": "^10.1.1", + "eslint-config-prettier": "^10.1.5", + "eslint-plugin-prettier": "^5.4.0", "h3": "^1.15.1", "ofetch": "^1.4.1", "postcss": "^8.4.47", diff --git a/server/internal/config/sys-conf.ts b/server/internal/config/sys-conf.ts index 306d81e..0d0b5e2 100644 --- a/server/internal/config/sys-conf.ts +++ b/server/internal/config/sys-conf.ts @@ -1,6 +1,9 @@ +import path from "path"; + class SystemConfig { private libraryFolder = process.env.LIBRARY ?? "./.data/library"; private dataFolder = process.env.DATA ?? "./.data/data"; + private headscaleFolder = path.join(this.dataFolder, "headscale"); getLibraryFolder() { return this.libraryFolder; @@ -9,6 +12,10 @@ class SystemConfig { getDataFolder() { return this.dataFolder; } + + getHeadscaleFolder() { + return this.headscaleFolder; + } } export const systemConfig = new SystemConfig(); diff --git a/server/internal/p2p/headscale.ts b/server/internal/p2p/headscale.ts index f8847ce..0aacff1 100644 --- a/server/internal/p2p/headscale.ts +++ b/server/internal/p2p/headscale.ts @@ -1,5 +1,6 @@ -import type { HeadscaleService} from "@drop-oss/headscalez"; +import type { HeadscaleService } from "@drop-oss/headscalez"; import { startHeadscale } from "@drop-oss/headscalez"; +import { systemConfig } from "../config/sys-conf"; export class HeadscaleManager { private headscaleService?: HeadscaleService; @@ -11,7 +12,10 @@ export class HeadscaleManager { async setup() { const externalUrl = process.env.CONTROL_URL; if (externalUrl) { - const headscale = await startHeadscale({ externalUrl }); + const headscale = await startHeadscale({ + externalUrl, + dir: systemConfig.getHeadscaleFolder(), + }); this.headscaleService = headscale; } } diff --git a/yarn.lock b/yarn.lock index 9928e3c..92c2a1b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1469,6 +1469,11 @@ resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.2.1.tgz#6d083acfddae21fb329c8df8c94bf895ce7d0c15" integrity sha512-VzgHzGblFmUeBmmrk55zPyrQIArQN4vujc9shWytaPdB3P7qhi0cpaiKIr7tlCmFv2lYUwnLospIqjL9ZSAhhg== +"@pkgr/core@^0.2.3": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.2.4.tgz#d897170a2b0ba51f78a099edccd968f7b103387c" + integrity sha512-ROFF39F6ZrnzSUEmQQZUar0Jt4xVoP9WnDRdWwF4NNcXs3xBTLgBUDoOwW141y1jP+S8nahIbdxbFC7IShw9Iw== + "@polka/url@^1.0.0-next.24": version "1.0.0-next.28" resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.28.tgz#d45e01c4a56f143ee69c54dd6b12eade9e270a73" @@ -3566,10 +3571,10 @@ eslint-config-flat-gitignore@^2.1.0: dependencies: "@eslint/compat" "^1.2.5" -eslint-config-prettier@^10.1.1: - version "10.1.1" - resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.1.1.tgz#cf0ff6e5c4e7e15f129f1f1ce2a5ecba92dec132" - integrity sha512-4EQQr6wXwS+ZJSzaR5ZCrYgLxqvUjdXctaEtBqHcbkW944B1NQyO4qpdHQbXBONfwxXdkAY81HH4+LUfrg+zPw== +eslint-config-prettier@^10.1.5: + version "10.1.5" + resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz#00c18d7225043b6fbce6a665697377998d453782" + integrity sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw== eslint-flat-config-utils@^2.0.1: version "2.0.1" @@ -3628,6 +3633,14 @@ eslint-plugin-jsdoc@^50.6.9: spdx-expression-parse "^4.0.0" synckit "^0.9.1" +eslint-plugin-prettier@^5.4.0: + version "5.4.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.4.0.tgz#54d4748904e58eaf1ffe26c4bffa4986ca7f952b" + integrity sha512-BvQOvUhkVQM1i63iMETK9Hjud9QhqBnbtT1Zc642p9ynzBuCe5pybkOnvqZIBypXmMlsGcnU4HZ8sCTPfpAexA== + dependencies: + prettier-linter-helpers "^1.0.0" + synckit "^0.11.0" + eslint-plugin-regexp@^2.7.0: version "2.7.0" resolved "https://registry.yarnpkg.com/eslint-plugin-regexp/-/eslint-plugin-regexp-2.7.0.tgz#84fa2d2b122e343f596cfb83ed8a5517a1dfef2a" @@ -3866,6 +3879,11 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== +fast-diff@^1.1.2: + version "1.3.0" + resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.3.0.tgz#ece407fa550a64d638536cd727e129c61616e0f0" + integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw== + fast-fifo@^1.2.0, fast-fifo@^1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.3.2.tgz#286e31de96eb96d38a97899815740ba2a4f3640c" @@ -6227,6 +6245,13 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== +prettier-linter-helpers@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz#d23d41fe1375646de2d0104d3454a3008802cf7b" + integrity sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w== + dependencies: + fast-diff "^1.1.2" + prettier@^3.5.3: version "3.5.3" resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.5.3.tgz#4fc2ce0d657e7a02e602549f053b239cb7dfe1b5" @@ -7037,6 +7062,14 @@ svgo@^3.2.0, svgo@^3.3.2: csso "^5.0.5" picocolors "^1.0.0" +synckit@^0.11.0: + version "0.11.4" + resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.4.tgz#48972326b59723fc15b8d159803cf8302b545d59" + integrity sha512-Q/XQKRaJiLiFIBNN+mndW7S/RHxvwzuZS6ZwmRzUBqJBv/5QIKCEwkBC8GBf8EQJKYnaFs0wOZbKTXBPj8L9oQ== + dependencies: + "@pkgr/core" "^0.2.3" + tslib "^2.8.1" + synckit@^0.9.1: version "0.9.2" resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.9.2.tgz#a3a935eca7922d48b9e7d6c61822ee6c3ae4ec62" From ce0e21be9a9d122142b4bfe896c2a02a7cdfe9ed Mon Sep 17 00:00:00 2001 From: DecDuck Date: Mon, 12 May 2025 17:10:36 +1000 Subject: [PATCH 014/156] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ca881d9..8777058 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ # Drop [![Website](https://img.shields.io/badge/website-000000?style=for-the-badge&logo=About.me&logoColor=white)](https://droposs.org) +[![Static Badge](https://img.shields.io/badge/FORUM-blue?style=flat-square)](https://forum.droposs.org) [![GitHub License](https://img.shields.io/badge/AGPL--3.0-red?style=for-the-badge)](LICENSE) [![Discord](https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/ACq4qZp4a9) [![Open Collective](https://img.shields.io/badge/OpenCollective-1F87FF?style=for-the-badge&logo=OpenCollective&logoColor=white)](https://opencollective.com/drop-oss) From 086664adfdbbdc453c6050effb6b4af179abe0cc Mon Sep 17 00:00:00 2001 From: DecDuck Date: Mon, 12 May 2025 17:11:19 +1000 Subject: [PATCH 015/156] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8777058..69ae307 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ # Drop [![Website](https://img.shields.io/badge/website-000000?style=for-the-badge&logo=About.me&logoColor=white)](https://droposs.org) -[![Static Badge](https://img.shields.io/badge/FORUM-blue?style=flat-square)](https://forum.droposs.org) +[![Static Badge](https://img.shields.io/badge/FORUM-blue?style=for-the-badge)](https://forum.droposs.org) [![GitHub License](https://img.shields.io/badge/AGPL--3.0-red?style=for-the-badge)](LICENSE) [![Discord](https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/ACq4qZp4a9) [![Open Collective](https://img.shields.io/badge/OpenCollective-1F87FF?style=for-the-badge&logo=OpenCollective&logoColor=white)](https://opencollective.com/drop-oss) From 4fb0a185f63bce0e5742cc7f9816fc1b7aa1509e Mon Sep 17 00:00:00 2001 From: DecDuck Date: Tue, 13 May 2025 12:28:18 +1000 Subject: [PATCH 016/156] feat: preliminary peer api --- package.json | 2 +- server/api/v1/client/peer/index.get.ts | 20 +++++++++ server/api/v1/client/peer/index.post.ts | 26 ++++++++++++ server/internal/p2p/headscale.ts | 55 ++++++++++++++++++++++++- yarn.lock | 31 ++++++++++++-- 5 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 server/api/v1/client/peer/index.get.ts create mode 100644 server/api/v1/client/peer/index.post.ts diff --git a/package.json b/package.json index ca073bb..699d1d2 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ }, "dependencies": { "@drop-oss/droplet": "^0.7.2", - "@drop-oss/headscalez": "^0.0.3", + "@drop-oss/headscalez": "0.0.4", "@headlessui/vue": "^1.7.23", "@heroicons/vue": "^2.1.5", "@nuxt/fonts": "^0.11.0", diff --git a/server/api/v1/client/peer/index.get.ts b/server/api/v1/client/peer/index.get.ts new file mode 100644 index 0000000..c37d8cd --- /dev/null +++ b/server/api/v1/client/peer/index.get.ts @@ -0,0 +1,20 @@ +import { ClientCapabilities } from "~/prisma/client"; +import { defineClientEventHandler } from "~/server/internal/clients/event-handler"; +import headscaleManager from "~/server/internal/p2p/headscale"; + +export default defineClientEventHandler(async (h3, { fetchClient }) => { + const client = await fetchClient(); + if (!client.capabilities.includes(ClientCapabilities.PeerAPI)) + throw createError({ + statusCode: 403, + statusMessage: "Capability not allowed.", + }); + + if (!headscaleManager.enabled()) + throw createError({ + statusCode: 500, + statusMessage: "Peer network not available.", + }); + + return headscaleManager.configuration(); +}); diff --git a/server/api/v1/client/peer/index.post.ts b/server/api/v1/client/peer/index.post.ts new file mode 100644 index 0000000..775a7f4 --- /dev/null +++ b/server/api/v1/client/peer/index.post.ts @@ -0,0 +1,26 @@ +import { ClientCapabilities } from "~/prisma/client"; +import { defineClientEventHandler } from "~/server/internal/clients/event-handler"; +import headscaleManager from "~/server/internal/p2p/headscale"; + +export default defineClientEventHandler( + async (h3, { fetchClient, fetchUser }) => { + const client = await fetchClient(); + if (!client.capabilities.includes(ClientCapabilities.PeerAPI)) + throw createError({ + statusCode: 403, + statusMessage: "Capability not allowed.", + }); + + if (!headscaleManager.enabled()) + throw createError({ + statusCode: 500, + statusMessage: "Peer network not available.", + }); + + const user = await fetchUser(); + + const key = await headscaleManager.createPreauthKey(user, client); + + return key; + }, +); diff --git a/server/internal/p2p/headscale.ts b/server/internal/p2p/headscale.ts index 0aacff1..2472334 100644 --- a/server/internal/p2p/headscale.ts +++ b/server/internal/p2p/headscale.ts @@ -1,9 +1,12 @@ import type { HeadscaleService } from "@drop-oss/headscalez"; -import { startHeadscale } from "@drop-oss/headscalez"; +import { HeadscaleControlService, startHeadscale } from "@drop-oss/headscalez"; import { systemConfig } from "../config/sys-conf"; +import type { Client, User } from "~/prisma/client"; export class HeadscaleManager { + private controlURL?: string; private headscaleService?: HeadscaleService; + private headscaleClient?: HeadscaleControlService; constructor() { this.setup(); @@ -12,17 +15,67 @@ export class HeadscaleManager { async setup() { const externalUrl = process.env.CONTROL_URL; if (externalUrl) { + this.controlURL = externalUrl; const headscale = await startHeadscale({ externalUrl, dir: systemConfig.getHeadscaleFolder(), }); this.headscaleService = headscale; + this.headscaleClient = new HeadscaleControlService(headscale); } } enabled() { return !!this.headscaleService; } + + configuration() { + if (!this.controlURL) throw new Error("Headscale not available"); + return { + controlURL: this.controlURL, + }; + } + + private async fetchUser(user: User) { + if (!this.headscaleClient) + throw new Error("Headscale client not available"); + const { response } = await this.headscaleClient.client.listUsers({ + id: 0n, + name: user.id, + email: "", + }); + + if (response.users.length == 0) { + const { response } = await this.headscaleClient.client.createUser({ + name: user.id, + displayName: user.displayName, + email: user.email, + pictureUrl: user.profilePictureObjectId, + }); + + if (!response.user) throw new Error("Could not create user"); + + return response.user; + } + + return response.users[0]; + } + + async createPreauthKey(user: User, client: Client) { + if (!this.headscaleClient) + throw new Error("Headscale client not available"); + + const headscaleUser = await this.fetchUser(user); + const { response } = await this.headscaleClient.client.createPreAuthKey({ + user: headscaleUser.name, + reusable: false, + ephemeral: false, + aclTags: ["client", `user:${user.id}`, `client:${client.id}`], + }); + + if (!response.preAuthKey) throw new Error("Could not create pre-auth key"); + return response.preAuthKey; + } } export const headscaleManager = new HeadscaleManager(); diff --git a/yarn.lock b/yarn.lock index 92c2a1b..68fcd2c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -371,11 +371,14 @@ "@drop-oss/droplet-win32-arm64-msvc" "0.7.2" "@drop-oss/droplet-win32-x64-msvc" "0.7.2" -"@drop-oss/headscalez@^0.0.3": - version "0.0.3" - resolved "https://registry.yarnpkg.com/@drop-oss/headscalez/-/headscalez-0.0.3.tgz#deb6eae13b9b65cf225bb2e0e84bc47159b4a91e" - integrity sha512-Zsl0T/pwJyw7vbLwEUVAf9zXKKTG3qAbUVQEDiRe88bwhzauPG9/MgBBHA2N/9GHXMUFRRDcchPbwGjCcmcJ3Q== +"@drop-oss/headscalez@0.0.4": + version "0.0.4" + resolved "https://registry.yarnpkg.com/@drop-oss/headscalez/-/headscalez-0.0.4.tgz#7558fbbc542d93fb0c69bdcf48917bdae676c62d" + integrity sha512-vdDq6Qe/T5S0FKKYdnBXvOh53wGs8nxy6O3ACVyUjTbTGkDn3z5og4joh5TxoAOFodPHRLCihVuroiymS7yNTg== dependencies: + "@protobuf-ts/grpcweb-transport" "^2.10.0" + "@protobuf-ts/runtime" "^2.10.0" + "@protobuf-ts/runtime-rpc" "^2.10.0" execa "^9.5.3" fs-extra "^11.3.0" node-graceful-shutdown "^1.1.5" @@ -1549,6 +1552,26 @@ dependencies: "@prisma/debug" "6.7.0" +"@protobuf-ts/grpcweb-transport@^2.10.0": + version "2.10.0" + resolved "https://registry.yarnpkg.com/@protobuf-ts/grpcweb-transport/-/grpcweb-transport-2.10.0.tgz#c09f29694ec7dc4fc1107a0bf30d359325939311" + integrity sha512-VUyD+8kn4XEfWoEiKjAsWX5XWkt8Gfdp/uquS17yt9hdfMBVx3o5tlXX5ylwfWhcIjbRvkf7WHEEdS2wMuq86Q== + dependencies: + "@protobuf-ts/runtime" "^2.10.0" + "@protobuf-ts/runtime-rpc" "^2.10.0" + +"@protobuf-ts/runtime-rpc@^2.10.0": + version "2.10.0" + resolved "https://registry.yarnpkg.com/@protobuf-ts/runtime-rpc/-/runtime-rpc-2.10.0.tgz#68de8dcc369e56579569a4deafd394cf4683dc66" + integrity sha512-8CS/XPv3+pMK4v8UKhtCdvbS4h9l7aqlteKdRt0/UbIKZ8n0qHj6hX8cBhz2ngvohxCOS0N08zPr9aCLBNhW3Q== + dependencies: + "@protobuf-ts/runtime" "^2.10.0" + +"@protobuf-ts/runtime@^2.10.0": + version "2.10.0" + resolved "https://registry.yarnpkg.com/@protobuf-ts/runtime/-/runtime-2.10.0.tgz#bc90f632647ff2ae72887546ddf3d193f3f43d98" + integrity sha512-ypYwGg9Pn3W/2lZ7/HW60hONGuSdzphvOY8Dq7LeNttymDe0y3LaTUUMRpuGqOT6FfrWEMnfQbyqU8AAreo8wA== + "@rollup/plugin-alias@^5.1.1": version "5.1.1" resolved "https://registry.yarnpkg.com/@rollup/plugin-alias/-/plugin-alias-5.1.1.tgz#53601d88cda8b1577aa130b4a6e452283605bf26" From a101ff07c4be65826f87b2a06dd36b012d0647b6 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Wed, 14 May 2025 15:40:55 -0400 Subject: [PATCH 017/156] fix: allow notification nonce reuse per user --- .../migration.sql | 11 +++++++++++ prisma/models/user.prisma | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20250514193830_allow_notification_nonce_reuse_per_user/migration.sql diff --git a/prisma/migrations/20250514193830_allow_notification_nonce_reuse_per_user/migration.sql b/prisma/migrations/20250514193830_allow_notification_nonce_reuse_per_user/migration.sql new file mode 100644 index 0000000..035b237 --- /dev/null +++ b/prisma/migrations/20250514193830_allow_notification_nonce_reuse_per_user/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[userId,nonce]` on the table `Notification` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropIndex +DROP INDEX "Notification_nonce_key"; + +-- CreateIndex +CREATE UNIQUE INDEX "Notification_userId_nonce_key" ON "Notification"("userId", "nonce"); diff --git a/prisma/models/user.prisma b/prisma/models/user.prisma index 20f1c60..0b4f272 100644 --- a/prisma/models/user.prisma +++ b/prisma/models/user.prisma @@ -24,7 +24,7 @@ model User { model Notification { id String @id @default(uuid()) - nonce String? @unique + nonce String? userId String user User @relation(fields: [userId], references: [id]) @@ -35,4 +35,6 @@ model Notification { actions String[] read Boolean @default(false) + + @@unique([userId, nonce]) } From b03349671012e693bf238b4cabc23ca4c3597924 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Wed, 14 May 2025 16:07:25 -0400 Subject: [PATCH 018/156] feat: update checker based gh releases --- nuxt.config.ts | 2 +- package.json | 2 + server/h3.d.ts | 4 + server/internal/config/sys-conf.ts | 14 +++ server/internal/metadata/index.ts | 3 +- server/internal/notifications/index.ts | 36 ++++++- server/plugins/tasks.ts | 3 + server/tasks/check/update.ts | 143 +++++++++++++++++++++++++ yarn.lock | 5 + 9 files changed, 205 insertions(+), 7 deletions(-) create mode 100644 server/tasks/check/update.ts diff --git a/nuxt.config.ts b/nuxt.config.ts index b531bcd..96b9415 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,6 +1,6 @@ import tailwindcss from "@tailwindcss/vite"; -const dropVersion = "0.3"; +const dropVersion = "v0.3.0"; // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ diff --git a/package.json b/package.json index 398a063..b9a52f9 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "nuxt": "^3.16.2", "nuxt-security": "2.2.0", "prisma": "^6.7.0", + "semver": "^7.7.1", "stream-mime-type": "^2.0.0", "turndown": "^7.2.0", "unstorage": "^1.15.0", @@ -53,6 +54,7 @@ "@types/bcryptjs": "^3.0.0", "@types/luxon": "^3.6.2", "@types/node": "^22.13.16", + "@types/semver": "^7.7.0", "@types/turndown": "^5.0.5", "autoprefixer": "^10.4.20", "eslint": "^9.24.0", diff --git a/server/h3.d.ts b/server/h3.d.ts index 76ff537..67eced1 100644 --- a/server/h3.d.ts +++ b/server/h3.d.ts @@ -1 +1,5 @@ export type MinimumRequestObject = { headers: Headers }; + +export type TaskReturn = + | { success: true; data: T; error?: never } + | { success: false; data?: never; error: { message: string } }; diff --git a/server/internal/config/sys-conf.ts b/server/internal/config/sys-conf.ts index 306d81e..560a661 100644 --- a/server/internal/config/sys-conf.ts +++ b/server/internal/config/sys-conf.ts @@ -1,6 +1,12 @@ class SystemConfig { private libraryFolder = process.env.LIBRARY ?? "./.data/library"; private dataFolder = process.env.DATA ?? "./.data/data"; + private dropVersion = "v0.3.0"; + private checkForUpdates = + process.env.CHECK_FOR_UPDATES !== undefined && + process.env.CHECK_FOR_UPDATES.toLocaleLowerCase() === "true" + ? true + : false; getLibraryFolder() { return this.libraryFolder; @@ -9,6 +15,14 @@ class SystemConfig { getDataFolder() { return this.dataFolder; } + + getDropVersion() { + return this.dropVersion; + } + + shouldCheckForUpdates() { + return this.checkForUpdates; + } } export const systemConfig = new SystemConfig(); diff --git a/server/internal/metadata/index.ts b/server/internal/metadata/index.ts index f50e8e4..8721459 100644 --- a/server/internal/metadata/index.ts +++ b/server/internal/metadata/index.ts @@ -10,6 +10,7 @@ import type { } from "./types"; import { ObjectTransactionalHandler } from "../objects/transactional"; import { PriorityListIndexed } from "../utils/prioritylist"; +import { systemConfig } from "../config/sys-conf"; export class MissingMetadataProviderConfig extends Error { private providerName: string; @@ -25,7 +26,7 @@ export class MissingMetadataProviderConfig extends Error { } // TODO: add useragent to all outbound api calls (best practice) -export const DropUserAgent = "Drop/0.2"; +export const DropUserAgent = `Drop/${systemConfig.getDropVersion()}`; export abstract class MetadataProvider { abstract name(): string; diff --git a/server/internal/notifications/index.ts b/server/internal/notifications/index.ts index 930f317..67570dc 100644 --- a/server/internal/notifications/index.ts +++ b/server/internal/notifications/index.ts @@ -9,6 +9,7 @@ Design goals: import type { Notification } from "~/prisma/client"; import prisma from "../db/database"; +// TODO: document notification action format export type NotificationCreateArgs = Pick< Notification, "title" | "description" | "actions" | "nonce" @@ -61,14 +62,18 @@ class NotificationSystem { throw new Error("No nonce in notificationCreateArgs"); const notification = await prisma.notification.upsert({ where: { - nonce: notificationCreateArgs.nonce, + userId_nonce: { + nonce: notificationCreateArgs.nonce, + userId, + }, }, update: { - userId: userId, + // we don't need to update the userid right? + // userId: userId, ...notificationCreateArgs, }, create: { - userId: userId, + userId, ...notificationCreateArgs, }, }); @@ -84,13 +89,34 @@ class NotificationSystem { }, }); + const res: Promise[] = []; for (const user of users) { - await this.push(user.id, notificationCreateArgs); + res.push(this.push(user.id, notificationCreateArgs)); } + // wait for all notifications to pass + await Promise.all(res); } async systemPush(notificationCreateArgs: NotificationCreateArgs) { - return await this.push("system", notificationCreateArgs); + await this.push("system", notificationCreateArgs); + } + + async pushAllAdmins(notificationCreateArgs: NotificationCreateArgs) { + const users = await prisma.user.findMany({ + where: { + admin: true, + }, + select: { + id: true, + }, + }); + + const res: Promise[] = []; + for (const user of users) { + res.push(this.push(user.id, notificationCreateArgs)); + } + // wait for all notifications to pass + await Promise.all(res); } } diff --git a/server/plugins/tasks.ts b/server/plugins/tasks.ts index 3c8dde4..a822007 100644 --- a/server/plugins/tasks.ts +++ b/server/plugins/tasks.ts @@ -3,5 +3,8 @@ export default defineNitroPlugin(async (_nitro) => { await Promise.all([ runTask("cleanup:invitations"), runTask("cleanup:sessions"), + // TODO: maybe implement some sort of rate limit thing to prevent this from calling github api a bunch in the event of crashloop or whatever? + // probably will require custom task scheduler for object cleanup anyway, so something to thing about + runTask("check:update"), ]); }); diff --git a/server/tasks/check/update.ts b/server/tasks/check/update.ts new file mode 100644 index 0000000..96de84d --- /dev/null +++ b/server/tasks/check/update.ts @@ -0,0 +1,143 @@ +import { type } from "arktype"; +import { systemConfig } from "../../internal/config/sys-conf"; +import * as semver from "semver"; +import type { TaskReturn } from "../../h3"; +import notificationSystem from "../../internal/notifications"; + +const latestRelease = type({ + url: "string", // api url for specific release + html_url: "string", // user facing url + id: "number", // release id + tag_name: "string", // tag used for release + name: "string", // release name + draft: "boolean", + prerelease: "boolean", + created_at: "string", + published_at: "string", +}); + +export default defineTask({ + meta: { + name: "check:update", + }, + async run() { + if (systemConfig.shouldCheckForUpdates()) { + console.log("[Task check:update]: Checking for update"); + try { + const response = await fetch( + "https://api.github.com/repos/Drop-OSS/drop/releases/latest", + ); + + if (!response.ok) { + console.log("[Task check:update]: Failed to check for update", { + status: response.status, + body: response.body, + }); + + return { + result: { + success: false, + error: { + message: "" + response.status, + }, + }, + }; + } + + const resJson = await response.json(); + const body = latestRelease(resJson); + if (body instanceof type.errors) { + console.error(body.summary); + console.log("GitHub Api response", resJson); + return { + result: { + success: false, + error: { + message: body.summary, + }, + }, + }; + } + + // const currVerStr = systemConfig.getDropVersion() + const currVerStr = "v0.1"; + + const latestVer = semver.coerce(body.tag_name); + const currVer = semver.coerce(currVerStr); + if (latestVer === null) { + const msg = "Github Api returned invalid semver tag"; + console.log("[Task check:update]:", msg); + return { + result: { + success: false, + error: { + message: msg, + }, + }, + }; + } else if (currVer === null) { + const msg = "Drop provided a invalid semver tag"; + console.log("[Task check:update]:", msg); + return { + result: { + success: false, + error: { + message: msg, + }, + }, + }; + } + + if (semver.gt(latestVer, currVer)) { + console.log("[Task check:update]: Update available"); + notificationSystem.pushAllAdmins({ + nonce: `drop-update-available-${currVer}-to-${latestVer}`, + title: `Update available to v${latestVer}`, + description: `A new version of Drop is available v${latestVer}`, + actions: [`View|${body.html_url}`], + }); + } else { + console.log("[Task check:update]: no update available"); + } + + console.log("[Task check:update]: Done"); + } catch (e) { + console.error(e); + if (typeof e === "string") { + return { + result: { + success: false, + error: { + message: e, + }, + }, + }; + } else if (e instanceof Error) { + return { + result: { + success: false, + error: { + message: e.message, + }, + }, + }; + } + + return { + result: { + success: false, + error: { + message: "unknown cause, please check console", + }, + }, + }; + } + } + return { + result: { + success: true, + data: undefined, + }, + }; + }, +}); diff --git a/yarn.lock b/yarn.lock index 99ff8c7..634ac27 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1830,6 +1830,11 @@ resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.2.tgz#97d26e00cd4a0423b4af620abecf3e6f442b7975" integrity sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q== +"@types/semver@^7.7.0": + version "7.7.0" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.7.0.tgz#64c441bdae033b378b6eef7d0c3d77c329b9378e" + integrity sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA== + "@types/turndown@^5.0.5": version "5.0.5" resolved "https://registry.yarnpkg.com/@types/turndown/-/turndown-5.0.5.tgz#614de24fc9ace4d8c0d9483ba81dc8c1976dd26f" From ccdbbcf01cc489b0850c2bde6acd4a44883ae0a8 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Wed, 14 May 2025 16:30:35 -0400 Subject: [PATCH 019/156] fix: editing game image metadata in admin panel --- pages/admin/metadata/games/[id]/index.vue | 13 ++++++------- server/api/v1/admin/game/image/index.delete.ts | 4 +--- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/pages/admin/metadata/games/[id]/index.vue b/pages/admin/metadata/games/[id]/index.vue index 7af908a..9631a7a 100644 --- a/pages/admin/metadata/games/[id]/index.vue +++ b/pages/admin/metadata/games/[id]/index.vue @@ -10,7 +10,8 @@ class="flex flex-col lg:flex-row lg:justify-between items-start lg:items-center gap-2" >
    - + +

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

    - Date: Wed, 14 May 2025 16:51:45 -0400 Subject: [PATCH 020/156] fix: object fs backend not deleting metadata --- server/internal/objects/fsBackend.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/internal/objects/fsBackend.ts b/server/internal/objects/fsBackend.ts index 34e0800..375026f 100644 --- a/server/internal/objects/fsBackend.ts +++ b/server/internal/objects/fsBackend.ts @@ -99,6 +99,9 @@ export class FsObjectBackend extends ObjectBackend { const objectPath = path.join(this.baseObjectPath, id); if (!fs.existsSync(objectPath)) return true; fs.rmSync(objectPath); + const metadataPath = path.join(this.baseMetadataPath, `${id}.json`); + if (!fs.existsSync(metadataPath)) return true; + fs.rmSync(metadataPath); // remove item from cache await this.hashStore.delete(id); return true; @@ -153,6 +156,7 @@ export class FsObjectBackend extends ObjectBackend { await store.save(id, hashResult); return typeof hashResult; } + async listAll(): Promise { return fs.readdirSync(this.baseObjectPath); } From 2cc3f1329c64b5830d1df3030631acc3a096f59c Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Wed, 14 May 2025 17:19:39 -0400 Subject: [PATCH 021/156] feat: fs object metadata cache and validation --- server/internal/objects/fsBackend.ts | 22 ++++++++++++++++++---- server/internal/objects/objectHandler.ts | 15 ++++++++++----- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/server/internal/objects/fsBackend.ts b/server/internal/objects/fsBackend.ts index 375026f..de0ea0a 100644 --- a/server/internal/objects/fsBackend.ts +++ b/server/internal/objects/fsBackend.ts @@ -1,5 +1,5 @@ import type { ObjectMetadata, ObjectReference, Source } from "./objectHandler"; -import { ObjectBackend } from "./objectHandler"; +import { ObjectBackend, objectMetadata } from "./objectHandler"; import fs from "fs"; import path from "path"; @@ -8,12 +8,15 @@ import { createHash } from "crypto"; import prisma from "../db/database"; import cacheHandler from "../cache"; import { systemConfig } from "../config/sys-conf"; +import { type } from "arktype"; export class FsObjectBackend extends ObjectBackend { private baseObjectPath: string; private baseMetadataPath: string; private hashStore = new FsHashStore(); + private metadataCache = + cacheHandler.createCache("ObjectMetadata"); constructor() { super(); @@ -102,17 +105,27 @@ export class FsObjectBackend extends ObjectBackend { const metadataPath = path.join(this.baseMetadataPath, `${id}.json`); if (!fs.existsSync(metadataPath)) return true; fs.rmSync(metadataPath); - // remove item from cache + // remove item from caches + await this.metadataCache.remove(id); await this.hashStore.delete(id); return true; } async fetchMetadata( id: ObjectReference, ): Promise { + const cacheResult = await this.metadataCache.get(id); + if (cacheResult !== null) return cacheResult; + const metadataPath = path.join(this.baseMetadataPath, `${id}.json`); if (!fs.existsSync(metadataPath)) return undefined; - const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8")); - return metadata as ObjectMetadata; + const metadataRaw = JSON.parse(fs.readFileSync(metadataPath, "utf-8")); + const metadata = objectMetadata(metadataRaw); + if (metadata instanceof type.errors) { + console.error("FsObjectBackend#fetchMetadata", metadata.summary); + return undefined; + } + await this.metadataCache.set(id, metadata); + return metadata; } async writeMetadata( id: ObjectReference, @@ -121,6 +134,7 @@ export class FsObjectBackend extends ObjectBackend { const metadataPath = path.join(this.baseMetadataPath, `${id}.json`); if (!fs.existsSync(metadataPath)) return false; fs.writeFileSync(metadataPath, JSON.stringify(metadata)); + await this.metadataCache.set(id, metadata); return true; } async fetchHash(id: ObjectReference): Promise { diff --git a/server/internal/objects/objectHandler.ts b/server/internal/objects/objectHandler.ts index edbf8ad..81cb11f 100644 --- a/server/internal/objects/objectHandler.ts +++ b/server/internal/objects/objectHandler.ts @@ -14,17 +14,22 @@ * anotherUserId:write */ +import { type } from "arktype"; import { parse as getMimeTypeBuffer } from "file-type-mime"; import type { Writable } from "stream"; import { Readable } from "stream"; import { getMimeType as getMimeTypeStream } from "stream-mime-type"; export type ObjectReference = string; -export type ObjectMetadata = { - mime: string; - permissions: string[]; - userMetadata: { [key: string]: string }; -}; + +export const objectMetadata = type({ + mime: "string", + permissions: "string[]", + userMetadata: { + "[string]": "string", + }, +}); +export type ObjectMetadata = typeof objectMetadata.infer; export enum ObjectPermission { Read = "read", From 898516b33d3a8ea976620bc885b215f7d4e139ad Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Wed, 14 May 2025 18:27:31 -0400 Subject: [PATCH 022/156] chore: style --- pages/admin/metadata/index.vue | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pages/admin/metadata/index.vue b/pages/admin/metadata/index.vue index 44c8d15..72dab31 100644 --- a/pages/admin/metadata/index.vue +++ b/pages/admin/metadata/index.vue @@ -20,7 +20,8 @@ to="/admin/metadata/games" class="transition group aspect-[3/2] flex flex-col justify-center items-center rounded-lg bg-zinc-950 hover:bg-zinc-950/50 shadow" > - GAMES @@ -28,7 +29,8 @@ to="/admin/metadata/companies" class="transition group aspect-[3/2] flex flex-col justify-center items-center rounded-lg bg-zinc-950 hover:bg-zinc-950/50 shadow" > - Companies From 56e1ba64ed4865cfaf581d9e62d1ca4286ed39e8 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Wed, 14 May 2025 19:54:06 -0400 Subject: [PATCH 023/156] fix: check update not using drop's correct version --- server/tasks/check/update.ts | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/server/tasks/check/update.ts b/server/tasks/check/update.ts index 96de84d..6fbaa80 100644 --- a/server/tasks/check/update.ts +++ b/server/tasks/check/update.ts @@ -23,11 +23,28 @@ export default defineTask({ async run() { if (systemConfig.shouldCheckForUpdates()) { console.log("[Task check:update]: Checking for update"); + + const currVerStr = systemConfig.getDropVersion(); + const currVer = semver.coerce(currVerStr); + if (currVer === null) { + const msg = "Drop provided a invalid semver tag"; + console.log("[Task check:update]:", msg); + return { + result: { + success: false, + error: { + message: msg, + }, + }, + }; + } + try { const response = await fetch( "https://api.github.com/repos/Drop-OSS/drop/releases/latest", ); + // if response failed somehow if (!response.ok) { console.log("[Task check:update]: Failed to check for update", { status: response.status, @@ -44,6 +61,7 @@ export default defineTask({ }; } + // parse and validate response const resJson = await response.json(); const body = latestRelease(resJson); if (body instanceof type.errors) { @@ -59,11 +77,8 @@ export default defineTask({ }; } - // const currVerStr = systemConfig.getDropVersion() - const currVerStr = "v0.1"; - + // parse remote version const latestVer = semver.coerce(body.tag_name); - const currVer = semver.coerce(currVerStr); if (latestVer === null) { const msg = "Github Api returned invalid semver tag"; console.log("[Task check:update]:", msg); @@ -75,19 +90,9 @@ export default defineTask({ }, }, }; - } else if (currVer === null) { - const msg = "Drop provided a invalid semver tag"; - console.log("[Task check:update]:", msg); - return { - result: { - success: false, - error: { - message: msg, - }, - }, - }; } + // check if is newer version if (semver.gt(latestVer, currVer)) { console.log("[Task check:update]: Update available"); notificationSystem.pushAllAdmins({ From 6df2ef1740965c9c1f37d396b7a814e27d2c4d72 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Wed, 14 May 2025 21:38:16 -0400 Subject: [PATCH 024/156] fix: igdb assuming certain values always exist --- server/internal/metadata/igdb.ts | 56 +++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/server/internal/metadata/igdb.ts b/server/internal/metadata/igdb.ts index 22ae57e..075ad38 100644 --- a/server/internal/metadata/igdb.ts +++ b/server/internal/metadata/igdb.ts @@ -12,6 +12,7 @@ import type { import type { AxiosRequestConfig } from "axios"; import axios from "axios"; import { DateTime } from "luxon"; +import * as jdenticon from "jdenticon"; type IGDBID = number; @@ -68,8 +69,8 @@ interface IGDBCover extends IGDBItem { interface IGDBSearchStub extends IGDBItem { name: string; - cover: IGDBID; - first_release_date: number; // unix timestamp + cover?: IGDBID; + first_release_date?: number; // unix timestamp summary: string; } @@ -155,7 +156,7 @@ export class IGDBProvider implements MetadataProvider { } private async authWithTwitch() { - console.log("authorizing with twitch"); + console.log("IGDB authorizing with twitch"); const params = new URLSearchParams({ client_id: this.clientId, client_secret: this.clientSecret, @@ -168,10 +169,17 @@ export class IGDBProvider implements MetadataProvider { method: "POST", }); + if (response.status !== 200) + throw new Error( + `Error in IDGB \nStatus Code: ${response.status}\n${response.data}`, + ); + this.accessToken = response.data.access_token; this.accessTokenExpiry = DateTime.now().plus({ seconds: response.data.expires_in, }); + + console.log("IDGB done authorizing with twitch"); } private async refreshCredentials() { @@ -231,12 +239,19 @@ export class IGDBProvider implements MetadataProvider { } private async _getMediaInternal(mediaID: IGDBID, type: string) { + if (mediaID === undefined) + throw new Error( + `IGDB mediaID when getting item of type ${type} was undefined`, + ); + const body = `where id = ${mediaID}; fields url;`; const response = await this.request(type, body); let result = ""; response.forEach((cover) => { + console.log(cover); + if (cover.url.startsWith("https:")) { result = cover.url; } else { @@ -244,6 +259,7 @@ export class IGDBProvider implements MetadataProvider { result = `https:${cover.url}`; } }); + return result; } @@ -276,12 +292,24 @@ export class IGDBProvider implements MetadataProvider { const results: GameMetadataSearchResult[] = []; for (let i = 0; i < response.length; i++) { + let icon = ""; + const cover = response[i].cover; + if (cover !== undefined) { + icon = await this.getCoverURL(cover); + } else { + icon = "/wallpapers/error-wallpaper.jpg"; + } + + const firstReleaseDate = response[i].first_release_date; results.push({ id: "" + response[i].id, name: response[i].name, - icon: await this.getCoverURL(response[i].cover), + icon, description: response[i].summary, - year: DateTime.fromSeconds(response[i].first_release_date).year, + year: + firstReleaseDate === undefined + ? DateTime.now().year + : DateTime.fromSeconds(firstReleaseDate).year, }); } @@ -297,7 +325,14 @@ export class IGDBProvider implements MetadataProvider { const response = await this.request("games", body); for (let i = 0; i < response.length; i++) { - const icon = createObject(await this.getCoverURL(response[i].cover)); + let iconRaw; + const cover = response[i].cover; + if (cover !== undefined) { + iconRaw = await this.getCoverURL(cover); + } else { + iconRaw = jdenticon.toPng(id, 512); + } + const icon = createObject(iconRaw); let banner = ""; const images = [icon]; @@ -343,14 +378,17 @@ export class IGDBProvider implements MetadataProvider { } } + const firstReleaseDate = response[i].first_release_date; + return { id: "" + response[i].id, name: response[i].name, shortDescription: this.trimMessage(response[i].summary, 280), description: response[i].summary, - released: DateTime.fromSeconds( - response[i].first_release_date, - ).toJSDate(), + released: + firstReleaseDate === undefined + ? DateTime.now().toJSDate() + : DateTime.fromSeconds(firstReleaseDate).toJSDate(), reviewCount: response[i]?.total_rating_count ?? 0, reviewRating: (response[i]?.total_rating ?? 0) / 100, From bea26a9a6da1066cf86bceb132dd6f171b544505 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Wed, 14 May 2025 21:40:25 -0400 Subject: [PATCH 025/156] feat: game metadata rating support --- pages/store/[id]/index.vue | 7 +- prisma/models/content.prisma | 23 +++- server/internal/metadata/giantbomb.ts | 3 +- server/internal/metadata/igdb.ts | 11 +- server/internal/metadata/index.ts | 37 +++++- server/internal/metadata/manual.ts | 3 +- server/internal/metadata/pcgamingwiki.ts | 138 ++++++++++++++++++++--- server/internal/metadata/types.d.ts | 14 ++- 8 files changed, 203 insertions(+), 33 deletions(-) diff --git a/pages/store/[id]/index.vue b/pages/store/[id]/index.vue index cc6943f..48f2ec7 100644 --- a/pages/store/[id]/index.vue +++ b/pages/store/[id]/index.vue @@ -103,9 +103,7 @@ 'w-4 h-4', ]" /> - ({{ game.mReviewCount }} reviews) + ({{ 0 }} reviews) @@ -220,7 +218,8 @@ const platforms = game.versions .flat() .filter((e, i, u) => u.indexOf(e) === i); -const rating = Math.round(game.mReviewRating * 5); +// const rating = Math.round(game.mReviewRating * 5); +const rating = Math.round(0 * 5); const ratingArray = Array(5) .fill(null) .map((_, i) => i + 1 <= rating); diff --git a/prisma/models/content.prisma b/prisma/models/content.prisma index ee3b267..4631b06 100644 --- a/prisma/models/content.prisma +++ b/prisma/models/content.prisma @@ -3,6 +3,8 @@ enum MetadataSource { GiantBomb PCGamingWiki IGDB + Metacritic + OpenCritic } model Game { @@ -19,8 +21,7 @@ model Game { mDescription String // Supports markdown mReleased DateTime // When the game was released - mReviewCount Int - mReviewRating Float // 0 to 1 + ratings GameRating[] mIconObjectId String // linked to objects in s3 mBannerObjectId String // linked to objects in s3 @@ -42,6 +43,24 @@ model Game { @@unique([metadataSource, metadataId], name: "metadataKey") } +model GameRating { + id String @id @default(uuid()) + + metadataSource MetadataSource + metadataId String + created DateTime @default(now()) + + mReviewCount Int + mReviewRating Float // 0 to 1 + + mReviewHref String? + + Game Game? @relation(fields: [gameId], references: [id], onDelete: Cascade) + gameId String? + + @@unique([metadataSource, metadataId], name: "metadataKey") +} + // A particular set of files that relate to the version model GameVersion { gameId String diff --git a/server/internal/metadata/giantbomb.ts b/server/internal/metadata/giantbomb.ts index 2441aa4..312fab5 100644 --- a/server/internal/metadata/giantbomb.ts +++ b/server/internal/metadata/giantbomb.ts @@ -207,8 +207,7 @@ export class GiantBombProvider implements MetadataProvider { tags: [], - reviewCount: 0, - reviewRating: 0, + reviews: [], publishers, developers, diff --git a/server/internal/metadata/igdb.ts b/server/internal/metadata/igdb.ts index 075ad38..57656d7 100644 --- a/server/internal/metadata/igdb.ts +++ b/server/internal/metadata/igdb.ts @@ -390,8 +390,15 @@ export class IGDBProvider implements MetadataProvider { ? DateTime.now().toJSDate() : DateTime.fromSeconds(firstReleaseDate).toJSDate(), - reviewCount: response[i]?.total_rating_count ?? 0, - reviewRating: (response[i]?.total_rating ?? 0) / 100, + reviews: [ + { + metadataId: "" + response[i].id, + metadataSource: MetadataSource.IGDB, + mReviewCount: response[i]?.total_rating_count ?? 0, + mReviewRating: (response[i]?.total_rating ?? 0) / 100, + mReviewHref: response[i].url, + }, + ], publishers: [], developers: [], diff --git a/server/internal/metadata/index.ts b/server/internal/metadata/index.ts index 8721459..b62137c 100644 --- a/server/internal/metadata/index.ts +++ b/server/internal/metadata/index.ts @@ -1,4 +1,4 @@ -import { MetadataSource } from "~/prisma/client"; +import { MetadataSource, type GameRating } from "~/prisma/client"; import prisma from "../db/database"; import type { _FetchGameMetadataParams, @@ -7,6 +7,7 @@ import type { GameMetadataSearchResult, InternalGameMetadataResult, CompanyMetadata, + GameMetadataRating, } from "./types"; import { ObjectTransactionalHandler } from "../objects/transactional"; import { PriorityListIndexed } from "../utils/prioritylist"; @@ -135,6 +136,34 @@ export class MetadataHandler { return results; } + private parseRatings(ratings: GameMetadataRating[]) { + const results: { + where: { + metadataKey: { + metadataId: string; + metadataSource: MetadataSource; + }; + }; + create: Omit; + }[] = []; + + ratings.forEach((r) => { + results.push({ + where: { + metadataKey: { + metadataId: r.metadataId, + metadataSource: r.metadataSource, + }, + }, + create: { + ...r, + }, + }); + }); + + return results; + } + async createGame( result: InternalGameMetadataResult, libraryBasePath: string, @@ -181,9 +210,6 @@ export class MetadataHandler { mName: metadata.name, mShortDescription: metadata.shortDescription, mDescription: metadata.description, - - mReviewCount: metadata.reviewCount, - mReviewRating: metadata.reviewRating, mReleased: metadata.released, mIconObjectId: metadata.icon, @@ -198,6 +224,9 @@ export class MetadataHandler { connect: metadata.developers, }, + ratings: { + connectOrCreate: this.parseRatings(metadata.reviews), + }, tags: { connectOrCreate: this.parseTags(metadata.tags), }, diff --git a/server/internal/metadata/manual.ts b/server/internal/metadata/manual.ts index d7b00dc..551faf3 100644 --- a/server/internal/metadata/manual.ts +++ b/server/internal/metadata/manual.ts @@ -34,8 +34,7 @@ export class ManualMetadataProvider implements MetadataProvider { publishers: [], developers: [], tags: [], - reviewCount: 0, - reviewRating: 0, + reviews: [], icon: iconId, coverId: iconId, diff --git a/server/internal/metadata/pcgamingwiki.ts b/server/internal/metadata/pcgamingwiki.ts index 489e41a..251b0fe 100644 --- a/server/internal/metadata/pcgamingwiki.ts +++ b/server/internal/metadata/pcgamingwiki.ts @@ -7,12 +7,14 @@ import type { GameMetadata, _FetchCompanyMetadataParams, CompanyMetadata, + GameMetadataRating, } from "./types"; import type { AxiosRequestConfig } from "axios"; import axios from "axios"; import * as jdenticon from "jdenticon"; import { DateTime } from "luxon"; import * as cheerio from "cheerio"; +import { type } from "arktype"; interface PCGamingWikiParseRawPage { parse: { @@ -31,11 +33,6 @@ interface PCGamingWikiParseRawPage { }; } -interface PCGamingWikiParsedPage { - shortIntro: string; - introduction: string; -} - interface PCGamingWikiPage { PageID: string; PageName: string; @@ -89,6 +86,10 @@ type StringArrayKeys = { [K in keyof T]: T[K] extends string | string[] | null ? K : never; }[keyof T]; +const ratingProviderReview = type({ + rating: "string.integer.parse", +}); + // Api Docs: https://www.pcgamingwiki.com/wiki/PCGamingWiki:API // Good tool for helping build cargo queries: https://www.pcgamingwiki.com/wiki/Special:CargoQuery export class PCGamingWikiProvider implements MetadataProvider { @@ -115,7 +116,7 @@ export class PCGamingWikiProvider implements MetadataProvider { if (response.status !== 200) throw new Error( - `Error in pcgamingwiki \nStatus Code: ${response.status}`, + `Error in pcgamingwiki \nStatus Code: ${response.status}\n${response.data}`, ); return response; @@ -134,9 +135,13 @@ export class PCGamingWikiProvider implements MetadataProvider { return response; } - private async getPageContent( - pageID: string, - ): Promise { + /** + * Gets the raw wiki page for parsing, + * requested values are to be considered unstable as compared to cargo queries + * @param pageID + * @returns + */ + private async getPageContent(pageID: string) { const searchParams = new URLSearchParams({ action: "parse", format: "json", @@ -149,9 +154,116 @@ export class PCGamingWikiProvider implements MetadataProvider { // remove citations from intro introductionEle.find("sup").remove(); + const infoBoxEle = $(".template-infobox").first(); + const receptionEle = infoBoxEle + .find(".template-infobox-header") + .filter((_, el) => $(el).text().trim() === "Reception"); + + const receptionResults: (GameMetadataRating | undefined)[] = []; + if (receptionEle.length > 0) { + // we have a match! + + const ratingElements = infoBoxEle.find(".template-infobox-type"); + + // TODO: cleanup this ratnest + const parseIdFromHref = (href: string): string | undefined => { + const url = new URL(href); + const opencriticRegex = /^\/game\/(\d+)\/.+$/; + switch (url.hostname.toLocaleLowerCase()) { + case "www.metacritic.com": { + // https://www.metacritic.com/game/elden-ring/critic-reviews/?platform=pc + return url.pathname + .replace("/game/", "") + .replace("/critic-reviews", "") + .replace(/\/$/, ""); + } + case "opencritic.com": { + // https://opencritic.com/game/12090/elden-ring + let id = "unknown"; + let matches; + if ((matches = opencriticRegex.exec(url.pathname)) !== null) { + matches.forEach((match, _groupIndex) => { + // console.log(`Found match, group ${_groupIndex}: ${match}`); + id = match; + }); + } + + if (id === "unknown") { + return undefined; + } + return id; + } + case "www.igdb.com": { + // https://www.igdb.com/games/elden-ring + return url.pathname.replace("/games/", "").replace(/\/$/, ""); + } + default: { + console.warn("Pcgamingwiki, unknown host", url.hostname); + return undefined; + } + } + }; + const getRating = ( + source: MetadataSource, + ): GameMetadataRating | undefined => { + const providerEle = ratingElements.filter( + (_, el) => + $(el).text().trim().toLocaleLowerCase() === + source.toLocaleLowerCase(), + ); + if (providerEle.length > 0) { + // get info associated with provider + const reviewEle = providerEle + .first() + .parent() + .find(".template-infobox-info") + .find("a") + .first(); + + const href = reviewEle.attr("href"); + if (!href) { + console.log( + `pcgamingwiki: failed to properly get review href for ${source}`, + ); + return undefined; + } + const ratingObj = ratingProviderReview({ + rating: reviewEle.text().trim(), + }); + if (ratingObj instanceof type.errors) { + console.log( + "pcgamingwiki: failed to properly get review rating", + ratingObj.summary, + ); + return undefined; + } + + const id = parseIdFromHref(href); + if (!id) return undefined; + + return { + mReviewHref: href, + metadataId: id, + metadataSource: source, + mReviewCount: 0, + // make float within 0 to 1 + mReviewRating: ratingObj.rating / 100, + }; + } + + return undefined; + }; + receptionResults.push(getRating(MetadataSource.Metacritic)); + receptionResults.push(getRating(MetadataSource.IGDB)); + receptionResults.push(getRating(MetadataSource.OpenCritic)); + } + + console.log(res.data.parse.title, receptionResults); + return { - shortIntro: introductionEle.find("p").first().text(), - introduction: introductionEle.text(), + shortIntro: introductionEle.find("p").first().text().trim(), + introduction: introductionEle.text().trim(), + reception: receptionResults, }; } @@ -318,9 +430,7 @@ export class PCGamingWikiProvider implements MetadataProvider { tags: this.compileTags(game), - reviewCount: 0, - reviewRating: 0, - + reviews: pageContent.reception.filter((v) => typeof v !== "undefined"), publishers, developers, diff --git a/server/internal/metadata/types.d.ts b/server/internal/metadata/types.d.ts index b627041..b5286a7 100644 --- a/server/internal/metadata/types.d.ts +++ b/server/internal/metadata/types.d.ts @@ -1,4 +1,4 @@ -import type { Company } from "~/prisma/client"; +import type { Company, GameRating } from "~/prisma/client"; import type { TransactionDataType } from "../objects/transactional"; import type { ObjectReference } from "../objects/objectHandler"; @@ -18,6 +18,15 @@ export interface GameMetadataSource { export type InternalGameMetadataResult = GameMetadataSearchResult & GameMetadataSource; +export type GameMetadataRating = Pick< + GameRating, + | "metadataSource" + | "metadataId" + | "mReviewCount" + | "mReviewHref" + | "mReviewRating" +>; + export interface GameMetadata { id: string; name: string; @@ -32,8 +41,7 @@ export interface GameMetadata { tags: string[]; - reviewCount: number; - reviewRating: number; + reviews: GameMetadataRating[]; // Created with another utility function icon: ObjectReference; From 9bf36c8737e40a23e8e2d386937d0a471c7bd902 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Wed, 14 May 2025 21:52:32 -0400 Subject: [PATCH 026/156] feat: pcgamgingwiki desc in searchstub --- server/internal/metadata/pcgamingwiki.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/server/internal/metadata/pcgamingwiki.ts b/server/internal/metadata/pcgamingwiki.ts index 251b0fe..c58ae36 100644 --- a/server/internal/metadata/pcgamingwiki.ts +++ b/server/internal/metadata/pcgamingwiki.ts @@ -258,8 +258,6 @@ export class PCGamingWikiProvider implements MetadataProvider { receptionResults.push(getRating(MetadataSource.OpenCritic)); } - console.log(res.data.parse.title, receptionResults); - return { shortIntro: introductionEle.find("p").first().text().trim(), introduction: introductionEle.text().trim(), @@ -277,26 +275,28 @@ export class PCGamingWikiProvider implements MetadataProvider { format: "json", }); - const res = await this.cargoQuery(searchParams); + const response = + await this.cargoQuery(searchParams); - const mapped = res.data.cargoquery.map((result) => { + const results: GameMetadataSearchResult[] = []; + for (const result of response.data.cargoquery) { const game = result.title; + const pageContent = await this.getPageContent(game.PageID); - const metadata: GameMetadataSearchResult = { + results.push({ id: game.PageID, name: game.PageName, icon: game["Cover URL"] ?? "", - description: "", // TODO: need to render the `Introduction` template somehow (or we could just hardcode it) + description: pageContent.shortIntro, year: game.Released !== null && game.Released.length > 0 ? // sometimes will provide multiple dates this.parseTS(game.Released).year : 0, - }; - return metadata; - }); + }); + } - return mapped; + return results; } /** From a34f10d9b9033230bbfe5ab4526334367241d7e1 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Wed, 14 May 2025 22:03:32 -0400 Subject: [PATCH 027/156] feat: igdb tag support --- server/internal/metadata/igdb.ts | 45 +++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/server/internal/metadata/igdb.ts b/server/internal/metadata/igdb.ts index 57656d7..0fbbedc 100644 --- a/server/internal/metadata/igdb.ts +++ b/server/internal/metadata/igdb.ts @@ -32,6 +32,12 @@ interface IGDBItem { id: IGDBID; } +interface IGDBGenre extends IGDBItem { + name: string; + slug: string; + url: string; +} + // denotes role a company had in a game interface IGDBInvolvedCompany extends IGDBItem { company: IGDBID; @@ -250,8 +256,6 @@ export class IGDBProvider implements MetadataProvider { let result = ""; response.forEach((cover) => { - console.log(cover); - if (cover.url.startsWith("https:")) { result = cover.url; } else { @@ -279,6 +283,34 @@ export class IGDBProvider implements MetadataProvider { return msg.length > len ? msg.substring(0, 280) + "..." : msg; } + private async _getGenreInternal(genreID: IGDBID) { + if (genreID === undefined) throw new Error(`IGDB genreID was undefined`); + + const body = `where id = ${genreID}; fields slug,name,url;`; + const response = await this.request("genres", body); + + let result = ""; + + response.forEach((genre) => { + result = genre.name; + }); + + return result; + } + + private async getGenres(genres: IGDBID[] | undefined): Promise { + if (genres === undefined) return []; + + const results: string[] = []; + for (const genre of genres) { + results.push(await this._getGenreInternal(genre)); + } + + console.log(results); + + return results; + } + name() { return "IGDB"; } @@ -297,7 +329,7 @@ export class IGDBProvider implements MetadataProvider { if (cover !== undefined) { icon = await this.getCoverURL(cover); } else { - icon = "/wallpapers/error-wallpaper.jpg"; + icon = ""; } const firstReleaseDate = response[i].first_release_date; @@ -308,7 +340,7 @@ export class IGDBProvider implements MetadataProvider { description: response[i].summary, year: firstReleaseDate === undefined - ? DateTime.now().year + ? 0 : DateTime.fromSeconds(firstReleaseDate).year, }); } @@ -387,7 +419,7 @@ export class IGDBProvider implements MetadataProvider { description: response[i].summary, released: firstReleaseDate === undefined - ? DateTime.now().toJSDate() + ? new Date() : DateTime.fromSeconds(firstReleaseDate).toJSDate(), reviews: [ @@ -403,8 +435,7 @@ export class IGDBProvider implements MetadataProvider { publishers: [], developers: [], - // TODO: support tags - tags: [], + tags: await this.getGenres(response[i].genres), icon, bannerId: banner, From 82b123a345977d35d952abac5307c56745177962 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Wed, 14 May 2025 22:13:53 -0400 Subject: [PATCH 028/156] fix: gamerating model --- .../migration.sql | 41 +++++++++++++++++++ prisma/models/content.prisma | 4 +- 2 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20250515021331_add_game_ratings/migration.sql diff --git a/prisma/migrations/20250515021331_add_game_ratings/migration.sql b/prisma/migrations/20250515021331_add_game_ratings/migration.sql new file mode 100644 index 0000000..52a2f5f --- /dev/null +++ b/prisma/migrations/20250515021331_add_game_ratings/migration.sql @@ -0,0 +1,41 @@ +/* + Warnings: + + - You are about to drop the column `mReviewCount` on the `Game` table. All the data in the column will be lost. + - You are about to drop the column `mReviewRating` on the `Game` table. All the data in the column will be lost. + +*/ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "MetadataSource" ADD VALUE 'Metacritic'; +ALTER TYPE "MetadataSource" ADD VALUE 'OpenCritic'; + +-- AlterTable +ALTER TABLE "Game" DROP COLUMN "mReviewCount", +DROP COLUMN "mReviewRating"; + +-- CreateTable +CREATE TABLE "GameRating" ( + "id" TEXT NOT NULL, + "metadataSource" "MetadataSource" NOT NULL, + "metadataId" TEXT NOT NULL, + "created" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "mReviewCount" INTEGER NOT NULL, + "mReviewRating" DOUBLE PRECISION NOT NULL, + "mReviewHref" TEXT, + "gameId" TEXT NOT NULL, + + CONSTRAINT "GameRating_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "GameRating_metadataSource_metadataId_key" ON "GameRating"("metadataSource", "metadataId"); + +-- AddForeignKey +ALTER TABLE "GameRating" ADD CONSTRAINT "GameRating_gameId_fkey" FOREIGN KEY ("gameId") REFERENCES "Game"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/models/content.prisma b/prisma/models/content.prisma index 4631b06..3ac08b1 100644 --- a/prisma/models/content.prisma +++ b/prisma/models/content.prisma @@ -55,8 +55,8 @@ model GameRating { mReviewHref String? - Game Game? @relation(fields: [gameId], references: [id], onDelete: Cascade) - gameId String? + Game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) + gameId String @@unique([metadataSource, metadataId], name: "metadataKey") } From 9d2aded70f8102a81134f90f0a0dae15b42d86e9 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Wed, 14 May 2025 22:53:09 -0400 Subject: [PATCH 029/156] feat: add acl to notifications not sure if i got all the acls of the different notifications down rn, but it seems to be about right --- .../migration.sql | 2 ++ prisma/models/user.prisma | 3 ++ server/api/v1/client/capability/index.post.ts | 1 + server/api/v1/notifications/index.get.ts | 33 ++++++++++++++++++- server/internal/acls/index.ts | 2 ++ server/internal/library/index.ts | 1 + server/internal/notifications/index.ts | 4 ++- server/tasks/check/update.ts | 1 + 8 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20250515024929_add_required_perms_to_notifications/migration.sql diff --git a/prisma/migrations/20250515024929_add_required_perms_to_notifications/migration.sql b/prisma/migrations/20250515024929_add_required_perms_to_notifications/migration.sql new file mode 100644 index 0000000..0af0368 --- /dev/null +++ b/prisma/migrations/20250515024929_add_required_perms_to_notifications/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Notification" ADD COLUMN "requiredPerms" TEXT[] DEFAULT ARRAY[]::TEXT[]; diff --git a/prisma/models/user.prisma b/prisma/models/user.prisma index 0b4f272..bb08844 100644 --- a/prisma/models/user.prisma +++ b/prisma/models/user.prisma @@ -34,6 +34,9 @@ model Notification { description String actions String[] + // ACL items + requiredPerms String[] @default([]) + read Boolean @default(false) @@unique([userId, nonce]) diff --git a/server/api/v1/client/capability/index.post.ts b/server/api/v1/client/capability/index.post.ts index 0123860..37f5a30 100644 --- a/server/api/v1/client/capability/index.post.ts +++ b/server/api/v1/client/capability/index.post.ts @@ -55,6 +55,7 @@ export default defineClientEventHandler( title: `"${client.name}" can now access ${capability}`, description: `A device called "${client.name}" now has access to your ${capability}.`, actions: ["Review|/account/devices"], + requiredPerms: ["clients:read"], }); return {}; diff --git a/server/api/v1/notifications/index.get.ts b/server/api/v1/notifications/index.get.ts index 8797aa8..98f43c6 100644 --- a/server/api/v1/notifications/index.get.ts +++ b/server/api/v1/notifications/index.get.ts @@ -1,4 +1,4 @@ -import aclManager from "~/server/internal/acls"; +import aclManager, { type SystemACL } from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; export default defineEventHandler(async (h3) => { @@ -22,5 +22,36 @@ export default defineEventHandler(async (h3) => { }, }); + let i = notifications.length; + while (i--) { + const notif = notifications[i]; + + const hasPermsForNotif = await aclManager.allowSystemACL( + h3, + notif.requiredPerms as SystemACL, + ); + + if (!hasPermsForNotif) { + // remove element + console.log( + userId, + "did not have perms to access", + notif.id, + "based on", + notif.requiredPerms, + ); + + notifications.splice(i, 1); + } else { + console.log( + userId, + "had perms to access", + notif.id, + "based on", + notif.requiredPerms, + ); + } + } + return notifications; }); diff --git a/server/internal/acls/index.ts b/server/internal/acls/index.ts index 6f42ddc..8198b28 100644 --- a/server/internal/acls/index.ts +++ b/server/internal/acls/index.ts @@ -70,6 +70,8 @@ const systemACLPrefix = "system:"; export type SystemACL = Array<(typeof systemACLs)[number]>; +export type ValidACLItems = Array; + class ACLManager { private getAuthorizationToken(request: MinimumRequestObject) { const [type, token] = diff --git a/server/internal/library/index.ts b/server/internal/library/index.ts index 6556702..b24925c 100644 --- a/server/internal/library/index.ts +++ b/server/internal/library/index.ts @@ -306,6 +306,7 @@ class LibraryManager { title: `'${game.mName}' ('${versionName}') finished importing.`, description: `Drop finished importing version ${versionName} for ${game.mName}.`, actions: [`View|/admin/library/${gameId}`], + requiredPerms: ["import:game:new"], }); progress(100); diff --git a/server/internal/notifications/index.ts b/server/internal/notifications/index.ts index 67570dc..16b224b 100644 --- a/server/internal/notifications/index.ts +++ b/server/internal/notifications/index.ts @@ -9,10 +9,12 @@ Design goals: import type { Notification } from "~/prisma/client"; import prisma from "../db/database"; +// type Optional = Pick, K> & Omit; + // TODO: document notification action format export type NotificationCreateArgs = Pick< Notification, - "title" | "description" | "actions" | "nonce" + "title" | "description" | "actions" | "nonce" | "requiredPerms" >; class NotificationSystem { diff --git a/server/tasks/check/update.ts b/server/tasks/check/update.ts index 6fbaa80..c0e5ec9 100644 --- a/server/tasks/check/update.ts +++ b/server/tasks/check/update.ts @@ -100,6 +100,7 @@ export default defineTask({ title: `Update available to v${latestVer}`, description: `A new version of Drop is available v${latestVer}`, actions: [`View|${body.html_url}`], + requiredPerms: [""], }); } else { console.log("[Task check:update]: no update available"); From 1dba112bce14a8baa3e9a6617d08048f5684d5bb Mon Sep 17 00:00:00 2001 From: DecDuck Date: Thu, 15 May 2025 14:55:05 +1000 Subject: [PATCH 030/156] feat: separate library and metadata pages, notification acls --- pages/admin/library/[id]/index.vue | 185 ++++++++++++------ pages/admin/library/index.vue | 2 +- pages/admin/metadata/games/[id]/index.vue | 2 +- pages/admin/metadata/index.vue | 6 +- .../migration.sql | 2 + prisma/models/user.prisma | 3 +- server/api/v1/client/capability/index.post.ts | 1 + server/api/v1/notifications/index.get.ts | 18 +- server/api/v1/notifications/readall.post.ts | 18 +- server/api/v1/notifications/ws.get.ts | 19 +- server/internal/acls/index.ts | 32 +++ server/internal/library/index.ts | 1 + server/internal/notifications/index.ts | 27 ++- 13 files changed, 209 insertions(+), 107 deletions(-) create mode 100644 prisma/migrations/20250515043254_add_acls_to_notifications/migration.sql diff --git a/pages/admin/library/[id]/index.vue b/pages/admin/library/[id]/index.vue index ed19f0c..83cb810 100644 --- a/pages/admin/library/[id]/index.vue +++ b/pages/admin/library/[id]/index.vue @@ -1,82 +1,141 @@ + diff --git a/server/internal/oidc/index.ts b/server/internal/oidc/index.ts index 24c4e33..ec5f43e 100644 --- a/server/internal/oidc/index.ts +++ b/server/internal/oidc/index.ts @@ -1,5 +1,6 @@ import { randomUUID } from "crypto"; import prisma from "../db/database"; +import type { User } from "~/prisma/client"; import { AuthMec } from "~/prisma/client"; import objectHandler from "../objects"; import type { Readable } from "stream"; @@ -12,10 +13,15 @@ interface OIDCWellKnown { scopes_supported: string[]; } +interface OIDCAuthSessionOptions { + redirect: string | undefined; +} + interface OIDCAuthSession { redirectUrl: string; callbackUrl: string; state: string; + options: OIDCAuthSessionOptions; } interface OIDCUserInfo { @@ -132,7 +138,7 @@ export class OIDCManager { }; } - generateAuthSession(): OIDCAuthSession { + generateAuthSession(options?: OIDCAuthSessionOptions): OIDCAuthSession { const stateKey = randomUUID(); const normalisedUrl = new URL( @@ -148,12 +154,16 @@ export class OIDCManager { redirectUrl: finalUrl, callbackUrl: redirectUrl, state: stateKey, + options: options ?? { redirect: undefined }, }; this.signinStateTable[stateKey] = session; return session; } - async authorize(code: string, state: string) { + async authorize( + code: string, + state: string, + ): Promise<{ user: User; options: OIDCAuthSessionOptions } | string> { const session = this.signinStateTable[state]; if (!session) return "Invalid state parameter"; @@ -191,7 +201,9 @@ export class OIDCManager { const user = await this.fetchOrCreateUser(userinfo); - return user; + if (typeof user === "string") return user; + + return { user, options: session.options }; } catch (e) { console.error(e); return `Request to identity provider failed: ${e}`; diff --git a/server/routes/auth/callback/oidc.get.ts b/server/routes/auth/callback/oidc.get.ts index e2a6854..0bedf34 100644 --- a/server/routes/auth/callback/oidc.get.ts +++ b/server/routes/auth/callback/oidc.get.ts @@ -21,15 +21,19 @@ export default defineEventHandler(async (h3) => { statusMessage: "No state in query params.", }); - const user = await manager.authorize(code, state); + const result = await manager.authorize(code, state); - if (typeof user === "string") + if (typeof result === "string") throw createError({ statusCode: 403, - statusMessage: `Failed to sign in: "${user}". Please try again.`, + statusMessage: `Failed to sign in: "${result}". Please try again.`, }); - await sessionHandler.signin(h3, user.id, true); + await sessionHandler.signin(h3, result.user.id, true); + + if (result.options.redirect) { + return sendRedirect(h3, result.options.redirect); + } return sendRedirect(h3, "/"); }); diff --git a/server/routes/auth/oidc.get.ts b/server/routes/auth/oidc.get.ts index be3cf94..a2b89e9 100644 --- a/server/routes/auth/oidc.get.ts +++ b/server/routes/auth/oidc.get.ts @@ -1,10 +1,16 @@ import { enabledAuthManagers } from "~/server/plugins/04.auth-init"; export default defineEventHandler((h3) => { - if (!enabledAuthManagers.OpenID) return sendRedirect(h3, "/auth/signin"); + const redirect = getQuery(h3).redirect?.toString(); + + if (!enabledAuthManagers.OpenID) + return sendRedirect( + h3, + `/auth/signin${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""}`, + ); const manager = enabledAuthManagers.OpenID; - const { redirectUrl } = manager.generateAuthSession(); + const { redirectUrl } = manager.generateAuthSession({ redirect }); return sendRedirect(h3, redirectUrl); }); From bee3b0c5882a961a355df23aa9711d1389d264a9 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Thu, 15 May 2025 13:45:05 -0400 Subject: [PATCH 035/156] fix: drop update notifications --- server/tasks/check/update.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/tasks/check/update.ts b/server/tasks/check/update.ts index c0e5ec9..4823f74 100644 --- a/server/tasks/check/update.ts +++ b/server/tasks/check/update.ts @@ -95,12 +95,12 @@ export default defineTask({ // check if is newer version if (semver.gt(latestVer, currVer)) { console.log("[Task check:update]: Update available"); - notificationSystem.pushAllAdmins({ + notificationSystem.systemPush({ nonce: `drop-update-available-${currVer}-to-${latestVer}`, title: `Update available to v${latestVer}`, description: `A new version of Drop is available v${latestVer}`, actions: [`View|${body.html_url}`], - requiredPerms: [""], + acls: ["system:notifications:read"], }); } else { console.log("[Task check:update]: no update available"); From 59c3b9b76e4e2747a9a11fb0d26960c946d97b9c Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Thu, 15 May 2025 13:53:05 -0400 Subject: [PATCH 036/156] fix: don't send system notifications to all users --- server/internal/notifications/index.ts | 50 +++++++++++++++++++++----- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/server/internal/notifications/index.ts b/server/internal/notifications/index.ts index b4a60ba..5780b8f 100644 --- a/server/internal/notifications/index.ts +++ b/server/internal/notifications/index.ts @@ -94,14 +94,15 @@ class NotificationSystem { await this.pushNotification(userId, notification); } - async pushAll(notificationCreateArgs: NotificationCreateArgs) { - const users = await prisma.user.findMany({ - where: { id: { not: "system" } }, - select: { - id: true, - }, - }); - + /** + * Internal call to batch push notifications to many users + * @param notificationCreateArgs + * @param users + */ + private async _pushMany( + notificationCreateArgs: NotificationCreateArgs, + users: { id: string }[], + ) { const res: Promise[] = []; for (const user of users) { res.push(this.push(user.id, notificationCreateArgs)); @@ -110,8 +111,39 @@ class NotificationSystem { 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" } }, + select: { + id: true, + }, + }); + + await this._pushMany(notificationCreateArgs, users); + } + + /** + * Send a notification to all system level users + * @param notificationCreateArgs + * @returns + */ async systemPush(notificationCreateArgs: NotificationCreateArgs) { - return await this.pushAll(notificationCreateArgs); + const users = await prisma.user.findMany({ + where: { + id: { not: "system" }, + // no reason to send to any users other then admins rn + admin: true, + }, + select: { + id: true, + }, + }); + + await this._pushMany(notificationCreateArgs, users); } } From 831b20d7372786e859fef55f9027916d9b936f30 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Thu, 15 May 2025 14:21:12 -0400 Subject: [PATCH 037/156] fix: remove old requiredPerms field --- .../migration.sql | 2 -- prisma/models/user.prisma | 3 -- server/api/v1/notifications/index.get.ts | 33 +------------------ 3 files changed, 1 insertion(+), 37 deletions(-) delete mode 100644 prisma/migrations/20250515024929_add_required_perms_to_notifications/migration.sql diff --git a/prisma/migrations/20250515024929_add_required_perms_to_notifications/migration.sql b/prisma/migrations/20250515024929_add_required_perms_to_notifications/migration.sql deleted file mode 100644 index 0af0368..0000000 --- a/prisma/migrations/20250515024929_add_required_perms_to_notifications/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "Notification" ADD COLUMN "requiredPerms" TEXT[] DEFAULT ARRAY[]::TEXT[]; diff --git a/prisma/models/user.prisma b/prisma/models/user.prisma index 3445084..d880109 100644 --- a/prisma/models/user.prisma +++ b/prisma/models/user.prisma @@ -35,9 +35,6 @@ model Notification { description String actions String[] - // ACL items - requiredPerms String[] @default([]) - read Boolean @default(false) @@unique([userId, nonce]) diff --git a/server/api/v1/notifications/index.get.ts b/server/api/v1/notifications/index.get.ts index ea81524..982d520 100644 --- a/server/api/v1/notifications/index.get.ts +++ b/server/api/v1/notifications/index.get.ts @@ -1,4 +1,4 @@ -import aclManager, { type SystemACL } from "~/server/internal/acls"; +import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; export default defineEventHandler(async (h3) => { @@ -24,36 +24,5 @@ export default defineEventHandler(async (h3) => { }, }); - let i = notifications.length; - while (i--) { - const notif = notifications[i]; - - const hasPermsForNotif = await aclManager.allowSystemACL( - h3, - notif.requiredPerms as SystemACL, - ); - - if (!hasPermsForNotif) { - // remove element - console.log( - userId, - "did not have perms to access", - notif.id, - "based on", - notif.requiredPerms, - ); - - notifications.splice(i, 1); - } else { - console.log( - userId, - "had perms to access", - notif.id, - "based on", - notif.requiredPerms, - ); - } - } - return notifications; }); From a89c657fe1c1ccfb0f48467efb50168c2ebe6b50 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Thu, 15 May 2025 15:51:35 -0400 Subject: [PATCH 038/156] feat: very basic screenshot api --- prisma/models/content.prisma | 3 ++- .../api/v1/screenshots/[id]/index.delete.ts | 17 ++++++++++++ server/api/v1/screenshots/[id]/index.get.ts | 17 ++++++++++++ .../api/v1/screenshots/game/[id]/index.get.ts | 18 +++++++++++++ .../v1/screenshots/game/[id]/index.post.ts | 27 +++++++++++++++++++ server/api/v1/screenshots/index.get.ts | 11 ++++++++ server/internal/acls/descriptions.ts | 4 +++ server/internal/acls/index.ts | 10 +++++++ server/internal/screenshots/index.ts | 24 ++++++++++++++--- 9 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 server/api/v1/screenshots/[id]/index.delete.ts create mode 100644 server/api/v1/screenshots/[id]/index.get.ts create mode 100644 server/api/v1/screenshots/game/[id]/index.get.ts create mode 100644 server/api/v1/screenshots/game/[id]/index.post.ts create mode 100644 server/api/v1/screenshots/index.get.ts diff --git a/prisma/models/content.prisma b/prisma/models/content.prisma index 3ac08b1..4781ee3 100644 --- a/prisma/models/content.prisma +++ b/prisma/models/content.prisma @@ -116,11 +116,12 @@ model Screenshot { user User @relation(fields: [userId], references: [id], onDelete: Cascade) objectId String - private Boolean @default(true) + private Boolean // if other users can see createdAt DateTime @default(now()) @db.Timestamptz(0) @@index([gameId, userId]) + @@index([userId]) } model Company { diff --git a/server/api/v1/screenshots/[id]/index.delete.ts b/server/api/v1/screenshots/[id]/index.delete.ts new file mode 100644 index 0000000..360dbf4 --- /dev/null +++ b/server/api/v1/screenshots/[id]/index.delete.ts @@ -0,0 +1,17 @@ +// get a specific screenshot +import aclManager from "~/server/internal/acls"; +import screenshotManager from "~/server/internal/screenshots"; + +export default defineEventHandler(async (h3) => { + const userId = await aclManager.getUserIdACL(h3, ["screenshots:delete"]); + if (!userId) throw createError({ statusCode: 403 }); + + const screenshotId = getRouterParam(h3, "id"); + if (!screenshotId) + throw createError({ + statusCode: 400, + statusMessage: "Missing screenshot ID", + }); + + return await screenshotManager.delete(screenshotId); +}); diff --git a/server/api/v1/screenshots/[id]/index.get.ts b/server/api/v1/screenshots/[id]/index.get.ts new file mode 100644 index 0000000..da84f6a --- /dev/null +++ b/server/api/v1/screenshots/[id]/index.get.ts @@ -0,0 +1,17 @@ +// get a specific screenshot +import aclManager from "~/server/internal/acls"; +import screenshotManager from "~/server/internal/screenshots"; + +export default defineEventHandler(async (h3) => { + const userId = await aclManager.getUserIdACL(h3, ["screenshots:read"]); + if (!userId) throw createError({ statusCode: 403 }); + + const screenshotId = getRouterParam(h3, "id"); + if (!screenshotId) + throw createError({ + statusCode: 400, + statusMessage: "Missing screenshot ID", + }); + + return await screenshotManager.get(screenshotId); +}); diff --git a/server/api/v1/screenshots/game/[id]/index.get.ts b/server/api/v1/screenshots/game/[id]/index.get.ts new file mode 100644 index 0000000..71addae --- /dev/null +++ b/server/api/v1/screenshots/game/[id]/index.get.ts @@ -0,0 +1,18 @@ +// get all user screenshots by game +import aclManager from "~/server/internal/acls"; +import screenshotManager from "~/server/internal/screenshots"; + +export default defineEventHandler(async (h3) => { + const userId = await aclManager.getUserIdACL(h3, ["screenshots:read"]); + if (!userId) throw createError({ statusCode: 403 }); + + const gameId = getRouterParam(h3, "id"); + if (!gameId) + throw createError({ + statusCode: 400, + statusMessage: "Missing game ID", + }); + + const results = await screenshotManager.getUserAllByGame(userId, gameId); + return results; +}); diff --git a/server/api/v1/screenshots/game/[id]/index.post.ts b/server/api/v1/screenshots/game/[id]/index.post.ts new file mode 100644 index 0000000..c06b815 --- /dev/null +++ b/server/api/v1/screenshots/game/[id]/index.post.ts @@ -0,0 +1,27 @@ +// create new screenshot +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; +import screenshotManager from "~/server/internal/screenshots"; + +// TODO: make defineClientEventHandler instead? +// only clients will be upload screenshots yea?? +export default defineEventHandler(async (h3) => { + const userId = await aclManager.getUserIdACL(h3, ["screenshots:new"]); + if (!userId) throw createError({ statusCode: 403 }); + + const gameId = getRouterParam(h3, "id"); + if (!gameId) + throw createError({ + statusCode: 400, + statusMessage: "Missing game ID", + }); + + const game = await prisma.game.findUnique({ + where: { id: gameId }, + select: { id: true }, + }); + if (!game) + throw createError({ statusCode: 400, statusMessage: "Invalid game ID" }); + + await screenshotManager.upload(userId, gameId, h3.node.req); +}); diff --git a/server/api/v1/screenshots/index.get.ts b/server/api/v1/screenshots/index.get.ts new file mode 100644 index 0000000..97b9f93 --- /dev/null +++ b/server/api/v1/screenshots/index.get.ts @@ -0,0 +1,11 @@ +// get all user screenshots +import aclManager from "~/server/internal/acls"; +import screenshotManager from "~/server/internal/screenshots"; + +export default defineEventHandler(async (h3) => { + const userId = await aclManager.getUserIdACL(h3, ["screenshots:read"]); + if (!userId) throw createError({ statusCode: 403 }); + + const results = await screenshotManager.getUserAll(userId); + return results; +}); diff --git a/server/internal/acls/descriptions.ts b/server/internal/acls/descriptions.ts index d5ac10a..d9f21a6 100644 --- a/server/internal/acls/descriptions.ts +++ b/server/internal/acls/descriptions.ts @@ -22,6 +22,10 @@ export const userACLDescriptions: ObjectFromList = { "notifications:listen": "Connect to a websocket to recieve notifications.", "notifications:delete": "Delete this account's notifications.", + "screenshots:new": "Create screenshots for this account", + "screenshots:read": "Read all screenshots for this account", + "screenshots:delete": "Delete a screenshot for this account", + "collections:new": "Create collections for this account.", "collections:read": "Fetch all collections (including library).", "collections:delete": "Delete a collection for this account.", diff --git a/server/internal/acls/index.ts b/server/internal/acls/index.ts index c2570c3..96c3708 100644 --- a/server/internal/acls/index.ts +++ b/server/internal/acls/index.ts @@ -17,6 +17,10 @@ export const userACLs = [ "notifications:listen", "notifications:delete", + "screenshots:new", + "screenshots:read", + "screenshots:delete", + "collections:new", "collections:read", "collections:delete", @@ -83,6 +87,12 @@ class ACLManager { return token; } + /** + * Get userId and require one of the specified acls + * @param request + * @param acls + * @returns + */ async getUserIdACL(request: MinimumRequestObject | undefined, acls: UserACL) { if (!request) throw new Error("Native web requests not available - weird deployment?"); diff --git a/server/internal/screenshots/index.ts b/server/internal/screenshots/index.ts index e8b4911..295e26d 100644 --- a/server/internal/screenshots/index.ts +++ b/server/internal/screenshots/index.ts @@ -13,7 +13,16 @@ class ScreenshotManager { }); } - async getAllByGame(gameId: string, userId: string) { + async getUserAll(userId: string) { + const results = await prisma.screenshot.findMany({ + where: { + userId, + }, + }); + return results; + } + + async getUserAllByGame(userId: string, gameId: string) { const results = await prisma.screenshot.findMany({ where: { gameId, @@ -31,9 +40,16 @@ class ScreenshotManager { }); } - async upload(gameId: string, userId: string, inputStream: IncomingMessage) { + async upload(userId: string, gameId: string, inputStream: IncomingMessage) { const objectId = randomUUID(); - const saveStream = await objectHandler.createWithStream(objectId, {}, []); + const saveStream = await objectHandler.createWithStream( + objectId, + { + // TODO: set createAt to the time screenshot was taken + createdAt: new Date().toISOString(), + }, + [`${userId}:read`, `${userId}:delete`], + ); if (!saveStream) throw createError({ statusCode: 500, @@ -43,12 +59,12 @@ class ScreenshotManager { // pipe into object store await stream.pipeline(inputStream, saveStream); - // TODO: set createAt to the time screenshot was taken await prisma.screenshot.create({ data: { gameId, userId, objectId, + private: true, }, }); } From 4fbc7304902932341c21f0f449e250d6e6956ebb Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Thu, 15 May 2025 17:29:43 -0400 Subject: [PATCH 039/156] chore: style --- server/internal/clients/handler.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/internal/clients/handler.ts b/server/internal/clients/handler.ts index ca63f7b..8978f24 100644 --- a/server/internal/clients/handler.ts +++ b/server/internal/clients/handler.ts @@ -2,7 +2,10 @@ import { randomUUID } from "node:crypto"; import prisma from "../db/database"; import type { Platform } from "~/prisma/client"; import { useCertificateAuthority } from "~/server/plugins/ca"; -import type { CapabilityConfiguration, InternalClientCapability } from "./capabilities"; +import type { + CapabilityConfiguration, + InternalClientCapability, +} from "./capabilities"; import capabilityManager from "./capabilities"; export interface ClientMetadata { From 21eec081ee148a5225489b72c6d69cb67b68aaa5 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Thu, 15 May 2025 18:28:08 -0400 Subject: [PATCH 040/156] fix: missing user check in screenshot api endpoint --- server/api/v1/screenshots/[id]/index.delete.ts | 13 ++++++++++++- server/api/v1/screenshots/[id]/index.get.ts | 12 +++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/server/api/v1/screenshots/[id]/index.delete.ts b/server/api/v1/screenshots/[id]/index.delete.ts index 360dbf4..c09c332 100644 --- a/server/api/v1/screenshots/[id]/index.delete.ts +++ b/server/api/v1/screenshots/[id]/index.delete.ts @@ -13,5 +13,16 @@ export default defineEventHandler(async (h3) => { statusMessage: "Missing screenshot ID", }); - return await screenshotManager.delete(screenshotId); + const result = await screenshotManager.get(screenshotId); + if (!result) + throw createError({ + statusCode: 400, + statusMessage: "Incorrect screenshot ID", + }); + else if (result.userId !== userId) + throw createError({ + statusCode: 403, + }); + + await screenshotManager.delete(screenshotId); }); diff --git a/server/api/v1/screenshots/[id]/index.get.ts b/server/api/v1/screenshots/[id]/index.get.ts index da84f6a..7864a1b 100644 --- a/server/api/v1/screenshots/[id]/index.get.ts +++ b/server/api/v1/screenshots/[id]/index.get.ts @@ -13,5 +13,15 @@ export default defineEventHandler(async (h3) => { statusMessage: "Missing screenshot ID", }); - return await screenshotManager.get(screenshotId); + const result = await screenshotManager.get(screenshotId); + if (!result) + throw createError({ + statusCode: 400, + statusMessage: "Incorrect screenshot ID", + }); + else if (result.userId !== userId) + throw createError({ + statusCode: 403, + }); + return result; }); From 0719ffe0fa95fe11139221eb68a645f8602aad12 Mon Sep 17 00:00:00 2001 From: Francois Ribemont Date: Sat, 24 May 2025 21:11:51 +0100 Subject: [PATCH 041/156] Fixes wrong image being used in example --- deploy-template/compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy-template/compose.yml b/deploy-template/compose.yml index d3011a6..8d34e30 100644 --- a/deploy-template/compose.yml +++ b/deploy-template/compose.yml @@ -16,7 +16,7 @@ services: - POSTGRES_USER=drop - POSTGRES_DB=drop drop: - image: registry.deepcore.dev/drop-oss/drop/main:latest + image: decduck/drop-oss:v0.2.0-beta depends_on: postgres: condition: service_healthy From 4b009f1acae04b2b8eedea70c204cf249fa71c57 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Tue, 27 May 2025 12:30:20 -0400 Subject: [PATCH 042/156] feat: basic playtime backend --- prisma/models/client.prisma | 9 +++-- prisma/models/content.prisma | 16 ++++++++ prisma/models/user.prisma | 1 + server/internal/clients/capabilities.ts | 24 ++++++++++++ server/internal/playtime/index.ts | 50 +++++++++++++++++++++++++ 5 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 server/internal/playtime/index.ts diff --git a/prisma/models/client.prisma b/prisma/models/client.prisma index e01787a..414fcbb 100644 --- a/prisma/models/client.prisma +++ b/prisma/models/client.prisma @@ -1,7 +1,8 @@ enum ClientCapabilities { - PeerAPI @map("peerAPI") // other clients can use the HTTP API to P2P with this client - UserStatus @map("userStatus") // this client can report this user's status (playing, online, etc etc) - CloudSaves @map("cloudSaves") // ability to save to save slots + PeerAPI @map("peerAPI") // other clients can use the HTTP API to P2P with this client + UserStatus @map("userStatus") // this client can report this user's status (playing, online, etc etc) + CloudSaves @map("cloudSaves") // ability to save to save slots + TrackPlaytime @map("trackPlaytime") // ability to track user playtime } // References a device @@ -18,4 +19,4 @@ model Client { lastAccessedSaves SaveSlot[] tokens APIToken[] -} \ No newline at end of file +} diff --git a/prisma/models/content.prisma b/prisma/models/content.prisma index 4781ee3..ab9fff3 100644 --- a/prisma/models/content.prisma +++ b/prisma/models/content.prisma @@ -36,6 +36,7 @@ model Game { saves SaveSlot[] screenshots Screenshot[] tags Tag[] + playtime Playtime[] developers Company[] @relation(name: "developers") publishers Company[] @relation(name: "publishers") @@ -124,6 +125,21 @@ model Screenshot { @@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 { id String @id @default(uuid()) diff --git a/prisma/models/user.prisma b/prisma/models/user.prisma index d880109..30cecb8 100644 --- a/prisma/models/user.prisma +++ b/prisma/models/user.prisma @@ -19,6 +19,7 @@ model User { saves SaveSlot[] screenshots Screenshot[] + playtime Playtime[] } model Notification { diff --git a/server/internal/clients/capabilities.ts b/server/internal/clients/capabilities.ts index 5e30e00..639e835 100644 --- a/server/internal/clients/capabilities.ts +++ b/server/internal/clients/capabilities.ts @@ -10,6 +10,7 @@ export enum InternalClientCapability { PeerAPI = "peerAPI", UserStatus = "userStatus", CloudSaves = "cloudSaves", + TrackPlaytime = "trackPlaytime", } export const validCapabilities = Object.values(InternalClientCapability); @@ -79,6 +80,7 @@ class CapabilityManager { [InternalClientCapability.PeerAPI]: async () => true, [InternalClientCapability.UserStatus]: async () => true, // No requirements for user status [InternalClientCapability.CloudSaves]: async () => true, // No requirements for cloud saves + [InternalClientCapability.TrackPlaytime]: async () => true, }; async validateCapabilityConfiguration( @@ -160,6 +162,28 @@ class CapabilityManager { }, }); }, + [InternalClientCapability.TrackPlaytime]: async function () { + const currentClient = await prisma.client.findUnique({ + where: { id: clientId }, + select: { + capabilities: true, + }, + }); + if (!currentClient) throw new Error("Invalid client ID"); + if ( + currentClient.capabilities.includes(ClientCapabilities.TrackPlaytime) + ) + return; + + await prisma.client.update({ + where: { id: clientId }, + data: { + capabilities: { + push: ClientCapabilities.TrackPlaytime, + }, + }, + }); + }, }; await upsertFunctions[capability](); } diff --git a/server/internal/playtime/index.ts b/server/internal/playtime/index.ts new file mode 100644 index 0000000..3ae1497 --- /dev/null +++ b/server/internal/playtime/index.ts @@ -0,0 +1,50 @@ +import prisma from "../db/database"; + +class PlaytimeManager { + /** + * Get a user's playtime on a game + * @param gameId + * @param userId + * @returns + */ + async get(gameId: string, userId: string) { + return await prisma.playtime.findUnique({ + where: { + gameId_userId: { + gameId, + userId, + }, + }, + }); + } + + /** + * Add time to a user's playtime + * @param gameId + * @param userId + * @param seconds seconds played + */ + async add(gameId: string, userId: string, seconds: number) { + await prisma.playtime.upsert({ + where: { + gameId_userId: { + gameId, + userId, + }, + }, + create: { + gameId, + userId, + seconds, + }, + update: { + seconds: { + increment: seconds, + }, + }, + }); + } +} + +export const playtimeManager = new PlaytimeManager(); +export default playtimeManager; From 0816d2ab3e764c12090481faf0909b490dbeaa8e Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Tue, 27 May 2025 15:14:50 -0400 Subject: [PATCH 043/156] fix: info leak in screenshots api --- .../api/v1/screenshots/[id]/index.delete.ts | 5 ++-- server/api/v1/screenshots/[id]/index.get.ts | 5 ++-- server/internal/screenshots/index.ts | 26 +++++++++++++++++++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/server/api/v1/screenshots/[id]/index.delete.ts b/server/api/v1/screenshots/[id]/index.delete.ts index c09c332..b4c5e7c 100644 --- a/server/api/v1/screenshots/[id]/index.delete.ts +++ b/server/api/v1/screenshots/[id]/index.delete.ts @@ -16,12 +16,11 @@ export default defineEventHandler(async (h3) => { const result = await screenshotManager.get(screenshotId); if (!result) throw createError({ - statusCode: 400, - statusMessage: "Incorrect screenshot ID", + statusCode: 404, }); else if (result.userId !== userId) throw createError({ - statusCode: 403, + statusCode: 404, }); await screenshotManager.delete(screenshotId); diff --git a/server/api/v1/screenshots/[id]/index.get.ts b/server/api/v1/screenshots/[id]/index.get.ts index 7864a1b..5944103 100644 --- a/server/api/v1/screenshots/[id]/index.get.ts +++ b/server/api/v1/screenshots/[id]/index.get.ts @@ -16,12 +16,11 @@ export default defineEventHandler(async (h3) => { const result = await screenshotManager.get(screenshotId); if (!result) throw createError({ - statusCode: 400, - statusMessage: "Incorrect screenshot ID", + statusCode: 404, }); else if (result.userId !== userId) throw createError({ - statusCode: 403, + statusCode: 404, }); return result; }); diff --git a/server/internal/screenshots/index.ts b/server/internal/screenshots/index.ts index 295e26d..4179ebc 100644 --- a/server/internal/screenshots/index.ts +++ b/server/internal/screenshots/index.ts @@ -5,6 +5,11 @@ import stream from "node:stream/promises"; import prisma from "../db/database"; class ScreenshotManager { + /** + * Gets a specific screenshot + * @param id + * @returns + */ async get(id: string) { return await prisma.screenshot.findUnique({ where: { @@ -13,6 +18,11 @@ class ScreenshotManager { }); } + /** + * Get all user screenshots + * @param userId + * @returns + */ async getUserAll(userId: string) { const results = await prisma.screenshot.findMany({ where: { @@ -22,6 +32,12 @@ class ScreenshotManager { 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: { @@ -32,6 +48,10 @@ class ScreenshotManager { return results; } + /** + * Delete a specific screenshot + * @param id + */ async delete(id: string) { await prisma.screenshot.delete({ where: { @@ -40,6 +60,12 @@ class ScreenshotManager { }); } + /** + * 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( From 093bb60eb254d7c63b7006590576a57ea0634089 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Wed, 28 May 2025 15:07:11 +1000 Subject: [PATCH 044/156] chore: add DROP_VERISON constant --- server/internal/consts.ts | 1 + server/internal/metadata/index.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 server/internal/consts.ts diff --git a/server/internal/consts.ts b/server/internal/consts.ts new file mode 100644 index 0000000..d44c4b7 --- /dev/null +++ b/server/internal/consts.ts @@ -0,0 +1 @@ +export const DROP_VERSION = "0.3.0"; \ No newline at end of file diff --git a/server/internal/metadata/index.ts b/server/internal/metadata/index.ts index 9c1f03f..76f7074 100644 --- a/server/internal/metadata/index.ts +++ b/server/internal/metadata/index.ts @@ -10,6 +10,7 @@ import type { } from "./types"; import { ObjectTransactionalHandler } from "../objects/transactional"; import { PriorityListIndexed } from "../utils/prioritylist"; +import { DROP_VERSION } from "../consts"; export class MissingMetadataProviderConfig extends Error { private providerName: string; @@ -25,7 +26,7 @@ export class MissingMetadataProviderConfig extends Error { } // TODO: add useragent to all outbound api calls (best practice) -export const DropUserAgent = "Drop/0.2"; +export const DropUserAgent = `Drop/${DROP_VERSION}`; export abstract class MetadataProvider { abstract name(): string; From be793ce0f78ffd19467c26152029e183382b43eb Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Wed, 28 May 2025 12:15:23 -0400 Subject: [PATCH 045/156] fix: use ghcr instead of docker registry --- deploy-template/compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy-template/compose.yml b/deploy-template/compose.yml index 296468a..f96edcd 100644 --- a/deploy-template/compose.yml +++ b/deploy-template/compose.yml @@ -17,7 +17,7 @@ services: - POSTGRES_USER=drop - POSTGRES_DB=drop drop: - image: decduck/drop-oss:v0.2.0-beta + image: ghcr.io/drop-oss/drop:latest depends_on: postgres: condition: service_healthy From ea90a7f0868e0c530f60a76d984f24e2c409fe3c Mon Sep 17 00:00:00 2001 From: DecDuck Date: Thu, 29 May 2025 17:28:49 +1000 Subject: [PATCH 046/156] fix: blade's metadata issue --- server/internal/metadata/index.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/server/internal/metadata/index.ts b/server/internal/metadata/index.ts index 9c1f03f..8523e6f 100644 --- a/server/internal/metadata/index.ts +++ b/server/internal/metadata/index.ts @@ -215,11 +215,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, @@ -231,8 +234,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; } From c5f8b44537191e28e71a7130d2b2f8e8df8e84de Mon Sep 17 00:00:00 2001 From: DecDuck Date: Thu, 29 May 2025 17:36:52 +1000 Subject: [PATCH 047/156] fix: metadata update errors --- pages/admin/metadata/games/[id]/index.vue | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/pages/admin/metadata/games/[id]/index.vue b/pages/admin/metadata/games/[id]/index.vue index df83e5e..21db8d8 100644 --- a/pages/admin/metadata/games/[id]/index.vue +++ b/pages/admin/metadata/games/[id]/index.vue @@ -569,6 +569,8 @@ const descriptionSaving = ref(0); let savingTimeout: undefined | NodeJS.Timeout; +type PatchGameBody = Partial; + watch(descriptionHTML, (_v) => { console.log(game.value.mDescription); descriptionSaving.value = 1; @@ -581,7 +583,7 @@ watch(descriptionHTML, (_v) => { body: { id: gameId, mDescription: game.value.mDescription, - }, + } satisfies PatchGameBody, }); descriptionSaving.value = 0; } catch (e) { @@ -625,8 +627,8 @@ async function updateBannerImage(id: string) { method: "PATCH", body: { id: gameId, - mBannerId: id, - }, + mBannerObjectId: id, + } satisfies PatchGameBody, }); game.value.mBannerObjectId = mBannerObjectId; } catch (e) { @@ -652,8 +654,8 @@ async function updateCoverImage(id: string) { method: "PATCH", body: { id: gameId, - mCoverId: id, - }, + mCoverObjectId: id, + } satisfies PatchGameBody, }); game.value.mCoverObjectId = mCoverObjectId; } catch (e) { @@ -727,8 +729,8 @@ async function updateImageCarousel() { method: "PATCH", body: { id: gameId, - mImageCarousel: game.value.mImageCarouselObjectIds, - }, + mImageCarouselObjectIds: game.value.mImageCarouselObjectIds, + } satisfies PatchGameBody, }); } catch (e) { createModal( From 15806a3c9fd2339532cd07add9180f28acf59689 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Thu, 29 May 2025 13:27:38 -0400 Subject: [PATCH 048/156] feat: allow clients to fetch drop version --- nuxt.config.ts | 25 ++++++++++++++++++++----- server/api/v1/index.get.ts | 4 ++++ server/internal/config/sys-conf.ts | 16 +++++++++++++++- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/nuxt.config.ts b/nuxt.config.ts index 96b9415..bd2ffe2 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,6 +1,20 @@ import tailwindcss from "@tailwindcss/vite"; +import { execSync } from "node:child_process"; -const dropVersion = "v0.3.0"; +// 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({ @@ -42,16 +56,17 @@ export default defineNuxtConfig({ plugins: [tailwindcss()], }, + runtimeConfig: { + gitRef: commitHash, + dropVersion: dropVersion, + }, + app: { head: { link: [{ rel: "icon", href: "/favicon.ico" }], }, }, - appConfig: { - dropVersion: dropVersion, - }, - routeRules: { "/api/**": { cors: true }, }, diff --git a/server/api/v1/index.get.ts b/server/api/v1/index.get.ts index 37b2372..322ba50 100644 --- a/server/api/v1/index.get.ts +++ b/server/api/v1/index.get.ts @@ -1,5 +1,9 @@ +import { systemConfig } from "~/server/internal/config/sys-conf"; + export default defineEventHandler((_h3) => { return { appName: "Drop", + version: systemConfig.getDropVersion(), + ref: systemConfig.getGitRef(), }; }); diff --git a/server/internal/config/sys-conf.ts b/server/internal/config/sys-conf.ts index 560a661..acf5865 100644 --- a/server/internal/config/sys-conf.ts +++ b/server/internal/config/sys-conf.ts @@ -1,13 +1,23 @@ class SystemConfig { private libraryFolder = process.env.LIBRARY ?? "./.data/library"; private dataFolder = process.env.DATA ?? "./.data/data"; - private dropVersion = "v0.3.0"; + + 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; } @@ -20,6 +30,10 @@ class SystemConfig { return this.dropVersion; } + getGitRef() { + return this.gitRef; + } + shouldCheckForUpdates() { return this.checkForUpdates; } From 233324d6fb5516315012eecb0592adee8e1198e9 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Thu, 29 May 2025 13:52:32 -0400 Subject: [PATCH 049/156] feat: supply drop version during release build --- .github/workflows/release.yml | 29 ++++++++++++++++++++++++++++- Dockerfile | 7 +++++++ server/tasks/check/update.ts | 1 + 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2422087..b88ee35 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,8 +20,33 @@ jobs: uses: actions/checkout@v4 with: submodules: true + fetch-tags: true token: ${{ secrets.GITHUB_TOKEN }} + - name: Get base tag + id: get_base_tag + run: | + BASE_TAG=$(git describe --tags --abbrev=0) + echo "base_tag=$BASE_TAG" >> $GITHUB_OUTPUT + + - name: Determine final tag + id: get_final_tag + run: | + BASE_TAG=${{ steps.get_base_tag.outputs.base_tag }} + TODAY=$(date +'%Y.%m.%d') + + echo "Today will be: $TODAY" + echo "today=$TODAY" >> $GITHUB_OUTPUT + + if [[ "${{ github.event_name }}" == "release" ]]; then + FINAL_TAG="$BASE_TAG" + else + FINAL_TAG="${BASE_TAG}-nightly.$TODAY" + fi + + echo "Drop's release tag will be: $FINAL_TAG" + echo "final_tag=$FINAL_TAG" >> $GITHUB_OUTPUT + - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -45,7 +70,7 @@ jobs: images: | 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}} @@ -66,3 +91,5 @@ jobs: platforms: linux/amd64,linux/arm64 cache-from: type=gha cache-to: type=gha,mode=max + build-args: | + BUILD_DROP_VERSION=${{ steps.get_final_tag.outputs.final_tag }} diff --git a/Dockerfile b/Dockerfile index 73ba58f..ac6caa5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,5 @@ +# syntax=docker/dockerfile:1 + # Unified deps builder FROM node:lts-alpine AS deps WORKDIR /app @@ -12,10 +14,15 @@ WORKDIR /app ENV NODE_ENV=production ENV NUXT_TELEMETRY_DISABLED=1 +# add git so drop can determine its git ref at build +RUN apk add git + # copy deps and rest of project files COPY --from=deps /app/node_modules ./node_modules COPY . . +ARG BUILD_DROP_VERSION="v0.0.0-unknown.1" + # build RUN yarn postinstall RUN yarn build diff --git a/server/tasks/check/update.ts b/server/tasks/check/update.ts index 4823f74..5a35e51 100644 --- a/server/tasks/check/update.ts +++ b/server/tasks/check/update.ts @@ -92,6 +92,7 @@ export default defineTask({ }; } + // 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"); From d99c64825951d08c4b32edf25ee64d1840738d79 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Thu, 29 May 2025 13:54:14 -0400 Subject: [PATCH 050/156] fix: accidental removal of nightly tag --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b88ee35..3454c4a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,6 +70,7 @@ jobs: images: | 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}} From f08e1b40c39f3ba1f37b705beb81df5cb67c5f45 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Thu, 29 May 2025 14:18:19 -0400 Subject: [PATCH 051/156] fix: git install in docker --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index ac6caa5..1e000a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ ENV NODE_ENV=production ENV NUXT_TELEMETRY_DISABLED=1 # add git so drop can determine its git ref at build -RUN apk add git +RUN apk add --no-cache git # copy deps and rest of project files COPY --from=deps /app/node_modules ./node_modules From 04d5ad0519e913137abdddcca693a545ecb238e7 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Thu, 29 May 2025 14:18:57 -0400 Subject: [PATCH 052/156] fix: compose not following node best practices --- deploy-template/compose.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/deploy-template/compose.yml b/deploy-template/compose.yml index f96edcd..f8ea344 100644 --- a/deploy-template/compose.yml +++ b/deploy-template/compose.yml @@ -18,6 +18,9 @@ services: - POSTGRES_DB=drop drop: image: ghcr.io/drop-oss/drop:latest + stdin_open: true + tty: true + init: true depends_on: postgres: condition: service_healthy From ad2c0f982a4ba2f253ebb67f554a105261247d3e Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Thu, 29 May 2025 14:26:15 -0400 Subject: [PATCH 053/156] feat: add attestations to docker images attestations are best practice https://docs.docker.com/build/ci/github-actions/attestations/ --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3454c4a..109f033 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -87,6 +87,8 @@ 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 From 59b77b5a5e1136a3a737b84bff72620fed2e8840 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Thu, 29 May 2025 15:22:12 -0400 Subject: [PATCH 054/156] fix: allow specifying git ref --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 1e000a1..3ac6b7a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,6 +22,7 @@ COPY --from=deps /app/node_modules ./node_modules COPY . . ARG BUILD_DROP_VERSION="v0.0.0-unknown.1" +ARG BUILD_GIT_REF # build RUN yarn postinstall From f97a968e0d418bb251e9a38008dc33b8e3218db8 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Thu, 29 May 2025 15:59:53 -0400 Subject: [PATCH 055/156] fix: not being able to edit game title --- server/api/v1/admin/game/metadata.post.ts | 24 +++++++++++------------ server/internal/metadata/igdb.ts | 2 -- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/server/api/v1/admin/game/metadata.post.ts b/server/api/v1/admin/game/metadata.post.ts index 59b7dd2..e46dedc 100644 --- a/server/api/v1/admin/game/metadata.post.ts +++ b/server/api/v1/admin/game/metadata.post.ts @@ -1,3 +1,4 @@ +import type { Prisma } from "~/prisma/client"; import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; import { handleFileUpload } from "~/server/internal/utils/handlefileupload"; @@ -27,25 +28,24 @@ export default defineEventHandler(async (h3) => { const description = options.description; const gameId = options.id; - if (!id || !name || !description) { - dump(); + const changes: Prisma.GameUpdateInput = { + mName: name, + mShortDescription: description, + }; - throw createError({ - statusCode: 400, - statusMessage: "Nothing has changed", - }); + // handle if user uploaded new icon + if (id) { + changes.mIconObjectId = id; + await pull(); + } else { + dump(); } - await pull(); const newObject = await prisma.game.update({ where: { id: gameId, }, - data: { - mIconObjectId: id, - mName: name, - mShortDescription: description, - }, + data: changes, }); return newObject; diff --git a/server/internal/metadata/igdb.ts b/server/internal/metadata/igdb.ts index 0fbbedc..3427c9f 100644 --- a/server/internal/metadata/igdb.ts +++ b/server/internal/metadata/igdb.ts @@ -306,8 +306,6 @@ export class IGDBProvider implements MetadataProvider { results.push(await this._getGenreInternal(genre)); } - console.log(results); - return results; } From 37fa9537d0b0e80e2dd42845ce55fc83d5ae2d1b Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Thu, 29 May 2025 16:55:24 -0400 Subject: [PATCH 056/156] feat: object cleanup is finally here --- server/tasks/cleanup/objects.ts | 50 +++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/server/tasks/cleanup/objects.ts b/server/tasks/cleanup/objects.ts index 62955dc..d43f08f 100644 --- a/server/tasks/cleanup/objects.ts +++ b/server/tasks/cleanup/objects.ts @@ -1,5 +1,6 @@ import prisma from "~/server/internal/db/database"; import objectHandler from "~/server/internal/objects"; +import type { TaskReturn } from "../../h3"; type FieldReferenceMap = { [modelName: string]: { @@ -9,29 +10,54 @@ type FieldReferenceMap = { }; }; -export default defineTask({ +export default defineTask({ meta: { name: "cleanup:objects", }, async run() { console.log("[Task cleanup:objects]: Cleaning unreferenced objects"); + // get all objects const objects = await objectHandler.listAll(); console.log( `[Task cleanup:objects]: searching for ${objects.length} objects`, ); - console.log(objects); - const results = await findUnreferencedStrings(objects, buildRefMap()); + + // find unreferenced objects + const refMap = buildRefMap(); + console.log("[Task cleanup:objects]: Building reference map"); console.log( - `[Task cleanup:objects]: found ${results.length} Unreferenced objects`, + `[Task cleanup:objects]: Found ${Object.keys(refMap).length} models with reference fields`, ); - console.log(results); + console.log("[Task cleanup:objects]: Searching for unreferenced objects"); + const unrefedObjects = await findUnreferencedStrings(objects, refMap); + console.log( + `[Task cleanup:objects]: found ${unrefedObjects.length} Unreferenced objects`, + ); + // console.log(unrefedObjects); + + // remove objects + const deletePromises: Promise[] = []; + for (const obj of unrefedObjects) { + console.log(`[Task cleanup:objects]: Deleting object ${obj}`); + deletePromises.push(objectHandler.deleteAsSystem(obj)); + } + await Promise.all(deletePromises); 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 +85,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 +143,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, From d1c09784a4f934bf6feeef374ecf4c0da2d341bb Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Thu, 29 May 2025 17:07:30 -0400 Subject: [PATCH 057/156] fix: remove unused favicon ref --- layouts/admin.vue | 8 +------- layouts/default.vue | 8 +------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/layouts/admin.vue b/layouts/admin.vue index 91c2713..cea7e92 100644 --- a/layouts/admin.vue +++ b/layouts/admin.vue @@ -219,13 +219,7 @@ useHead({ htmlAttrs: { lang: "en", }, - link: [ - { - rel: "icon", - type: "image/png", - href: "/favicon.png", - }, - ], + link: [], titleTemplate(title) { return title ? `${title} | Admin | Drop` : `Admin Dashboard | Drop`; }, diff --git a/layouts/default.vue b/layouts/default.vue index 4e00849..11303ea 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -19,13 +19,7 @@ useHead({ htmlAttrs: { lang: "en", }, - link: [ - { - rel: "icon", - type: "image/png", - href: "/favicon.png", - }, - ], + link: [], titleTemplate(title) { if (title) return `${title} | Drop`; return `Drop`; From 4f789a2e5b341c85890a44036aa2a640a3531bbc Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Thu, 29 May 2025 17:27:03 -0400 Subject: [PATCH 058/156] feat: cleanup extra metadata --- server/internal/objects/fsBackend.ts | 24 ++++++++++++++++++++++++ server/internal/objects/objectHandler.ts | 10 ++++++++++ server/tasks/cleanup/objects.ts | 3 +++ 3 files changed, 37 insertions(+) diff --git a/server/internal/objects/fsBackend.ts b/server/internal/objects/fsBackend.ts index de0ea0a..e8b070a 100644 --- a/server/internal/objects/fsBackend.ts +++ b/server/internal/objects/fsBackend.ts @@ -174,6 +174,30 @@ export class FsObjectBackend extends ObjectBackend { async listAll(): Promise { return fs.readdirSync(this.baseObjectPath); } + + async cleanupMetadata() { + const metadataFiles = fs.readdirSync(this.baseMetadataPath); + const objects = await this.listAll(); + + const extraFiles = metadataFiles.filter( + (file) => !objects.includes(file.replace(/\.json$/, "")), + ); + console.log( + `[FsObjectBackend#cleanupMetadata]: Found ${extraFiles.length} metadata files without corresponding objects.`, + ); + for (const file of extraFiles) { + const filePath = path.join(this.baseMetadataPath, file); + try { + fs.rmSync(filePath); + console.log(`[FsObjectBackend#cleanupMetadata]: Removed ${file}`); + } catch (error) { + console.error( + `[FsObjectBackend#cleanupMetadata]: Failed to remove ${file}`, + error, + ); + } + } + } } class FsHashStore { diff --git a/server/internal/objects/objectHandler.ts b/server/internal/objects/objectHandler.ts index 81cb11f..0506f41 100644 --- a/server/internal/objects/objectHandler.ts +++ b/server/internal/objects/objectHandler.ts @@ -71,6 +71,7 @@ export abstract class ObjectBackend { ): Promise; abstract fetchHash(id: ObjectReference): Promise; abstract listAll(): Promise; + abstract cleanupMetadata(): Promise; } export class ObjectHandler { @@ -257,4 +258,13 @@ export class ObjectHandler { async listAll() { return await this.backend.listAll(); } + + /** + * Purges metadata for objects that no longer exist + * This is useful for cleaning up metadata files that are left behinds + * @returns + */ + async cleanupMetadata() { + return await this.backend.cleanupMetadata(); + } } diff --git a/server/tasks/cleanup/objects.ts b/server/tasks/cleanup/objects.ts index d43f08f..44cd7f3 100644 --- a/server/tasks/cleanup/objects.ts +++ b/server/tasks/cleanup/objects.ts @@ -44,6 +44,9 @@ export default defineTask({ } await Promise.all(deletePromises); + // Remove any possible leftover metadata + objectHandler.cleanupMetadata(); + console.log("[Task cleanup:objects]: Done"); return { result: { From 2b61e9a3715ab70cc8d701dfd12c6a66f8935900 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Wed, 28 May 2025 15:07:11 +1000 Subject: [PATCH 059/156] chore: add DROP_VERISON constant --- server/internal/consts.ts | 1 + 1 file changed, 1 insertion(+) create mode 100644 server/internal/consts.ts diff --git a/server/internal/consts.ts b/server/internal/consts.ts new file mode 100644 index 0000000..d44c4b7 --- /dev/null +++ b/server/internal/consts.ts @@ -0,0 +1 @@ +export const DROP_VERSION = "0.3.0"; \ No newline at end of file From 925f3cb4f002b3365edbd944955c12d161f83334 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Fri, 30 May 2025 08:39:58 +1000 Subject: [PATCH 060/156] chore: remove const --- server/internal/consts.ts | 1 - 1 file changed, 1 deletion(-) delete mode 100644 server/internal/consts.ts diff --git a/server/internal/consts.ts b/server/internal/consts.ts deleted file mode 100644 index d44c4b7..0000000 --- a/server/internal/consts.ts +++ /dev/null @@ -1 +0,0 @@ -export const DROP_VERSION = "0.3.0"; \ No newline at end of file From fca85633c10c19bdeecfba628126a07e510b1e1b Mon Sep 17 00:00:00 2001 From: DecDuck Date: Fri, 30 May 2025 08:41:50 +1000 Subject: [PATCH 061/156] chore: remove old playtime api --- server/internal/playtime/index.ts | 50 ------------------------------- 1 file changed, 50 deletions(-) delete mode 100644 server/internal/playtime/index.ts diff --git a/server/internal/playtime/index.ts b/server/internal/playtime/index.ts deleted file mode 100644 index 3ae1497..0000000 --- a/server/internal/playtime/index.ts +++ /dev/null @@ -1,50 +0,0 @@ -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; From 83a9b22d8267096257c0a2090162bf194d4ca124 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Fri, 30 May 2025 10:29:55 +1000 Subject: [PATCH 062/156] fix: various fixes --- nuxt.config.ts | 12 ++++-------- pages/admin/metadata/games/[id]/index.vue | 2 +- server/api/v1/admin/game/metadata.post.ts | 14 +++++++++++--- server/api/v1/screenshots/[id]/index.delete.ts | 2 +- server/api/v1/screenshots/[id]/index.get.ts | 2 +- server/internal/metadata/index.ts | 1 - server/internal/screenshots/index.ts | 5 +++-- 7 files changed, 21 insertions(+), 17 deletions(-) diff --git a/nuxt.config.ts b/nuxt.config.ts index bd2ffe2..ff77811 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -2,18 +2,14 @@ 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; +const dropVersion = process.env.BUILD_DROP_VERSION ?? "v0.3.0-alpha.1"; // 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; - + process.env.BUILD_GIT_REF ?? + execSync("git rev-parse --short HEAD").toString().trim(); + console.log(`Building Drop ${dropVersion} #${commitHash}`); // https://nuxt.com/docs/api/configuration/nuxt-config diff --git a/pages/admin/metadata/games/[id]/index.vue b/pages/admin/metadata/games/[id]/index.vue index 2512ff7..f773a3c 100644 --- a/pages/admin/metadata/games/[id]/index.vue +++ b/pages/admin/metadata/games/[id]/index.vue @@ -10,7 +10,7 @@ class="flex flex-col lg:flex-row lg:justify-between items-start lg:items-center gap-2" >
    - +

    diff --git a/server/api/v1/admin/game/metadata.post.ts b/server/api/v1/admin/game/metadata.post.ts index e46dedc..df64c04 100644 --- a/server/api/v1/admin/game/metadata.post.ts +++ b/server/api/v1/admin/game/metadata.post.ts @@ -28,24 +28,32 @@ export default defineEventHandler(async (h3) => { const description = options.description; const gameId = options.id; - const changes: Prisma.GameUpdateInput = { + const updateModel: Prisma.GameUpdateInput = { mName: name, mShortDescription: description, }; // handle if user uploaded new icon if (id) { - changes.mIconObjectId = id; + updateModel.mIconObjectId = id; await pull(); } else { dump(); } + // If the API call doesn't provide values, don't set them + for (const [key, value] of Object.entries(updateModel)) { + if (value === undefined) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete updateModel[key as keyof typeof updateModel]; + } + } + const newObject = await prisma.game.update({ where: { id: gameId, }, - data: changes, + data: updateModel, }); return newObject; diff --git a/server/api/v1/screenshots/[id]/index.delete.ts b/server/api/v1/screenshots/[id]/index.delete.ts index b4c5e7c..801b19d 100644 --- a/server/api/v1/screenshots/[id]/index.delete.ts +++ b/server/api/v1/screenshots/[id]/index.delete.ts @@ -18,7 +18,7 @@ export default defineEventHandler(async (h3) => { throw createError({ statusCode: 404, }); - else if (result.userId !== userId) + if (result.userId !== userId) throw createError({ statusCode: 404, }); diff --git a/server/api/v1/screenshots/[id]/index.get.ts b/server/api/v1/screenshots/[id]/index.get.ts index 5944103..c43b272 100644 --- a/server/api/v1/screenshots/[id]/index.get.ts +++ b/server/api/v1/screenshots/[id]/index.get.ts @@ -18,7 +18,7 @@ export default defineEventHandler(async (h3) => { throw createError({ statusCode: 404, }); - else if (result.userId !== userId) + if (result.userId !== userId) throw createError({ statusCode: 404, }); diff --git a/server/internal/metadata/index.ts b/server/internal/metadata/index.ts index ad23e94..cd3bd6a 100644 --- a/server/internal/metadata/index.ts +++ b/server/internal/metadata/index.ts @@ -234,7 +234,6 @@ export class MetadataHandler { libraryBasePath, }, }); - // relate companies to game await pullObjects(); diff --git a/server/internal/screenshots/index.ts b/server/internal/screenshots/index.ts index 4179ebc..b41ac03 100644 --- a/server/internal/screenshots/index.ts +++ b/server/internal/screenshots/index.ts @@ -53,11 +53,12 @@ class ScreenshotManager { * @param id */ async delete(id: string) { - await prisma.screenshot.delete({ + const deletedScreenshot = await prisma.screenshot.delete({ where: { id, }, }); + await objectHandler.deleteAsSystem(deletedScreenshot.objectId); } /** @@ -74,7 +75,7 @@ class ScreenshotManager { // TODO: set createAt to the time screenshot was taken createdAt: new Date().toISOString(), }, - [`${userId}:read`, `${userId}:delete`], + [`${userId}:read`], // This is a system tracked object, so we don't want users to have direct write access to it ); if (!saveStream) throw createError({ From 85edc4cca2e6ab9c13fbfbcdf9f57f792b9d3cea Mon Sep 17 00:00:00 2001 From: DecDuck Date: Fri, 30 May 2025 13:17:21 +1000 Subject: [PATCH 063/156] chore: move more admin over to arktype validators --- .gitignore | 3 +- nuxt.config.ts | 3 +- package.json | 1 + pages/auth/register.vue | 8 +- prisma/schema.prisma | 9 +++ .../v1/admin/auth/invitation/index.delete.ts | 14 +--- .../v1/admin/auth/invitation/index.post.ts | 20 ++--- .../api/v1/admin/game/image/index.delete.ts | 13 +--- server/api/v1/admin/game/index.delete.ts | 2 +- server/api/v1/admin/game/metadata.post.ts | 1 + .../api/v1/admin/game/version/index.delete.ts | 49 ++++++------ .../api/v1/admin/game/version/index.patch.ts | 74 ++++++++++--------- .../api/v1/admin/import/version/index.post.ts | 42 ++++++----- server/api/v1/client/chunk.get.ts | 2 +- server/arktype.ts | 14 ++++ yarn.lock | 52 +++++++++++++ 16 files changed, 193 insertions(+), 114 deletions(-) create mode 100644 server/arktype.ts diff --git a/.gitignore b/.gitignore index 705c5c9..7188be1 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,5 @@ deploy-template/* !deploy-template/compose.yml # generated prisma client -/prisma/client \ No newline at end of file +/prisma/client +/prisma/validate \ No newline at end of file diff --git a/nuxt.config.ts b/nuxt.config.ts index ff77811..11c26e2 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -9,7 +9,7 @@ const dropVersion = process.env.BUILD_DROP_VERSION ?? "v0.3.0-alpha.1"; const commitHash = process.env.BUILD_GIT_REF ?? execSync("git rev-parse --short HEAD").toString().trim(); - + console.log(`Building Drop ${dropVersion} #${commitHash}`); // https://nuxt.com/docs/api/configuration/nuxt-config @@ -79,6 +79,7 @@ export default defineNuxtConfig({ openAPI: { // tracking for dynamic openapi schema https://github.com/nitrojs/nitro/issues/2974 + // create body from types: https://github.com/nitrojs/nitro/issues/3275 meta: { title: "Drop", description: diff --git a/package.json b/package.json index b9a52f9..86724d9 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@drop-oss/droplet": "^0.7.2", "@headlessui/vue": "^1.7.23", "@heroicons/vue": "^2.1.5", + "@lobomfz/prismark": "0.0.3", "@nuxt/fonts": "^0.11.0", "@nuxt/image": "^1.10.0", "@prisma/client": "^6.7.0", diff --git a/pages/auth/register.vue b/pages/auth/register.vue index 3ee5bd4..14fd641 100644 --- a/pages/auth/register.vue +++ b/pages/auth/register.vue @@ -211,17 +211,19 @@ const confirmPassword = ref(undefined); const emailValidator = type("string.email"); const validEmail = computed( - () => !(emailValidator(email.value) instanceof type.errors), + () => !((emailValidator(email.value) as unknown) instanceof type.errors), ); const usernameValidator = type("string.alphanumeric >= 5").to("string.lower"); const validUsername = computed( - () => !(usernameValidator(username.value) instanceof type.errors), + () => + !((usernameValidator(username.value) as unknown) instanceof type.errors), ); const passwordValidator = type("string >= 14"); const validPassword = computed( - () => !(passwordValidator(password.value) instanceof type.errors), + () => + !((passwordValidator(password.value) as unknown) instanceof type.errors), ); const validConfirmPassword = computed( () => password.value == confirmPassword.value, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7765176..b6493a8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,6 +9,15 @@ generator client { binaryTargets = ["native", "debian-openssl-3.0.x"] } +/** + * generator arktype { + * provider = "yarn prismark" + * output = "./validate" + * fileName = "schema.ts" + * nullish = true + * } + */ + datasource db { provider = "postgresql" url = env("DATABASE_URL") diff --git a/server/api/v1/admin/auth/invitation/index.delete.ts b/server/api/v1/admin/auth/invitation/index.delete.ts index 34d24bd..4e8760c 100644 --- a/server/api/v1/admin/auth/invitation/index.delete.ts +++ b/server/api/v1/admin/auth/invitation/index.delete.ts @@ -1,10 +1,11 @@ import { type } from "arktype"; +import { throwingArktype } from "~/server/arktype"; import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; const DeleteInvite = type({ id: "string", -}); +}).configure(throwingArktype); export default defineEventHandler<{ body: typeof DeleteInvite.infer; @@ -14,16 +15,7 @@ export default defineEventHandler<{ ]); if (!allowed) throw createError({ statusCode: 403 }); - 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: body.summary, - }); - } + const body = await readValidatedBody(h3, DeleteInvite); await prisma.invitation.delete({ where: { id: body.id } }); return {}; diff --git a/server/api/v1/admin/auth/invitation/index.post.ts b/server/api/v1/admin/auth/invitation/index.post.ts index 58305c2..2ae9415 100644 --- a/server/api/v1/admin/auth/invitation/index.post.ts +++ b/server/api/v1/admin/auth/invitation/index.post.ts @@ -1,13 +1,14 @@ import { type } from "arktype"; +import { throwingArktype } from "~/server/arktype"; import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; const CreateInvite = type({ - isAdmin: "boolean", - username: "string", - email: "string.email", + isAdmin: "boolean?", + username: "string?", + email: "string.email?", expires: "string.date.iso.parse", -}); +}).configure(throwingArktype); export default defineEventHandler<{ body: typeof CreateInvite.infer; @@ -17,16 +18,7 @@ export default defineEventHandler<{ ]); if (!allowed) throw createError({ statusCode: 403 }); - const body = CreateInvite(await readBody(h3)); - if (body instanceof type.errors) { - // hover out.summary to see validation errors - console.error(body.summary); - - throw createError({ - statusCode: 400, - statusMessage: body.summary, - }); - } + const body = await readValidatedBody(h3, CreateInvite); const invitation = await prisma.invitation.create({ data: body, diff --git a/server/api/v1/admin/game/image/index.delete.ts b/server/api/v1/admin/game/image/index.delete.ts index 70c7817..fe9a650 100644 --- a/server/api/v1/admin/game/image/index.delete.ts +++ b/server/api/v1/admin/game/image/index.delete.ts @@ -2,11 +2,12 @@ import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; import objectHandler from "~/server/internal/objects"; import { type } from "arktype"; +import { throwingArktype } from "~/server/arktype"; const DeleteGameImage = type({ gameId: "string", imageId: "string", -}); +}).configure(throwingArktype); export default defineEventHandler<{ body: typeof DeleteGameImage.infer; @@ -14,14 +15,8 @@ export default defineEventHandler<{ const allowed = await aclManager.allowSystemACL(h3, ["game:image:delete"]); if (!allowed) throw createError({ statusCode: 403 }); - const body = DeleteGameImage(await readBody(h3)); - if (body instanceof type.errors) { - console.error(h3.path, body.summary); - throw createError({ - statusCode: 400, - statusMessage: body.summary, - }); - } + const body = await readValidatedBody(h3, DeleteGameImage); + const gameId = body.gameId; const imageId = body.imageId; diff --git a/server/api/v1/admin/game/index.delete.ts b/server/api/v1/admin/game/index.delete.ts index 6775177..730763c 100644 --- a/server/api/v1/admin/game/index.delete.ts +++ b/server/api/v1/admin/game/index.delete.ts @@ -1,7 +1,7 @@ import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; -export default defineEventHandler(async (h3) => { +export default defineEventHandler<{ query: { id: string } }>(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, ["game:delete"]); if (!allowed) throw createError({ statusCode: 403 }); diff --git a/server/api/v1/admin/game/metadata.post.ts b/server/api/v1/admin/game/metadata.post.ts index df64c04..40ae5ad 100644 --- a/server/api/v1/admin/game/metadata.post.ts +++ b/server/api/v1/admin/game/metadata.post.ts @@ -3,6 +3,7 @@ import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; import { handleFileUpload } from "~/server/internal/utils/handlefileupload"; + export default defineEventHandler(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, ["game:update"]); if (!allowed) throw createError({ statusCode: 403 }); diff --git a/server/api/v1/admin/game/version/index.delete.ts b/server/api/v1/admin/game/version/index.delete.ts index 79aa04d..08d182b 100644 --- a/server/api/v1/admin/game/version/index.delete.ts +++ b/server/api/v1/admin/game/version/index.delete.ts @@ -1,27 +1,34 @@ +import { type } from "arktype"; +import { throwingArktype } from "~/server/arktype"; import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; -export default defineEventHandler(async (h3) => { - const allowed = await aclManager.allowSystemACL(h3, ["game:version:delete"]); - if (!allowed) throw createError({ statusCode: 403 }); +const DeleteVersion = type({ + id: "string", + versionName: "string", +}).configure(throwingArktype); - const body = await readBody(h3); - const gameId = body.id.toString(); - const version = body.versionName.toString(); - if (!gameId || !version) - throw createError({ - statusCode: 400, - statusMessage: "Missing ID or versionName in body", +export default defineEventHandler<{ body: typeof DeleteVersion }>( + async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, [ + "game:version:delete", + ]); + if (!allowed) throw createError({ statusCode: 403 }); + + const body = await readValidatedBody(h3, DeleteVersion); + + const gameId = body.id.toString(); + const version = body.versionName.toString(); + + await prisma.gameVersion.delete({ + where: { + gameId_versionName: { + gameId: gameId, + versionName: version, + }, + }, }); - await prisma.gameVersion.delete({ - where: { - gameId_versionName: { - gameId: gameId, - versionName: version, - }, - }, - }); - - return {}; -}); + return {}; + }, +); diff --git a/server/api/v1/admin/game/version/index.patch.ts b/server/api/v1/admin/game/version/index.patch.ts index 7509cd5..009c502 100644 --- a/server/api/v1/admin/game/version/index.patch.ts +++ b/server/api/v1/admin/game/version/index.patch.ts @@ -1,41 +1,47 @@ +import { type } from "arktype"; +import { throwingArktype } from "~/server/arktype"; import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; -export default defineEventHandler(async (h3) => { - const allowed = await aclManager.allowSystemACL(h3, ["game:version:update"]); - if (!allowed) throw createError({ statusCode: 403 }); +const UpdateVersionOrder = type({ + id: "string", + versions: "string[]", +}).configure(throwingArktype); - const body = await readBody(h3); - const gameId = body.id?.toString(); - // We expect an array of the version names for this game - const versions: string[] | undefined = body.versions; - if (!gameId || !versions || !Array.isArray(versions)) - throw createError({ - statusCode: 400, - statusMessage: "Missing id, versions or versions is not an array", - }); +export default defineEventHandler<{ body: typeof UpdateVersionOrder }>( + async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, [ + "game:version:update", + ]); + if (!allowed) throw createError({ statusCode: 403 }); - const newVersions = await prisma.$transaction( - versions.map((versionName, versionIndex) => - prisma.gameVersion.update({ - where: { - gameId_versionName: { - gameId: gameId, - versionName: versionName, + const body = await readValidatedBody(h3, UpdateVersionOrder); + const gameId = body.id; + // We expect an array of the version names for this game + const versions = body.versions; + + const newVersions = await prisma.$transaction( + versions.map((versionName, versionIndex) => + prisma.gameVersion.update({ + where: { + gameId_versionName: { + gameId: gameId, + versionName: versionName, + }, }, - }, - data: { - versionIndex: versionIndex, - }, - select: { - versionIndex: true, - versionName: true, - platform: true, - delta: true, - }, - }), - ), - ); + data: { + versionIndex: versionIndex, + }, + select: { + versionIndex: true, + versionName: true, + platform: true, + delta: true, + }, + }), + ), + ); - return newVersions; -}); + return newVersions; + }, +); diff --git a/server/api/v1/admin/import/version/index.post.ts b/server/api/v1/admin/import/version/index.post.ts index 6ec7b3e..90a5861 100644 --- a/server/api/v1/admin/import/version/index.post.ts +++ b/server/api/v1/admin/import/version/index.post.ts @@ -1,33 +1,39 @@ +import { type } from "arktype"; import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; import libraryManager from "~/server/internal/library"; import { parsePlatform } from "~/server/internal/utils/parseplatform"; +const ImportVersion = type({ + id: "string", + version: "string", + + platform: "string", + launch: "string?", + launchArgs: "string?", + setup: "string?", + setupArgs: "string?", + onlySetup: "boolean?", + delta: "boolean?", + umuId: "string?", +}); + export default defineEventHandler(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, ["import:version:new"]); if (!allowed) throw createError({ statusCode: 403 }); - const body = await readBody(h3); + const body = await readValidatedBody(h3, ImportVersion); const gameId = body.id; const versionName = body.version; - const platform = body.platform as string | undefined; - const launch = (body.launch ?? "") as string; - const launchArgs = (body.launchArgs ?? "") as string; - const setup = (body.setup ?? "") as string; - const setupArgs = (body.setupArgs ?? "") as string; - const onlySetup = body.onlySetup ?? (false as boolean); - const delta = (body.delta ?? false) as boolean; - const umuId = (body.umuId ?? "") as string; - - if (!gameId || !versionName) - throw createError({ - statusCode: 400, - statusMessage: "Game ID and version are required.", - }); - - if (!platform) - throw createError({ statusCode: 400, statusMessage: "Missing platform." }); + const platform = body.platform; + const launch = body.launch ?? ""; + const launchArgs = body.launchArgs ?? ""; + const setup = body.setup ?? ""; + const setupArgs = body.setupArgs ?? ""; + const onlySetup = body.onlySetup ?? false; + const delta = body.delta ?? false; + const umuId = body.umuId ?? ""; const platformParsed = parsePlatform(platform); if (!platformParsed) diff --git a/server/api/v1/client/chunk.get.ts b/server/api/v1/client/chunk.get.ts index bc78468..b24325a 100644 --- a/server/api/v1/client/chunk.get.ts +++ b/server/api/v1/client/chunk.get.ts @@ -59,5 +59,5 @@ export default defineEventHandler(async (h3) => { const gameReadStream = fs.createReadStream(gameFile, { start, end: end - 1 }); // end needs to be offset by 1 - return gameReadStream; + return sendStream(h3, gameReadStream); }); diff --git a/server/arktype.ts b/server/arktype.ts new file mode 100644 index 0000000..8acffd9 --- /dev/null +++ b/server/arktype.ts @@ -0,0 +1,14 @@ +import { configure } from "arktype/config"; + +export const throwingArktype = configure({ + onFail: (errors) => errors.throw(), + actual: () => "" +}); + +// be sure to specify both the runtime and static configs + +declare global { + interface ArkEnv { + onFail: typeof throwingArktype.onFail; + } +} diff --git a/yarn.lock b/yarn.lock index 634ac27..ee35fef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34,11 +34,23 @@ dependencies: "@ark/util" "0.45.5" +"@ark/schema@0.46.0": + version "0.46.0" + resolved "https://registry.yarnpkg.com/@ark/schema/-/schema-0.46.0.tgz#81a1a0dc1ff0f2faa098cba05de505a174bdc64e" + integrity sha512-c2UQdKgP2eqqDArfBqQIJppxJHvNNXuQPeuSPlDML4rjw+f1cu0qAlzOG4b8ujgm9ctIDWwhpyw6gjG5ledIVQ== + dependencies: + "@ark/util" "0.46.0" + "@ark/util@0.45.5": version "0.45.5" resolved "https://registry.yarnpkg.com/@ark/util/-/util-0.45.5.tgz#7742fa3973bfeea253442a44b51a0ea492c43e51" integrity sha512-BbuDOKJJYZ2dzZDNhBavkhgGD2hv4/idEhXpEPA5weOlE9/kU7XwAvEWn74tCuySHrD7peDqe2Ct/EDUlM40qg== +"@ark/util@0.46.0": + version "0.46.0" + resolved "https://registry.yarnpkg.com/@ark/util/-/util-0.46.0.tgz#aee240bdaf413793e5ca4c4e8e3707aa965f4be3" + integrity sha512-JPy/NGWn/lvf1WmGCPw2VGpBg5utZraE84I7wli18EDF3p3zc/e9WolT35tINeZO3l7C77SjqRJeAUoT0CvMRg== + "@babel/code-frame@^7.26.2": version "7.26.2" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.26.2.tgz#4b5fab97d33338eff916235055f0ebc21e573a85" @@ -752,6 +764,14 @@ resolved "https://registry.yarnpkg.com/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz#8ace5259254426ccef57f3175bc64ed7095ed919" integrity sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw== +"@lobomfz/prismark@^0.0.3": + version "0.0.3" + resolved "https://registry.yarnpkg.com/@lobomfz/prismark/-/prismark-0.0.3.tgz#8b4eb34963e96cf3fff01432e62de22f184f2f2d" + integrity sha512-g2xfR/F+sRBRUhWYlpUkafqZjqsQBetjfzdWvQndRU4wdoavn3zblM3OQwb7vrsrKB6Wmbs+DtLGaD5XBQ2v8A== + dependencies: + "@prisma/generator-helper" "^6.5.0" + arktype "^2.1.9" + "@mapbox/node-pre-gyp@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.0.tgz#16d1d9049c0218820da81a12ae084e7fe67790d1" @@ -1390,6 +1410,16 @@ resolved "https://registry.yarnpkg.com/@prisma/debug/-/debug-6.7.0.tgz#a955174e4f481b0ca5d7c0fd458ef848432afd59" integrity sha512-RabHn9emKoYFsv99RLxvfG2GHzWk2ZI1BuVzqYtmMSIcuGboHY5uFt3Q3boOREM9de6z5s3bQoyKeWnq8Fz22w== +"@prisma/debug@6.8.2": + version "6.8.2" + resolved "https://registry.yarnpkg.com/@prisma/debug/-/debug-6.8.2.tgz#59fb9e0ccb0f431fe7011c36c95f9bfcbe051749" + integrity sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg== + +"@prisma/dmmf@6.8.2": + version "6.8.2" + resolved "https://registry.yarnpkg.com/@prisma/dmmf/-/dmmf-6.8.2.tgz#46632cfeda537b37f616febc68fb1ea7ef656fe4" + integrity sha512-okGJF/7hQZam/2wt+Y0hPyNxyY5S+L0FzAgtL932Q3YxUWHusRllrN39bCV45oF3QWY992g7rTWYdL2Rynt1qg== + "@prisma/engines-version@6.7.0-36.3cff47a7f5d65c3ea74883f1d736e41d68ce91ed": version "6.7.0-36.3cff47a7f5d65c3ea74883f1d736e41d68ce91ed" resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-6.7.0-36.3cff47a7f5d65c3ea74883f1d736e41d68ce91ed.tgz#4ec7c8ee187924a910ec244e3790412d1069d723" @@ -1414,6 +1444,20 @@ "@prisma/engines-version" "6.7.0-36.3cff47a7f5d65c3ea74883f1d736e41d68ce91ed" "@prisma/get-platform" "6.7.0" +"@prisma/generator-helper@^6.5.0": + version "6.8.2" + resolved "https://registry.yarnpkg.com/@prisma/generator-helper/-/generator-helper-6.8.2.tgz#09a72522384690223d4a047f71667eeb36d5ae70" + integrity sha512-KBLW47sbwFBKKYMiICIAEWsG6TdpPapPT7e7hpmpF3xMgYAm6YIXu4JGwfQVDY9Vbcb+0vPdPdSEQtInYOOe5g== + dependencies: + "@prisma/debug" "6.8.2" + "@prisma/dmmf" "6.8.2" + "@prisma/generator" "6.8.2" + +"@prisma/generator@6.8.2": + version "6.8.2" + resolved "https://registry.yarnpkg.com/@prisma/generator/-/generator-6.8.2.tgz#6b523cc9107293d848cd59482271806dafe629bc" + integrity sha512-yExkvgqiKg1WHzjYttz40g5DsOtud8RhapM0Mum6pw+wrDoQyhSAxs5NHyFCV+9VPvRd2v+jAP2CTT07bsibjw== + "@prisma/get-platform@6.7.0": version "6.7.0" resolved "https://registry.yarnpkg.com/@prisma/get-platform/-/get-platform-6.7.0.tgz#967f21f8ad966b941dcb47b153b84958655390d1" @@ -2376,6 +2420,14 @@ arktype@^2.1.10: "@ark/schema" "0.45.5" "@ark/util" "0.45.5" +arktype@^2.1.9: + version "2.1.20" + resolved "https://registry.yarnpkg.com/arktype/-/arktype-2.1.20.tgz#dd46726b0faf23c2753369876c77bb037e7089d9" + integrity sha512-IZCEEXaJ8g+Ijd59WtSYwtjnqXiwM8sWQ5EjGamcto7+HVN9eK0C4p0zDlCuAwWhpqr6fIBkxPuYDl4/Mcj/+Q== + dependencies: + "@ark/schema" "0.46.0" + "@ark/util" "0.46.0" + ast-kit@^1.0.1, ast-kit@^1.4.0: version "1.4.2" resolved "https://registry.yarnpkg.com/ast-kit/-/ast-kit-1.4.2.tgz#75d3706c12549573c7de9e7c38ed04fb7d0cc10f" From 6cc7e10fcda6af8106efe2b33895b4f13ceb6e37 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Fri, 30 May 2025 13:18:35 +1000 Subject: [PATCH 064/156] fix: remove old validation on version import --- server/api/v1/admin/import/version/index.post.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/server/api/v1/admin/import/version/index.post.ts b/server/api/v1/admin/import/version/index.post.ts index 90a5861..2c6872c 100644 --- a/server/api/v1/admin/import/version/index.post.ts +++ b/server/api/v1/admin/import/version/index.post.ts @@ -51,12 +51,6 @@ export default defineEventHandler(async (h3) => { }); } - if (umuId && typeof umuId !== "string") - throw createError({ - statusCode: 400, - statusMessage: "If specified, UMU ID must be a string.", - }); - if (onlySetup) { if (!setup) throw createError({ From 185f37f135c11024a421b53d94f3082fc54171b0 Mon Sep 17 00:00:00 2001 From: Huskydog9988 <39809509+Huskydog9988@users.noreply.github.com> Date: Thu, 29 May 2025 19:59:02 -0400 Subject: [PATCH 065/156] fix: release workflow --- .github/workflows/release.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 109f033..195eb94 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,8 +26,11 @@ jobs: - name: Get base tag id: get_base_tag run: | - BASE_TAG=$(git describe --tags --abbrev=0) - echo "base_tag=$BASE_TAG" >> $GITHUB_OUTPUT + git fetch --tags + + TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + echo "Using base tag: $TAG" + echo "base_tag=$TAG" >> $GITHUB_OUTPUT - name: Determine final tag id: get_final_tag From 3fbe514f659b56709ac35a1527299fe2e8a48e25 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Fri, 30 May 2025 22:07:50 +1000 Subject: [PATCH 066/156] feat: ratings ui, import giantbomb ratings --- pages/library/game/[id]/index.vue | 3 +-- pages/store/[id]/index.vue | 14 ++++++------- prisma/models/content.prisma | 2 +- server/api/v1/games/[id]/index.get.ts | 14 ++++++++++++- server/internal/metadata/giantbomb.ts | 29 ++++++++++++++++++++++++++- server/internal/metadata/igdb.ts | 25 +++++++++++++---------- yarn.lock | 2 +- 7 files changed, 64 insertions(+), 25 deletions(-) diff --git a/pages/library/game/[id]/index.vue b/pages/library/game/[id]/index.vue index 7b87fc1..4daac62 100644 --- a/pages/library/game/[id]/index.vue +++ b/pages/library/game/[id]/index.vue @@ -120,12 +120,11 @@ import { ArrowUpRightIcon, } from "@heroicons/vue/20/solid"; import { micromark } from "micromark"; -import type { Game } from "~/prisma/client"; const route = useRoute(); const id = route.params.id.toString(); -const rawGame = await $dropFetch(`/api/v1/games/${id}`); +const { game: rawGame } = await $dropFetch(`/api/v1/games/${id}`); const game = computed(() => { if (!rawGame) { throw createError({ statusCode: 404, message: "Game not found" }); diff --git a/pages/store/[id]/index.vue b/pages/store/[id]/index.vue index 48f2ec7..844082d 100644 --- a/pages/store/[id]/index.vue +++ b/pages/store/[id]/index.vue @@ -103,7 +103,9 @@ 'w-4 h-4', ]" /> - ({{ 0 }} reviews) + ({{ rating._sum.mReviewCount ?? 0 }} reviews) @@ -174,10 +176,8 @@ diff --git a/pages/admin/library/import.vue b/pages/admin/library/import.vue index 9bdff84..559cebc 100644 --- a/pages/admin/library/import.vue +++ b/pages/admin/library/import.vue @@ -13,7 +13,7 @@ class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6" > {{ - games.unimportedGames[currentlySelectedGame] + games.unimportedGames[currentlySelectedGame].game }} Please select a directory... (); @@ -286,12 +285,12 @@ async function updateSelectedGame(value: number) { if (currentlySelectedGame.value == value) return; currentlySelectedGame.value = value; if (currentlySelectedGame.value == -1) return; - const game = games.unimportedGames[currentlySelectedGame.value]; - if (!game) return; + const option = games.unimportedGames[currentlySelectedGame.value]; + if (!option) return; metadataResults.value = undefined; currentlySelectedMetadata.value = -1; - gameSearchTerm.value = game; + gameSearchTerm.value = option.game; await searchGame(); } @@ -324,17 +323,21 @@ const router = useRouter(); const importLoading = ref(false); const importError = ref(); -async function importGame(metadata: boolean) { - if (!metadataResults.value && metadata) return; +async function importGame(useMetadata: boolean) { + if (!metadataResults.value && useMetadata) return; + + const metadata = + useMetadata && metadataResults.value + ? metadataResults.value[currentlySelectedMetadata.value] + : undefined; + const option = games.unimportedGames[currentlySelectedGame.value]; const game = await $dropFetch("/api/v1/admin/import/game", { method: "POST", body: { - path: games.unimportedGames[currentlySelectedGame.value], - metadata: - metadata && metadataResults.value - ? metadataResults.value[currentlySelectedMetadata.value] - : undefined, + path: option.game, + library: option.library, + metadata, }, }); diff --git a/pages/admin/library/index.vue b/pages/admin/library/index.vue index 4d27b2f..2772c54 100644 --- a/pages/admin/library/index.vue +++ b/pages/admin/library/index.vue @@ -14,10 +14,7 @@ version.

    -
    +
    0); + const libraryGames = ref( libraryState.games.map((e) => { const noVersions = e.status.noVersions; @@ -219,5 +219,6 @@ async function deleteGame(id: string) { await $dropFetch(`/api/v1/admin/game?id=${id}`, { method: "DELETE" }); const index = libraryGames.value.findIndex((e) => e.id === id); libraryGames.value.splice(index, 1); + toImport.value = true; } diff --git a/prisma/migrations/20250601022736_add_database_library/migration.sql b/prisma/migrations/20250601022736_add_database_library/migration.sql new file mode 100644 index 0000000..e51f2fa --- /dev/null +++ b/prisma/migrations/20250601022736_add_database_library/migration.sql @@ -0,0 +1,53 @@ +/* + Warnings: + + - You are about to drop the `ClientPeerAPIConfiguration` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- CreateEnum +CREATE TYPE "LibraryBackend" AS ENUM ('Filesystem'); + +-- AlterEnum +ALTER TYPE "ClientCapabilities" ADD VALUE 'trackPlaytime'; + +-- DropForeignKey +ALTER TABLE "ClientPeerAPIConfiguration" DROP CONSTRAINT "ClientPeerAPIConfiguration_clientId_fkey"; + +-- AlterTable +ALTER TABLE "Screenshot" ALTER COLUMN "private" DROP DEFAULT; + +-- DropTable +DROP TABLE "ClientPeerAPIConfiguration"; + +-- CreateTable +CREATE TABLE "Library" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "backend" "LibraryBackend" NOT NULL, + "options" JSONB NOT NULL, + + CONSTRAINT "Library_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Playtime" ( + "gameId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "seconds" INTEGER NOT NULL, + "updatedAt" TIMESTAMPTZ(6) NOT NULL, + "createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Playtime_pkey" PRIMARY KEY ("gameId","userId") +); + +-- CreateIndex +CREATE INDEX "Playtime_userId_idx" ON "Playtime"("userId"); + +-- CreateIndex +CREATE INDEX "Screenshot_userId_idx" ON "Screenshot"("userId"); + +-- AddForeignKey +ALTER TABLE "Playtime" ADD CONSTRAINT "Playtime_gameId_fkey" FOREIGN KEY ("gameId") REFERENCES "Game"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Playtime" ADD CONSTRAINT "Playtime_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20250601032211_add_library_relation_to_game/migration.sql b/prisma/migrations/20250601032211_add_library_relation_to_game/migration.sql new file mode 100644 index 0000000..e50aac1 --- /dev/null +++ b/prisma/migrations/20250601032211_add_library_relation_to_game/migration.sql @@ -0,0 +1,17 @@ +/* +Warnings: + +- You are about to drop the column `libraryBasePath` on the `Game` table. All the data in the column will be lost. + +*/ +-- DropIndex +DROP INDEX "Game_libraryBasePath_key"; + +-- AlterTable +ALTER TABLE "Game" RENAME COLUMN "libraryBasePath" TO "libraryPath"; + +ALTER TABLE "Game" ADD COLUMN "libraryId" TEXT; + +-- AddForeignKey +ALTER TABLE "Game" +ADD CONSTRAINT "Game_libraryId_fkey" FOREIGN KEY ("libraryId") REFERENCES "Library" ("id") ON DELETE SET NULL ON UPDATE CASCADE; \ No newline at end of file diff --git a/prisma/migrations/20250601032938_add_unique_constraint/migration.sql b/prisma/migrations/20250601032938_add_unique_constraint/migration.sql new file mode 100644 index 0000000..f460020 --- /dev/null +++ b/prisma/migrations/20250601032938_add_unique_constraint/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[libraryId,libraryPath]` on the table `Game` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "Game_libraryId_libraryPath_key" ON "Game"("libraryId", "libraryPath"); diff --git a/prisma/models/app.prisma b/prisma/models/app.prisma index 492c265..a85f9ae 100644 --- a/prisma/models/app.prisma +++ b/prisma/models/app.prisma @@ -1,7 +1,7 @@ model ApplicationSettings { timestamp DateTime @id @default(now()) - metadataProviders String[] + metadataProviders String[] saveSlotCountLimit Int @default(5) saveSlotSizeLimit Float @default(10) // MB @@ -13,3 +13,17 @@ enum Platform { Linux @map("linux") macOS @map("macos") } + +enum LibraryBackend { + Filesystem +} + +model Library { + id String @id @default(uuid()) + name String + + backend LibraryBackend + options Json + + games Game[] +} diff --git a/prisma/models/content.prisma b/prisma/models/content.prisma index f2253a1..43e67fd 100644 --- a/prisma/models/content.prisma +++ b/prisma/models/content.prisma @@ -29,8 +29,13 @@ model Game { mImageCarouselObjectIds String[] // linked to below array mImageLibraryObjectIds String[] // linked to objects in s3 - versions GameVersion[] - libraryBasePath String @unique // Base dir for all the game versions + versions GameVersion[] + + // These fields will not be optional in the next version + // Any game without a library ID will be assigned one at startup, based on the defaults + libraryId String? + library Library? @relation(fields: [libraryId], references: [id]) + libraryPath String collections CollectionEntry[] saves SaveSlot[] @@ -42,6 +47,7 @@ model Game { publishers Company[] @relation(name: "publishers") @@unique([metadataSource, metadataId], name: "metadataKey") + @@unique([libraryId, libraryPath], name: "libraryKey") } model GameRating { diff --git a/server/api/v1/admin/game/index.get.ts b/server/api/v1/admin/game/index.get.ts index 5e3979d..e52a495 100644 --- a/server/api/v1/admin/game/index.get.ts +++ b/server/api/v1/admin/game/index.get.ts @@ -33,11 +33,12 @@ export default defineEventHandler(async (h3) => { }, }); - if (!game) + if (!game || !game.libraryId) throw createError({ statusCode: 404, statusMessage: "Game ID not found" }); - const unimportedVersions = await libraryManager.fetchUnimportedVersions( - game.id, + const unimportedVersions = await libraryManager.fetchUnimportedGameVersions( + game.libraryId, + game.libraryPath, ); return { game, unimportedVersions }; diff --git a/server/api/v1/admin/import/game/index.get.ts b/server/api/v1/admin/import/game/index.get.ts index c913bdc..95373ef 100644 --- a/server/api/v1/admin/import/game/index.get.ts +++ b/server/api/v1/admin/import/game/index.get.ts @@ -6,5 +6,10 @@ export default defineEventHandler(async (h3) => { if (!allowed) throw createError({ statusCode: 403 }); const unimportedGames = await libraryManager.fetchAllUnimportedGames(); - return { unimportedGames }; + const iterableUnimportedGames = Object.entries(unimportedGames) + .map(([libraryId, gameArray]) => + gameArray.map((e) => ({ game: e, library: libraryId })), + ) + .flat(); + return { unimportedGames: iterableUnimportedGames }; }); diff --git a/server/api/v1/admin/import/game/index.post.ts b/server/api/v1/admin/import/game/index.post.ts index 7f39f52..6123346 100644 --- a/server/api/v1/admin/import/game/index.post.ts +++ b/server/api/v1/admin/import/game/index.post.ts @@ -1,37 +1,46 @@ +import { type } from "arktype"; +import { throwingArktype } from "~/server/arktype"; import aclManager from "~/server/internal/acls"; import libraryManager from "~/server/internal/library"; import metadataHandler from "~/server/internal/metadata"; -import type { - GameMetadataSearchResult, - GameMetadataSource, -} from "~/server/internal/metadata/types"; -export default defineEventHandler(async (h3) => { - const allowed = await aclManager.allowSystemACL(h3, ["import:game:new"]); - if (!allowed) throw createError({ statusCode: 403 }); +const ImportGameBody = type({ + library: "string", + path: "string", + ["metadata?"]: { + id: "string", + sourceId: "string", + name: "string", + }, +}).configure(throwingArktype); - const body = await readBody(h3); +export default defineEventHandler<{ body: typeof ImportGameBody.infer }>( + async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["import:game:new"]); + if (!allowed) throw createError({ statusCode: 403 }); - const path = body.path; - const metadata = body.metadata as GameMetadataSearchResult & - GameMetadataSource; - if (!path) - throw createError({ - statusCode: 400, - statusMessage: "Path missing from body", - }); + const { library, path, metadata } = await readValidatedBody( + h3, + ImportGameBody, + ); - const validPath = await libraryManager.checkUnimportedGamePath(path); - if (!validPath) - throw createError({ - statusCode: 400, - statusMessage: "Invalid unimported game path", - }); + if (!path) + throw createError({ + statusCode: 400, + statusMessage: "Path missing from body", + }); - if (!metadata || !metadata.id || !metadata.sourceId) { - console.log(metadata); - return await metadataHandler.createGameWithoutMetadata(path); - } else { - return await metadataHandler.createGame(metadata, path); - } -}); + const valid = await libraryManager.checkUnimportedGamePath(library, path); + if (!valid) + throw createError({ + statusCode: 400, + statusMessage: "Invalid library or game.", + }); + + if (!metadata) { + return await metadataHandler.createGameWithoutMetadata(library, path); + } else { + return await metadataHandler.createGame(metadata, library, path); + } + }, +); diff --git a/server/api/v1/admin/import/version/index.get.ts b/server/api/v1/admin/import/version/index.get.ts index 8687b94..893d295 100644 --- a/server/api/v1/admin/import/version/index.get.ts +++ b/server/api/v1/admin/import/version/index.get.ts @@ -1,4 +1,5 @@ import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; import libraryManager from "~/server/internal/library"; export default defineEventHandler(async (h3) => { @@ -13,8 +14,17 @@ export default defineEventHandler(async (h3) => { statusMessage: "Missing id in request params", }); - const unimportedVersions = - await libraryManager.fetchUnimportedVersions(gameId); + const game = await prisma.game.findUnique({ + where: { id: gameId }, + select: { libraryId: true, libraryPath: true }, + }); + if (!game || !game.libraryId) + throw createError({ statusCode: 404, statusMessage: "Game not found" }); + + const unimportedVersions = await libraryManager.fetchUnimportedGameVersions( + game.libraryId, + game.libraryPath, + ); if (!unimportedVersions) throw createError({ statusCode: 400, statusMessage: "Invalid game ID" }); diff --git a/server/api/v1/admin/import/version/index.post.ts b/server/api/v1/admin/import/version/index.post.ts index 2c6872c..d52042b 100644 --- a/server/api/v1/admin/import/version/index.post.ts +++ b/server/api/v1/admin/import/version/index.post.ts @@ -9,31 +9,31 @@ const ImportVersion = type({ version: "string", platform: "string", - launch: "string?", - launchArgs: "string?", - setup: "string?", - setupArgs: "string?", - onlySetup: "boolean?", - delta: "boolean?", - umuId: "string?", + launch: "string = ''", + launchArgs: "string = ''", + setup: "string = ''", + setupArgs: "string = ''", + onlySetup: "boolean = false", + delta: "boolean = false", + umuId: "string = ''", }); export default defineEventHandler(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, ["import:version:new"]); if (!allowed) throw createError({ statusCode: 403 }); - const body = await readValidatedBody(h3, ImportVersion); - const gameId = body.id; - const versionName = body.version; - - const platform = body.platform; - const launch = body.launch ?? ""; - const launchArgs = body.launchArgs ?? ""; - const setup = body.setup ?? ""; - const setupArgs = body.setupArgs ?? ""; - const onlySetup = body.onlySetup ?? false; - const delta = body.delta ?? false; - const umuId = body.umuId ?? ""; + const { + id, + version, + platform, + launch, + launchArgs, + setup, + setupArgs, + onlySetup, + delta, + umuId, + } = await readValidatedBody(h3, ImportVersion); const platformParsed = parsePlatform(platform); if (!platformParsed) @@ -41,7 +41,7 @@ export default defineEventHandler(async (h3) => { if (delta) { const validOverlayVersions = await prisma.gameVersion.count({ - where: { gameId: gameId, platform: platformParsed, delta: false }, + where: { gameId: id, platform: platformParsed, delta: false }, }); if (validOverlayVersions == 0) throw createError({ @@ -66,7 +66,7 @@ export default defineEventHandler(async (h3) => { } // startup & delta require more complex checking logic - const taskId = await libraryManager.importVersion(gameId, versionName, { + const taskId = await libraryManager.importVersion(id, version, { platform, onlySetup, diff --git a/server/api/v1/client/chunk.get.ts b/server/api/v1/client/chunk.get.ts index b24325a..4f70e05 100644 --- a/server/api/v1/client/chunk.get.ts +++ b/server/api/v1/client/chunk.get.ts @@ -1,10 +1,14 @@ +import cacheHandler from "~/server/internal/cache"; import prisma from "~/server/internal/db/database"; -import fs from "fs"; -import path from "path"; import libraryManager from "~/server/internal/library"; const chunkSize = 1024 * 1024 * 64; +const gameLookupCache = cacheHandler.createCache<{ + libraryId: string | null; + libraryPath: string; +}>("downloadGameLookupCache"); + export default defineEventHandler(async (h3) => { const query = getQuery(h3); const gameId = query.id?.toString(); @@ -18,36 +22,40 @@ export default defineEventHandler(async (h3) => { statusMessage: "Invalid chunk arguments", }); - const game = await prisma.game.findUnique({ - where: { - id: gameId, - }, - select: { - libraryBasePath: true, - }, - }); - if (!game) - throw createError({ statusCode: 400, statusMessage: "Invalid game ID" }); + let game = await gameLookupCache.getItem(gameId); + if (!game) { + game = await prisma.game.findUnique({ + where: { + id: gameId, + }, + select: { + libraryId: true, + libraryPath: true, + }, + }); + if (!game || !game.libraryId) + throw createError({ statusCode: 400, statusMessage: "Invalid game ID" }); - const versionDir = path.join( - libraryManager.fetchLibraryPath(), - game.libraryBasePath, - versionName, - ); - if (!fs.existsSync(versionDir)) + await gameLookupCache.setItem(gameId, game); + } + + if (!game.libraryId) throw createError({ - statusCode: 400, - statusMessage: "Invalid version name", + statusCode: 500, + statusMessage: "Somehow, we got here.", }); - const gameFile = path.join(versionDir, filename); - if (!fs.existsSync(gameFile)) - throw createError({ statusCode: 400, statusMessage: "Invalid game file" }); - - const gameFileStats = fs.statSync(gameFile); + const peek = await libraryManager.peekFile( + game.libraryId, + game.libraryPath, + versionName, + filename, + ); + if (!peek) + throw createError({ status: 400, statusMessage: "Failed to peek file" }); const start = chunkIndex * chunkSize; - const end = Math.min((chunkIndex + 1) * chunkSize, gameFileStats.size); + const end = Math.min((chunkIndex + 1) * chunkSize, peek.size); const currentChunkSize = end - start; setHeader(h3, "Content-Length", currentChunkSize); @@ -57,7 +65,18 @@ export default defineEventHandler(async (h3) => { statusMessage: "Invalid chunk index", }); - const gameReadStream = fs.createReadStream(gameFile, { start, end: end - 1 }); // end needs to be offset by 1 + const gameReadStream = await libraryManager.readFile( + game.libraryId, + game.libraryPath, + versionName, + filename, + { start, end: end - 1 }, + ); // end needs to be offset by 1 + if (!gameReadStream) + throw createError({ + statusCode: 400, + statusMessage: "Failed to create stream", + }); return sendStream(h3, gameReadStream); }); diff --git a/server/internal/library/filesystem.ts b/server/internal/library/filesystem.ts new file mode 100644 index 0000000..73a4a32 --- /dev/null +++ b/server/internal/library/filesystem.ts @@ -0,0 +1,107 @@ +import { ArkErrors, type } from "arktype"; +import { + GameNotFoundError, + VersionNotFoundError, + type LibraryProvider, +} from "./provider"; +import { LibraryBackend } from "~/prisma/client"; +import fs from "fs"; +import path from "path"; +import droplet from "@drop-oss/droplet"; +import type { Readable } from "stream"; + +export const FilesystemProviderConfig = type({ + baseDir: "string", +}); + +export class FilesystemProvider + implements LibraryProvider +{ + private config: typeof FilesystemProviderConfig.infer; + private myId: string; + + constructor(rawConfig: unknown, id: string) { + const config = FilesystemProviderConfig(rawConfig); + if (config instanceof ArkErrors) { + throw new Error( + `Failed to create filesystem provider: ${config.summary}`, + ); + } + + this.myId = id; + this.config = config; + fs.mkdirSync(this.config.baseDir, { recursive: true }); + } + + id(): string { + return this.myId; + } + + type(): LibraryBackend { + return LibraryBackend.Filesystem; + } + + async listGames(): Promise { + const dirs = fs.readdirSync(this.config.baseDir); + const folderDirs = dirs.filter((e) => { + const fullDir = path.join(this.config.baseDir, e); + return fs.lstatSync(fullDir).isDirectory(); + }); + return folderDirs; + } + + async listVersions(game: string): Promise { + const gameDir = path.join(this.config.baseDir, game); + if (!fs.existsSync(gameDir)) throw new GameNotFoundError(); + const versionDirs = fs.readdirSync(gameDir); + const validVersionDirs = versionDirs.filter((e) => { + const fullDir = path.join(this.config.baseDir, game, e); + return droplet.hasBackendForPath(fullDir); + }); + return validVersionDirs; + } + + async versionReaddir(game: string, version: string): Promise { + const versionDir = path.join(this.config.baseDir, game, version); + if (!fs.existsSync(versionDir)) throw new VersionNotFoundError(); + return droplet.listFiles(versionDir); + } + + async generateDropletManifest( + game: string, + version: string, + progress: (err: Error | null, v: number) => void, + log: (err: Error | null, v: string) => void, + ): Promise { + const versionDir = path.join(this.config.baseDir, game, version); + if (!fs.existsSync(versionDir)) throw new VersionNotFoundError(); + const manifest = await new Promise((r, j) => + droplet.generateManifest(versionDir, progress, log, (err, result) => { + if (err) return j(err); + r(result); + }), + ); + return manifest; + } + + // TODO: move this over to the droplet.readfile function it works + async readFile( + game: string, + version: string, + filename: string, + options?: { start?: number; end?: number }, + ): Promise { + const filepath = path.join(this.config.baseDir, game, version, filename); + if (!fs.existsSync(filepath)) return undefined; + const stream = fs.createReadStream(filepath, options); + + return stream; + } + + async peekFile(game: string, version: string, filename: string) { + const filepath = path.join(this.config.baseDir, game, version, filename); + if (!fs.existsSync(filepath)) return undefined; + const stat = fs.statSync(filepath); + return { size: stat.size }; + } +} diff --git a/server/internal/library/index.ts b/server/internal/library/index.ts index c288174..873c0df 100644 --- a/server/internal/library/index.ts +++ b/server/internal/library/index.ts @@ -5,60 +5,63 @@ * It also provides the endpoints with information about unmatched games */ -import fs from "fs"; import path from "path"; import prisma from "../db/database"; -import type { GameVersion } from "~/prisma/client"; import { fuzzy } from "fast-fuzzy"; -import { recursivelyReaddir } from "../utils/recursivedirs"; 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"; +import type { LibraryProvider } from "./provider"; class LibraryManager { - private basePath: string; + private libraries: Map> = new Map(); - constructor() { - this.basePath = systemConfig.getLibraryFolder(); - fs.mkdirSync(this.basePath, { recursive: true }); - } - - fetchLibraryPath() { - return this.basePath; + addLibrary(library: LibraryProvider) { + this.libraries.set(library.id(), library); } async fetchAllUnimportedGames() { - const dirs = fs.readdirSync(this.basePath).filter((e) => { - const fullDir = path.join(this.basePath, e); - return fs.lstatSync(fullDir).isDirectory(); - }); + const unimportedGames: { [key: string]: string[] } = {}; - const validGames = await prisma.game.findMany({ - where: { - libraryBasePath: { in: dirs }, - }, - select: { - libraryBasePath: true, - }, - }); - const validGameDirs = validGames.map((e) => e.libraryBasePath); + for (const [id, library] of this.libraries.entries()) { + const games = await library.listGames(); + const validGames = await prisma.game.findMany({ + where: { + libraryId: id, + libraryPath: { in: games }, + }, + select: { + libraryPath: true, + }, + }); + const providerUnimportedGames = games.filter( + (e) => validGames.findIndex((v) => v.libraryPath == e) == -1, + ); + unimportedGames[id] = providerUnimportedGames; + } - const unregisteredGames = dirs.filter((e) => !validGameDirs.includes(e)); - - return unregisteredGames; + return unimportedGames; } - async fetchUnimportedGameVersions( - libraryBasePath: string, - versions: Array, - ) { - const gameDir = path.join(this.basePath, libraryBasePath); - const versionsDirs = fs.readdirSync(gameDir); - const importedVersionDirs = versions.map((e) => e.versionName); - const unimportedVersions = versionsDirs.filter( - (e) => !importedVersionDirs.includes(e), + async fetchUnimportedGameVersions(libraryId: string, libraryPath: string) { + const provider = this.libraries.get(libraryId); + if (!provider) return undefined; + const game = await prisma.game.findUnique({ + where: { + libraryKey: { + libraryId, + libraryPath, + }, + }, + select: { + versions: true, + }, + }); + if (!game) return undefined; + + const versions = await provider.listVersions(libraryPath); + const unimportedVersions = versions.filter( + (e) => game.versions.findIndex((v) => v.versionName == e) == -1, ); return unimportedVersions; @@ -73,7 +76,8 @@ class LibraryManager { mShortDescription: true, metadataSource: true, mIconObjectId: true, - libraryBasePath: true, + libraryId: true, + libraryPath: true, }, orderBy: { mName: "asc", @@ -85,60 +89,24 @@ class LibraryManager { game: e, status: { noVersions: e.versions.length == 0, - unimportedVersions: await this.fetchUnimportedGameVersions( - e.libraryBasePath, - e.versions, - ), + unimportedVersions: (await this.fetchUnimportedGameVersions( + e.libraryId ?? "", + e.libraryPath, + ))!, }, })), ); } - async fetchUnimportedVersions(gameId: string) { - const game = await prisma.game.findUnique({ - where: { id: gameId }, - select: { - versions: { - select: { - versionName: true, - }, - }, - libraryBasePath: true, - }, - }); - - if (!game) return undefined; - const targetDir = path.join(this.basePath, game.libraryBasePath); - if (!fs.existsSync(targetDir)) - throw new Error( - "Game in database, but no physical directory? Something is very very wrong...", - ); - const versions = fs.readdirSync(targetDir); - const validVersions = versions.filter((versionDir) => { - const versionPath = path.join(targetDir, versionDir); - const stat = fs.statSync(versionPath); - return stat.isDirectory(); - }); - const currentVersions = game.versions.map((e) => e.versionName); - - const unimportedVersions = validVersions.filter( - (e) => !currentVersions.includes(e), - ); - return unimportedVersions; - } - async fetchUnimportedVersionInformation(gameId: string, versionName: string) { const game = await prisma.game.findUnique({ where: { id: gameId }, - select: { libraryBasePath: true, mName: true }, + select: { libraryPath: true, libraryId: true, mName: true }, }); - if (!game) return undefined; - const targetDir = path.join( - this.basePath, - game.libraryBasePath, - versionName, - ); - if (!fs.existsSync(targetDir)) return undefined; + if (!game || !game.libraryId) return undefined; + + const library = this.libraries.get(game.libraryId); + if (!library) return undefined; const fileExts: { [key: string]: string[] } = { Linux: [ @@ -165,7 +133,7 @@ class LibraryManager { match: number; }> = []; - const files = recursivelyReaddir(targetDir, 2); + const files = await library.versionReaddir(game.libraryPath, versionName); for (const file of files) { const filename = path.basename(file); const dotLocation = file.lastIndexOf("."); @@ -174,10 +142,9 @@ class LibraryManager { for (const checkExt of checkExts) { if (checkExt != ext) continue; const fuzzyValue = fuzzy(filename, game.mName); - const relative = path.relative(targetDir, file); options.push({ - filename: relative, - platform: platform, + filename, + platform, match: fuzzyValue, }); } @@ -190,17 +157,22 @@ class LibraryManager { } // Checks are done in least to most expensive order - async checkUnimportedGamePath(targetPath: string) { - const targetDir = path.join(this.basePath, targetPath); - if (!fs.existsSync(targetDir)) return false; - + async checkUnimportedGamePath(libraryId: string, libraryPath: string) { const hasGame = - (await prisma.game.count({ where: { libraryBasePath: targetPath } })) > 0; + (await prisma.game.count({ where: { libraryId, libraryPath } })) > 0; if (hasGame) return false; return true; } + /* + Game creation happens in metadata, because it's primarily a metadata object + + async createGame(libraryId: string, libraryPath: string, game: Omit) { + + } + */ + async importVersion( gameId: string, versionName: string, @@ -224,12 +196,12 @@ class LibraryManager { const game = await prisma.game.findUnique({ where: { id: gameId }, - select: { mName: true, libraryBasePath: true }, + select: { mName: true, libraryId: true, libraryPath: true }, }); - if (!game) return undefined; + if (!game || !game.libraryId) return undefined; - const baseDir = path.join(this.basePath, game.libraryBasePath, versionName); - if (!fs.existsSync(baseDir)) return undefined; + const library = this.libraries.get(game.libraryId); + if (!library) return undefined; taskHandler.create({ id: taskId, @@ -238,23 +210,18 @@ class LibraryManager { async run({ progress, log }) { // First, create the manifest via droplet. // This takes up 90% of our progress, so we wrap it in a *0.9 - const manifest = await new Promise((resolve, reject) => { - droplet.generateManifest( - baseDir, - (err, value) => { - if (err) return reject(err); - progress(value * 0.9); - }, - (err, line) => { - if (err) return reject(err); - log(line); - }, - (err, manifest) => { - if (err) return reject(err); - resolve(manifest); - }, - ); - }); + const manifest = await library.generateDropletManifest( + game.libraryPath, + versionName, + (err, value) => { + if (err) throw err; + progress(value * 0.9); + }, + (err, value) => { + if (err) throw err; + log(value); + }, + ); log("Created manifest successfully!"); @@ -315,6 +282,29 @@ class LibraryManager { return taskId; } + + async peekFile( + libraryId: string, + game: string, + version: string, + filename: string, + ) { + const library = this.libraries.get(libraryId); + if (!library) return undefined; + return library.peekFile(game, version, filename); + } + + async readFile( + libraryId: string, + game: string, + version: string, + filename: string, + options?: { start?: number; end?: number }, + ) { + const library = this.libraries.get(libraryId); + if (!library) return undefined; + return library.readFile(game, version, filename, options); + } } export const libraryManager = new LibraryManager(); diff --git a/server/internal/library/provider.ts b/server/internal/library/provider.ts new file mode 100644 index 0000000..b7d9ea9 --- /dev/null +++ b/server/internal/library/provider.ts @@ -0,0 +1,64 @@ +import type { Readable } from "stream"; +import type { LibraryBackend } from "~/prisma/client"; + +export abstract class LibraryProvider { + constructor(_config: CFG, _id: string) { + throw new Error("Library doesn't have a proper constructor"); + } + + /** + * @returns ID of the current library provider (fs, smb, s3, etc) + */ + abstract type(): LibraryBackend; + + /** + * @returns the specific ID of this current provider + */ + abstract id(): string; + + /** + * @returns list of (usually) top-level game folder names + */ + abstract listGames(): Promise; + + /** + * @param game folder name of the game to list versions for + * @returns list of version folder names + */ + abstract listVersions(game: string): Promise; + + /** + * @param game folder name of the game + * @param version folder name of the version + * @returns recursive list of all files in version, relative to the version folder (e.g. ./setup.exe) + */ + abstract versionReaddir(game: string, version: string): Promise; + + /** + * @param game folder name of the game + * @param version folder name of the version + * @returns string of JSON of the droplet manifest + */ + abstract generateDropletManifest( + game: string, + version: string, + progress: (err: Error | null, v: number) => void, + log: (err: Error | null, v: string) => void, + ): Promise; + + abstract peekFile( + game: string, + version: string, + filename: string, + ): Promise<{ size: number } | undefined>; + + abstract readFile( + game: string, + version: string, + filename: string, + options?: { start?: number; end?: number }, + ): Promise; +} + +export class GameNotFoundError extends Error {} +export class VersionNotFoundError extends Error {} diff --git a/server/internal/metadata/index.ts b/server/internal/metadata/index.ts index cd3bd6a..8c0e33e 100644 --- a/server/internal/metadata/index.ts +++ b/server/internal/metadata/index.ts @@ -97,18 +97,15 @@ export class MetadataHandler { return successfulResults; } - async createGameWithoutMetadata(libraryBasePath: string) { + async createGameWithoutMetadata(libraryId: string, libraryPath: string) { return await this.createGame( { id: "", - name: libraryBasePath, - icon: "", - description: "", - year: 0, + name: libraryPath, sourceId: "manual", - sourceName: "Manual", }, - libraryBasePath, + libraryId, + libraryPath, ); } @@ -165,8 +162,9 @@ export class MetadataHandler { } async createGame( - result: InternalGameMetadataResult, - libraryBasePath: string, + result: { sourceId: string; id: string; name: string }, + libraryId: string, + libraryPath: string, ) { const provider = this.providers.get(result.sourceId); if (!provider) @@ -231,7 +229,8 @@ export class MetadataHandler { connectOrCreate: this.parseTags(metadata.tags), }, - libraryBasePath, + libraryId, + libraryPath, }, }); diff --git a/server/plugins/05.library-init.ts b/server/plugins/05.library-init.ts new file mode 100644 index 0000000..7c40105 --- /dev/null +++ b/server/plugins/05.library-init.ts @@ -0,0 +1,74 @@ +import { LibraryBackend } from "~/prisma/client"; +import prisma from "../internal/db/database"; +import type { JsonValue } from "@prisma/client/runtime/library"; +import type { LibraryProvider } from "../internal/library/provider"; +import type { FilesystemProviderConfig } from "../internal/library/filesystem"; +import { FilesystemProvider } from "../internal/library/filesystem"; +import libraryManager from "../internal/library"; +import path from "path"; + +const libraryConstructors: { + [key in LibraryBackend]: ( + value: JsonValue, + id: string, + ) => LibraryProvider; +} = { + Filesystem: function ( + value: JsonValue, + id: string, + ): LibraryProvider { + return new FilesystemProvider(value, id); + }, +}; + +export default defineNitroPlugin(async () => { + let successes = 0; + const libraries = await prisma.library.findMany({}); + + // Add migration handler + const legacyPath = process.env.LIBRARY; + if (legacyPath && libraries.length == 0) { + const options: typeof FilesystemProviderConfig.infer = { + baseDir: path.resolve(legacyPath), + }; + + const library = await prisma.library.create({ + data: { + name: "Auto-created", + backend: LibraryBackend.Filesystem, + options, + }, + }); + + libraries.push(library); + + // Update all existing games + await prisma.game.updateMany({ + where: { + libraryId: null, + }, + data: { + libraryId: library.id, + }, + }); + } + + for (const library of libraries) { + const constructor = libraryConstructors[library.backend]; + try { + const provider = constructor(library.options, library.id); + libraryManager.addLibrary(provider); + successes++; + } catch (e) { + console.warn( + `Failed to create library (${library.id}) of type ${library.backend}:\n ${e}`, + ); + } + } + + if (successes == 0) { + console.warn( + "No library was successfully initialised. Please check for errors. If you have just set up an instance, this is normal.", + ); + } +}); diff --git a/yarn.lock b/yarn.lock index 43d026c..9f67fb9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -305,83 +305,71 @@ dependencies: mime "^3.0.0" -"@drop-oss/droplet-darwin-arm64@0.7.2": - version "0.7.2" - resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-arm64/-/droplet-darwin-arm64-0.7.2.tgz#fb714d3bf83dbf5e0ee6068ce2fdc74652a9d073" - integrity sha512-g1IiaSWYd+NDhyRbEKxSxrKFieJV/bwijcFfzP5VLHbTohDu5zJLe6Exc/IXbIb+Ex70Rfsk8Sf9n1zfHCD+Fg== +"@drop-oss/droplet-darwin-arm64@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-arm64/-/droplet-darwin-arm64-1.3.1.tgz#672484c15419dbc068950f2d53f130ccbea3ff17" + integrity sha512-rarsZtIiZhv2hb3bAZSJjxwnme+rWUFY+FY79MRrMnz7EuNBez063pFBqDhwFCz+0QqDBz7zKDUQR6v+6gnVFw== -"@drop-oss/droplet-darwin-universal@0.7.2": - version "0.7.2" - resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-universal/-/droplet-darwin-universal-0.7.2.tgz#237d70fab92b892e4d40855d13fd54ad55cf026e" - integrity sha512-wVVkMi0uwOob876xNFc37/5dGusKjlsWc4Z9bTUtTGeWo9gx5BkEpHBRrwD9NBAklr0Eu7Kmin3niB7pfx9vTw== +"@drop-oss/droplet-darwin-universal@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-universal/-/droplet-darwin-universal-1.3.1.tgz#0a3e1663125349b2d443dbfaba1bb7b8d2f9c64d" + integrity sha512-PuN5FdotwYuZ7O2r1aAWiE8hH/gH7CrH+j33OdgS4FI4XIOeW6qq+14JECZp6JgWv0863/C7tD5Ll4yMgIRvUQ== -"@drop-oss/droplet-darwin-x64@0.7.2": - version "0.7.2" - resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-x64/-/droplet-darwin-x64-0.7.2.tgz#ed12ced467ff38f7eb2419b0ae6b1c508d828b84" - integrity sha512-/p53OVesFG1Q/3+kYImitduGvZFfrfyVgdW+twoy+DYTX5EE1XZKaLZs2PSnbFSnnFJTmWvfnGqN5s+Dh12AKw== +"@drop-oss/droplet-darwin-x64@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-x64/-/droplet-darwin-x64-1.3.1.tgz#755db12988b431eff24df9a7177453178f29ce47" + integrity sha512-IloUIHnEI67S38vJxADbcXk81tR8b4fFTTpyCNUlAwIXHGbhEjFrfu+sLdK94MHN/vjlafMBf0APwYF2vclCkw== -"@drop-oss/droplet-linux-arm-gnueabihf@0.7.2": - version "0.7.2" - resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm-gnueabihf/-/droplet-linux-arm-gnueabihf-0.7.2.tgz#2fb0bfae2cb5fd08942d4f490f25046f006123ce" - integrity sha512-hZtkKhgMkSqhueOEBRBZlSWE6uawM9M31gPmajrYHNOEnnmt8oUtZriPvC1ffZwZnQb4LL7IMGUZmXTl6guZXQ== +"@drop-oss/droplet-linux-arm64-gnu@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-gnu/-/droplet-linux-arm64-gnu-1.3.1.tgz#26ac0b24a08e6785742a19cbf97003f23d3b152d" + integrity sha512-aiesHfQushi+EGmTj970bxZvhNsBh90kzKbg14vdgFTL0/mhcturJSHa0VhJ2/m4qIg10IlmJpbuEm165Q25rQ== -"@drop-oss/droplet-linux-arm-musleabihf@0.7.2": - version "0.7.2" - resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm-musleabihf/-/droplet-linux-arm-musleabihf-0.7.2.tgz#cbf54f5ff271a9e4601f6f6489cb6f630c6e9cbc" - integrity sha512-FBy8GE06mWSlv/t3d7iOF2wP9jvvPTePwPpIQyMpmEOz5MmdwF3/PFFncV4WcmxQ/RHUhIrZ3M9Dfq8WCiXPgw== +"@drop-oss/droplet-linux-arm64-musl@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-musl/-/droplet-linux-arm64-musl-1.3.1.tgz#1449cd59a75363a01f2ed68c1921e6d93fbafccd" + integrity sha512-Oa6HvvBbflxoG1nmdYbcgMAf29aTZ6xCxC84X+Q8TjiO5Qx2PHI+nX+KKK8rrJdQszrqpdT9wZbnD4zDtLzSeQ== -"@drop-oss/droplet-linux-arm64-gnu@0.7.2": - version "0.7.2" - resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-gnu/-/droplet-linux-arm64-gnu-0.7.2.tgz#96062bf8a63de742995d89b782fa7e11f26d984f" - integrity sha512-Ev+WOUwazMgzz3tcHZefCaELSQ/dUJA795eXiNp0jDFRhddeybulxabte9hM9XjP5Yg/pZ0GpenWMjcWvxVaIQ== +"@drop-oss/droplet-linux-riscv64-gnu@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-riscv64-gnu/-/droplet-linux-riscv64-gnu-1.3.1.tgz#6300be23128ed4fd8895ca610cc2fb9bdf9abc05" + integrity sha512-vAVUiMixfB/oXIZ7N6QhJB1N+lb96JLrs2EjZiPGNSgwKGMV0H+84ZI+5NJ30qoytm7WB8mm2beezoCpM8frjg== -"@drop-oss/droplet-linux-arm64-musl@0.7.2": - version "0.7.2" - resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-musl/-/droplet-linux-arm64-musl-0.7.2.tgz#cabb0305e3337dc8fabe4d46d21756fe2d93bde6" - integrity sha512-uJ0oOjPNNsNrqc8kJhlOxetz+lYb1QUOIKyKjpmTKVHYjNXj8bvc/FSDYwQjCPRs0r9qrEszF8hW6lsibQ92/g== +"@drop-oss/droplet-linux-x64-gnu@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-gnu/-/droplet-linux-x64-gnu-1.3.1.tgz#5f59e8816978af444301ad7116a0377f9aa2633a" + integrity sha512-R3UtBIw5amY1ExaX8fZMcS1zLv0DF9Y8YoBgqk+VbQrHMVfiQKiktv/dXRp+9iWzLB/m5aG/Se5QuJazOMlwtA== -"@drop-oss/droplet-linux-riscv64-gnu@0.7.2": - version "0.7.2" - resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-riscv64-gnu/-/droplet-linux-riscv64-gnu-0.7.2.tgz#5ea5b99df8677def14da6099dec9577433736655" - integrity sha512-5xdbTvEs8MiOL3ren+QyCXvcLmKWa7NSAehdunaD82qIwV19Xz+/C7OC1jN2zGgAQ0TBM/HcbkmWITNEQB7Oiw== +"@drop-oss/droplet-linux-x64-musl@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-musl/-/droplet-linux-x64-musl-1.3.1.tgz#84b136665dc713c66a3e5b2be6ce5d97514dcb25" + integrity sha512-rDtmTYzx39Y1xHyRvm2AW97GkHy4ZfhXsmYWSjqo0dmoM5BY/nHfmNO6kWOABg4WP6mr3NPZKJTe885JVNilcg== -"@drop-oss/droplet-linux-x64-gnu@0.7.2": - version "0.7.2" - resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-gnu/-/droplet-linux-x64-gnu-0.7.2.tgz#244f137f6f301c307414e43b7cbd42fbd3e3247f" - integrity sha512-xM7tEzAR/yGFpO3C3lLpyOiqCD84MqwXQS6I1aR+z7IU+tAVwX1JYmu4HYGw1pxPCHpK/9w8NtAwzgSiw5d2jQ== +"@drop-oss/droplet-win32-arm64-msvc@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-arm64-msvc/-/droplet-win32-arm64-msvc-1.3.1.tgz#77948fe25f27dedda979367feadbe89622aa6b19" + integrity sha512-ZQVLgloMd7NIW3j1cvL7DKp9164K8luLxb692yuXRF6pQ7ok8IPWgwiWoeqQ1OE/msPkgXEC7hHupwWtfX6tHw== -"@drop-oss/droplet-linux-x64-musl@0.7.2": - version "0.7.2" - resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-musl/-/droplet-linux-x64-musl-0.7.2.tgz#37bc2d079cc63949bd5ac5194be65cb9c769feb0" - integrity sha512-s9YbnqPQhz468py49icPO74ezXF+EGKt7DX9vMs7XIp2Uyz+pWejRkerSj70WTypy5UcSNgcIBOB6kfD/FMMAQ== +"@drop-oss/droplet-win32-x64-msvc@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-x64-msvc/-/droplet-win32-x64-msvc-1.3.1.tgz#35f2dca041af48dec6743bf5c62a35582e25715d" + integrity sha512-NJsZM4g40I0b/MHFTvur3t30ULiU8D3DfhZTlLzyT+btiQ/8PdjCKRM4CPJJhs7JG8Bp30cl2n54XnsnyaFtJA== -"@drop-oss/droplet-win32-arm64-msvc@0.7.2": - version "0.7.2" - resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-arm64-msvc/-/droplet-win32-arm64-msvc-0.7.2.tgz#c0a6048b9dc89596bf230346c5bbe86fcdc27009" - integrity sha512-E0isKXZIt/mFUAfziZ9hat84uol4hWHcEZ86xxfz4L8/wljrKU7Vbw9yaYznk4FvKRHnwoccymtOTLrSq2Ju4Q== - -"@drop-oss/droplet-win32-x64-msvc@0.7.2": - version "0.7.2" - resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-x64-msvc/-/droplet-win32-x64-msvc-0.7.2.tgz#2835e05bcf9923eb23e04b94c298520ecb6299d0" - integrity sha512-O5t2B/3Ld+17q1qDPVds3V/Ex2as2l8piVBgEKIkEL51wJYu7ucwMwWrfdMWKXRn17Fl5ueeujZLuD3iySRkLw== - -"@drop-oss/droplet@^0.7.2": - version "0.7.2" - resolved "https://registry.yarnpkg.com/@drop-oss/droplet/-/droplet-0.7.2.tgz#a914dbee85cb3b3a0c9dd90d9cebec5bb0575bce" - integrity sha512-XxKUuRMYMdTVT4IaetNRN07iUpHJkXdS1LKfPBDrNkjszfG0SGjqCd1PVw7p6ugPWdezS8ygGODR6c/cAOQ4kw== +"@drop-oss/droplet@^1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@drop-oss/droplet/-/droplet-1.3.1.tgz#360faadadf50dbe3133ed8aadce6a077d289a48d" + integrity sha512-wXQof5rUiUujAiVwJovAu4Tj2Rhlb0pE/lHSEHF3ywcOLQFcSrE1naQVz3RQ7at+ZkwmDL9fFMXmGJkEKI8jog== optionalDependencies: - "@drop-oss/droplet-darwin-arm64" "0.7.2" - "@drop-oss/droplet-darwin-universal" "0.7.2" - "@drop-oss/droplet-darwin-x64" "0.7.2" - "@drop-oss/droplet-linux-arm-gnueabihf" "0.7.2" - "@drop-oss/droplet-linux-arm-musleabihf" "0.7.2" - "@drop-oss/droplet-linux-arm64-gnu" "0.7.2" - "@drop-oss/droplet-linux-arm64-musl" "0.7.2" - "@drop-oss/droplet-linux-riscv64-gnu" "0.7.2" - "@drop-oss/droplet-linux-x64-gnu" "0.7.2" - "@drop-oss/droplet-linux-x64-musl" "0.7.2" - "@drop-oss/droplet-win32-arm64-msvc" "0.7.2" - "@drop-oss/droplet-win32-x64-msvc" "0.7.2" + "@drop-oss/droplet-darwin-arm64" "1.3.1" + "@drop-oss/droplet-darwin-universal" "1.3.1" + "@drop-oss/droplet-darwin-x64" "1.3.1" + "@drop-oss/droplet-linux-arm64-gnu" "1.3.1" + "@drop-oss/droplet-linux-arm64-musl" "1.3.1" + "@drop-oss/droplet-linux-riscv64-gnu" "1.3.1" + "@drop-oss/droplet-linux-x64-gnu" "1.3.1" + "@drop-oss/droplet-linux-x64-musl" "1.3.1" + "@drop-oss/droplet-win32-arm64-msvc" "1.3.1" + "@drop-oss/droplet-win32-x64-msvc" "1.3.1" "@emnapi/core@^1.4.0": version "1.4.0" From 40e66def1e7d05bcb74efeb0155f507a58c639e8 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Sun, 1 Jun 2025 16:06:56 +1000 Subject: [PATCH 069/156] Multi-upload to image library #56 (#60) * feat: support for file upload handler to track multiple files * feat: update image upload endpoint to allow multiple files * fix: lint --- components/UploadFileDialog.vue | 32 +++++++++++++++----- pages/admin/metadata/games/[id]/index.vue | 1 + server/api/v1/admin/game/image/index.post.ts | 6 ++-- server/api/v1/admin/game/metadata.post.ts | 6 ++-- server/api/v1/admin/news/index.post.ts | 5 +-- server/internal/utils/handlefileupload.ts | 14 ++++----- 6 files changed, 42 insertions(+), 22 deletions(-) diff --git a/components/UploadFileDialog.vue b/components/UploadFileDialog.vue index b2b8b91..101f40c 100644 --- a/components/UploadFileDialog.vue +++ b/components/UploadFileDialog.vue @@ -51,15 +51,22 @@ class="transition mt-2 block text-sm font-semibold text-zinc-400 group-hover:text-zinc-500" >Upload file -

    - {{ currentFile.name }} -

    +
    +

    + {{ currentFile }} +

    +
    @@ -67,7 +74,7 @@
    ({ }); const file = ref(); -const currentFile = computed(() => file.value?.item(0)); +const currentFiles = computed(() => file.value); +const currentFileList = computed(() => { + if (!currentFiles.value) return undefined; + const list = []; + for (const file of currentFiles.value) { + list.push(file.name); + } + return list; +}); const props = defineProps<{ endpoint: string; accept: string; + multiple?: boolean; options?: { [key: string]: string }; }>(); const emit = defineEmits(["upload"]); @@ -134,10 +150,12 @@ const emit = defineEmits(["upload"]); const uploadLoading = ref(false); const uploadError = ref(); async function uploadFile() { - if (!currentFile.value) return; + if (!currentFiles.value) return; const form = new FormData(); - form.append("file", currentFile.value); + for (const file of currentFiles.value) { + form.append(file.name, file); + } if (props.options) { for (const [key, value] of Object.entries(props.options)) { diff --git a/pages/admin/metadata/games/[id]/index.vue b/pages/admin/metadata/games/[id]/index.vue index f773a3c..415fe7e 100644 --- a/pages/admin/metadata/games/[id]/index.vue +++ b/pages/admin/metadata/games/[id]/index.vue @@ -293,6 +293,7 @@ :options="{ id: game.id }" accept="image/*" endpoint="/api/v1/admin/game/image" + :multiple="true" @upload="(result: Game) => uploadAfterImageUpload(result)" /> diff --git a/server/api/v1/admin/game/image/index.post.ts b/server/api/v1/admin/game/image/index.post.ts index 7ff453d..e230e9f 100644 --- a/server/api/v1/admin/game/image/index.post.ts +++ b/server/api/v1/admin/game/image/index.post.ts @@ -20,8 +20,8 @@ export default defineEventHandler(async (h3) => { statusMessage: "Failed to upload file", }); - const [id, options, pull, dump] = uploadResult; - if (!id) { + const [ids, options, pull, dump] = uploadResult; + if (ids.length == 0) { dump(); throw createError({ statusCode: 400, @@ -48,7 +48,7 @@ export default defineEventHandler(async (h3) => { }, data: { mImageLibraryObjectIds: { - push: id, + push: ids, }, }, }); diff --git a/server/api/v1/admin/game/metadata.post.ts b/server/api/v1/admin/game/metadata.post.ts index df64c04..c64ef97 100644 --- a/server/api/v1/admin/game/metadata.post.ts +++ b/server/api/v1/admin/game/metadata.post.ts @@ -14,14 +14,16 @@ export default defineEventHandler(async (h3) => { statusMessage: "This endpoint requires multipart form data.", }); - const uploadResult = await handleFileUpload(h3, {}, ["internal:read"]); + const uploadResult = await handleFileUpload(h3, {}, ["internal:read"], 1); if (!uploadResult) throw createError({ statusCode: 400, statusMessage: "Failed to upload file", }); - const [id, options, pull, dump] = uploadResult; + const [ids, options, pull, dump] = uploadResult; + + const id = ids.at(0); // handleFileUpload reads the rest of the options for us. const name = options.name; diff --git a/server/api/v1/admin/news/index.post.ts b/server/api/v1/admin/news/index.post.ts index 5e3286e..abda24d 100644 --- a/server/api/v1/admin/news/index.post.ts +++ b/server/api/v1/admin/news/index.post.ts @@ -14,19 +14,20 @@ export default defineEventHandler(async (h3) => { statusMessage: "This endpoint requires multipart form data.", }); - const uploadResult = await handleFileUpload(h3, {}, ["internal:read"]); + const uploadResult = await handleFileUpload(h3, {}, ["internal:read"], 1); if (!uploadResult) throw createError({ statusCode: 400, statusMessage: "Failed to upload file", }); - const [imageId, options, pull, _dump] = uploadResult; + const [imageIds, options, pull, _dump] = uploadResult; const title = options.title; const description = options.description; const content = options.content; const tags = options.tags ? (JSON.parse(options.tags) as string[]) : []; + const imageId = imageIds.at(0); if (!title || !description || !content) throw createError({ diff --git a/server/internal/utils/handlefileupload.ts b/server/internal/utils/handlefileupload.ts index e5fe80c..4e36c44 100644 --- a/server/internal/utils/handlefileupload.ts +++ b/server/internal/utils/handlefileupload.ts @@ -6,23 +6,21 @@ export async function handleFileUpload( h3: H3Event, metadata: { [key: string]: string }, permissions: Array, -): Promise< - [string | undefined, { [key: string]: string }, Pull, Dump] | undefined -> { + max = -1, +): Promise<[string[], { [key: string]: string }, Pull, Dump] | undefined> { const formData = await readMultipartFormData(h3); if (!formData) return undefined; const transactionalHandler = new ObjectTransactionalHandler(); const [add, pull, dump] = transactionalHandler.new(metadata, permissions); const options: { [key: string]: string } = {}; - let id; + const ids = []; for (const entry of formData) { if (entry.filename) { - // Only pick one file - if (id) continue; + if (max > 0 && ids.length >= max) continue; // Add file to transaction handler so we can void it later if we error out - id = add(entry.data); + ids.push(add(entry.data)); continue; } if (!entry.name) continue; @@ -30,5 +28,5 @@ export async function handleFileUpload( options[entry.name] = entry.data.toString("utf-8"); } - return [id, options, pull, dump]; + return [ids, options, pull, dump]; } From 2056871dc995a5a8ba653e74980d1ac98842f720 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Sun, 1 Jun 2025 18:33:42 +1000 Subject: [PATCH 070/156] Add UI for multi-library management #59 (#63) * feat: add ui for library source management * fix: lint --- components/SourceOptions/Filesystem.vue | 25 ++ pages/admin/library/index.vue | 30 +- pages/admin/library/sources/index.vue | 379 ++++++++++++++++++ .../v1/admin/library/sources/index.delete.ts | 25 ++ .../api/v1/admin/library/sources/index.get.ts | 16 + .../v1/admin/library/sources/index.patch.ts | 67 ++++ .../v1/admin/library/sources/index.post.ts | 69 ++++ server/internal/acls/descriptions.ts | 5 + server/internal/acls/index.ts | 5 + server/internal/library/filesystem.ts | 4 +- server/internal/library/index.ts | 13 + server/plugins/05.library-init.ts | 2 +- 12 files changed, 625 insertions(+), 15 deletions(-) create mode 100644 components/SourceOptions/Filesystem.vue create mode 100644 pages/admin/library/sources/index.vue create mode 100644 server/api/v1/admin/library/sources/index.delete.ts create mode 100644 server/api/v1/admin/library/sources/index.get.ts create mode 100644 server/api/v1/admin/library/sources/index.patch.ts create mode 100644 server/api/v1/admin/library/sources/index.post.ts diff --git a/components/SourceOptions/Filesystem.vue b/components/SourceOptions/Filesystem.vue new file mode 100644 index 0000000..9448c06 --- /dev/null +++ b/components/SourceOptions/Filesystem.vue @@ -0,0 +1,25 @@ + + + diff --git a/pages/admin/library/index.vue b/pages/admin/library/index.vue index 2772c54..7481731 100644 --- a/pages/admin/library/index.vue +++ b/pages/admin/library/index.vue @@ -1,18 +1,22 @@ @@ -374,7 +354,7 @@ class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 transition-all duration-200 hover:scale-105 hover:shadow-lg active:scale-95 sm:mt-0 sm:w-auto" @click="showAddCarouselModal = false" > - Cancel + {{ $t("cancel") }} @@ -389,7 +369,7 @@ type="button" class="cursor-pointer relative inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" > - Upload + {{ $t("upload") }} Game Name{{ $t("library.admin.game.editGameName") }}
    Game Description{{ $t("library.admin.game.editGameDescription") }}
    - Save + {{ $t("save") }} @@ -463,12 +443,13 @@ import type { Game } from "~/prisma/client"; import { micromark } from "micromark"; import { - ArrowTopRightOnSquareIcon, CheckIcon, DocumentIcon, PencilIcon, PhotoIcon, } from "@heroicons/vue/24/solid"; +import type { SerializeObject } from "nitropack"; +import type { H3Error } from "h3"; definePageMeta({ layout: "admin", @@ -480,13 +461,16 @@ const showAddImageDescriptionModal = ref(false); const showEditCoreMetadata = ref(false); const mobileShowFinalDescription = ref(true); -const route = useRoute(); -const gameId = route.params.id.toString(); -const { game: rawGame, unimportedVersions } = await $dropFetch( - `/api/v1/admin/game?id=${encodeURIComponent(gameId)}`, -); -const game = ref(rawGame); +const game = defineModel>() as Ref>; +if (!game.value) + throw createError({ + statusCode: 500, + statusMessage: "Game not provided to editor component", + }); +const { t } = useI18n(); + +// I don't know why I split these fields off. const coreMetadataName = ref(game.value.mName); const coreMetadataDescription = ref(game.value.mShortDescription); const coreMetadataIconUrl = ref(useObject(game.value.mIconObjectId)); @@ -495,7 +479,6 @@ const coreMetadataLoading = ref(false); function coreMetadataUploadFiles(e: InputEvent) { if (coreMetadataIconUrl.value.startsWith("blob")) { - console.log("freed object URL"); URL.revokeObjectURL(coreMetadataIconUrl.value); } @@ -506,9 +489,9 @@ function coreMetadataUploadFiles(e: InputEvent) { createModal( ModalType.Notification, { - title: "Failed to upload file", - description: "Drop couldn't upload this file.", - buttonText: "Close", + title: t("errors.upload.title"), + description: t("errors.upload.description", [t("errors.unknown")]), + buttonText: t("close"), }, (e, c) => c(), ); @@ -543,11 +526,11 @@ function coreMetadataUpdate_wrapper() { createModal( ModalType.Notification, { - title: "Failed to update metadata", - description: `Drop failed to update the game's metadata: ${ - e?.statusMessage || "An unknown error occurred. " - }`, - buttonText: "Close", + title: t("errors.game.metadata.title"), + description: t("errors.game.metadata.description", [ + (e as H3Error)?.statusMessage ?? t("errors.unknown"), + ]), + buttonText: t("close"), }, (e, c) => c(), ); @@ -569,37 +552,42 @@ const descriptionEditor = ref(); // 0 is not loading // 1 is waiting for stop // 2 is loading -const descriptionSaving = ref(0); +enum DescriptionSavingState { + NotLoading, + Waiting, + Loading, +} +const descriptionSaving = ref( + DescriptionSavingState.NotLoading, +); let savingTimeout: undefined | NodeJS.Timeout; type PatchGameBody = Partial; watch(descriptionHTML, (_v) => { - console.log(game.value.mDescription); - descriptionSaving.value = 1; + descriptionSaving.value = DescriptionSavingState.Waiting; if (savingTimeout) clearTimeout(savingTimeout); savingTimeout = setTimeout(async () => { try { - descriptionSaving.value = 2; + descriptionSaving.value = DescriptionSavingState.Loading; await $dropFetch("/api/v1/admin/game", { method: "PATCH", body: { - id: gameId, + id: game.value.id, mDescription: game.value.mDescription, } satisfies PatchGameBody, }); - descriptionSaving.value = 0; + descriptionSaving.value = DescriptionSavingState.NotLoading; } catch (e) { createModal( ModalType.Notification, { - title: "Failed to update game description", - description: `Drop failed to update the game description: ${ - // @ts-expect-error attempt to get statusMessage on error - e?.statusMessage ?? t("errors.unknown") - }`, - buttonText: "Close", + title: t("errors.game.description.title"), + description: t("errors.game.description.description", [ + (e as H3Error)?.statusMessage ?? t("errors.unknown"), + ]), + buttonText: t("close"), }, (e, c) => c(), ); @@ -630,7 +618,7 @@ async function updateBannerImage(id: string) { const { mBannerObjectId } = await $dropFetch("/api/v1/admin/game", { method: "PATCH", body: { - id: gameId, + id: game.value.id, mBannerObjectId: id, } satisfies PatchGameBody, }); @@ -639,12 +627,11 @@ async function updateBannerImage(id: string) { createModal( ModalType.Notification, { - title: "There an error while updating the banner image", - description: `Drop encountered an error while updating the banner image: ${ - // @ts-expect-error attempt to get statusMessage on error - e?.statusMessage ?? t("errors.unknown") - }`, - buttonText: "Close", + title: t("errors.game.banner.title"), + description: t("errors.game.banner.description", [ + (e as H3Error)?.statusMessage ?? t("errors.unknown"), + ]), + buttonText: t("close"), }, (e, c) => c(), ); @@ -657,22 +644,20 @@ async function updateCoverImage(id: string) { const { mCoverObjectId } = await $dropFetch("/api/v1/admin/game", { method: "PATCH", body: { - id: gameId, + id: game.value.id, mCoverObjectId: id, } satisfies PatchGameBody, }); game.value.mCoverObjectId = mCoverObjectId; - coreMetadataIconUrl.value = useObject(mCoverObjectId); } catch (e) { createModal( ModalType.Notification, { - title: "There an error while updating the cover image", - description: `Drop encountered an error while updating the cover image: ${ - // @ts-expect-error attempt to get statusMessage on error - e?.statusMessage ?? t("errors.unknown") - }`, - buttonText: "Close", + title: t("errors.game.cover.title"), + description: t("errors.game.cover.description", [ + (e as H3Error)?.statusMessage ?? t("errors.unknown"), + ]), + buttonText: t("close"), }, (e, c) => c(), ); @@ -697,12 +682,11 @@ async function deleteImage(id: string) { createModal( ModalType.Notification, { - title: "There an error while deleting the image", - description: `Drop encountered an error while deleting the image: ${ - // @ts-expect-error attempt to get statusMessage on error - e?.statusMessage ?? t("errors.unknown") - }`, - buttonText: "Close", + title: t("errors.game.deleteImage.title"), + description: t("errors.game.deleteImage.description", [ + (e as H3Error)?.statusMessage ?? t("errors.unknown"), + ]), + buttonText: t("close"), }, (e, c) => c(), ); @@ -732,7 +716,7 @@ async function updateImageCarousel() { await $dropFetch("/api/v1/admin/game", { method: "PATCH", body: { - id: gameId, + id: game.value.id, mImageCarouselObjectIds: game.value.mImageCarouselObjectIds, } satisfies PatchGameBody, }); @@ -740,12 +724,11 @@ async function updateImageCarousel() { createModal( ModalType.Notification, { - title: "There an error while updating the image carousel", - description: `Drop encountered an error while updating image carousel: ${ - // @ts-expect-error attempt to get statusMessage on error - e?.statusMessage ?? t("errors.unknown") - }`, - buttonText: "Close", + title: t("errors.game.carousel.title"), + description: t("errors.game.carousel.description", [ + (e as H3Error)?.statusMessage ?? t("errors.unknown"), + ]), + buttonText: t("close"), }, (e, c) => c(), ); diff --git a/components/GameEditor/Version.vue b/components/GameEditor/Version.vue new file mode 100644 index 0000000..9828914 --- /dev/null +++ b/components/GameEditor/Version.vue @@ -0,0 +1,177 @@ + + + + diff --git a/i18n/locales/en_us.json b/i18n/locales/en_us.json index a7eafc9..cb513a4 100644 --- a/i18n/locales/en_us.json +++ b/i18n/locales/en_us.json @@ -25,6 +25,8 @@ "actions": "Actions", "adminTitle": "Admin Dashboard | Drop", "adminTitleTemplate": "{0} | Admin | Drop", + "title": "Drop", + "titleTemplate": "{0} | Drop", "auth": { "callback": { "authClient": "Authorize client?", @@ -79,7 +81,9 @@ "noResults": "No results", "servers": "Servers", "tags": "Tags", - "today": "Today" + "today": "Today", + "divider": "{'|'}", + "srLoading": "Loading..." }, "create": "Create", "delete": "Delete", @@ -149,6 +153,36 @@ "desc": "Drop encountered an error while updating the version: {error}", "title": "There an error while updating the version order" } + }, + "upload": { + "title": "Failed to upload file", + "description": "Drop couldn't upload the file: {0}" + }, + "game": { + "metadata": { + "title": "Failed to update metadata", + "description": "Drop failed to update the game's metadata: {0}" + }, + "description": { + "title": "Failed to update game description", + "description": "Drop failed to update the game description: {0}" + }, + "banner": { + "title": "Failed to update the banner image", + "description": "Drop failed to update the banner image: {0}" + }, + "cover": { + "title": "Failed to update the cover image", + "description": "Drop failed to update the cover image: {0}" + }, + "deleteImage": { + "title": "Failed to delete the image", + "description": "Drop failed to delete the image: {0}" + }, + "carousel": { + "title": "Failed to update image carousel", + "description": "Drop failed to update the image carousel: {0}" + } } }, "footer": { @@ -174,7 +208,6 @@ "header": { "admin": { "admin": "Admin", - "meta": "Meta", "tasks": "Tasks", "users": "Users" }, @@ -183,6 +216,79 @@ }, "highest": "highest", "home": "Home", + "users": { + "admin": { + "description": "Manage the users on your Drop instance, and configure your authentication methods.", + "authLink": "Authentication {arrow}", + + "displayNameHeader": "Display Name", + "usernameHeader": "Username", + "emailHeader": "Email", + "adminHeader": "Admin?", + "authoptionsHeader": "Auth Options", + "srEditLabel": "Edit", + + "adminUserLabel": "Admin user", + "normalUserLabel": "Normal user", + + "authentication": { + "title": "Authentication", + "description": "Drop supports a variety of \"authentication mechanisms\". As you enable or disable them, they are shown on the sign in screen for users to select from. Click the dot menu to configure the authentication mechanism.", + "enabledKey": "Enabled?", + "enabled": "Enabled", + "disabled": "Disabled", + "srOpenOptions": "Open options", + "configure": "Configure", + + "simple": "Simple (username/password)", + "oidc": "OpenID Connect" + }, + + "simple": { + "title": "Simple authentication", + "description": "Simple authentication uses a system of 'invitations' to create users. You can create an invitation, and optionally specify a username or email for the user, and then it will generate a magic URL that can be used to create an account.", + + "invitationTitle": "invitations", + "createInvitation": "Create invitation", + + "noUsernameEnforced": "No username enforced.", + "noEmailEnforced": "No email enforced.", + + "adminInvitation": "Admin invitation", + "userInvitation": "User invitation", + + "expires": "Expires: {expiry}", + "neverExpires": "Never expires.", + + "noInvitations": "No invitations.", + + "inviteTitle": "Invite user to Drop", + "inviteDescription": "Drop will generate a URL that you can send to the person you want to invite. You can optionally specify a username or email for them to use.", + + "inviteUsernameLabel": "Username (optional)", + "inviteUsernameFormat": "Must be 5 or more characters", + "inviteUsernamePlaceholder": "myUsername", + + "inviteEmailLabel": "Email address (optional)", + "inviteEmailDescription": "Must be in the format user{'@'}example.com", + "inviteEmailPlaceholder": "me{'@'}example.com", + + "inviteAdminSwitchLabel": "Admin invitation", + "inviteAdminSwitchDescription": "Create this user as an administrator", + + "inviteExpiryLabel": "Expires", + + "inviteButton": "Invite", + + "invite3Days": "3 days", + "inviteWeek": "1 week", + "inviteMonth": "1 month", + "invite6Months": "6 months", + "inviteYear": "1 year", + "inviteNever": "Never" + } + } + }, "library": { "addGames": "All Games", "addToLib": "Add to Library", @@ -228,13 +334,36 @@ }, "metadataProvider": "Metadata provider", "noGames": "No games imported", - "noVersions": "You have no versions of this game available.", - "noVersionsAdded": "no versions added", - "openInMetadata": "Open in Metadata", - "openLibrary": "Open with Library {arrow}", - "openMetadata": "Open with Metadata {arrow}", + "openEditor": "Open in Editor {arrow}", "openStore": "Open in Store", "shortDesc": "Short Description", + "version": { + "noVersions": "You have no versions of this game available.", + "noVersionsAdded": "no versions added", + "delta": "Upgrade mode" + }, + "game": { + "imageCarousel": "Image Carousel", + "imageCarouselDescription": "Customise what images and what order are shown on the store page.", + "addImageCarousel": "Add from image library", + "imageCarouselEmpty": "No images added to the carousel yet.", + "removeImageCarousel": "Remove image", + "addCarouselNoImages": "No images to add.", + + "imageLibrary": "Image library", + "imageLibraryDescription": "Please note all images uploaded are accessible to all users through browser dev-tools.", + "setBanner": "Set as banner", + "setCover": "Set as cover", + "deleteImage": "Delete image", + + "currentBanner": "banner", + "currentCover": "cover", + + "addDescriptionNoImages": "No images to add.", + + "editGameName": "Game Name", + "editGameDescription": "Game Description" + }, "sources": { "create": "Create source", "createDesc": "Drop will use this source to access your game library, and make them available.", @@ -310,6 +439,8 @@ }, "options": "Options", "save": "Save", + "add": "Add", + "insert": "Insert", "security": "Security", "settings": "Settings", "store": { @@ -347,5 +478,10 @@ "settings": "Account settings" } }, + "task": { + "successful": "Successful!", + "successfulDescription": "\"{0}\" completed successfully" + }, + "todo": "Todo", "welcome": "American, Welcome!" } diff --git a/layouts/admin.vue b/layouts/admin.vue index 6c7ba76..04bb205 100644 --- a/layouts/admin.vue +++ b/layouts/admin.vue @@ -162,7 +162,6 @@ import { ServerStackIcon, HomeIcon, Cog6ToothIcon, - DocumentIcon, UserGroupIcon, RectangleStackIcon, } from "@heroicons/vue/24/outline"; @@ -181,12 +180,6 @@ const navigation: Array = [ prefix: "/admin/library", icon: ServerStackIcon, }, - { - label: t("header.admin.meta"), - route: "/admin/metadata", - prefix: "/admin/metadata", - icon: DocumentIcon, - }, { label: t("header.admin.users"), route: "/admin/users", diff --git a/layouts/default.vue b/layouts/default.vue index 11303ea..40b9307 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -15,14 +15,15 @@ const route = useRoute(); const noWrapper = !!route.query.noWrapper; +const { t } = useI18n(); + useHead({ htmlAttrs: { lang: "en", }, link: [], titleTemplate(title) { - if (title) return `${title} | Drop`; - return `Drop`; + return title ? t("titleTemplate", [title]) : t("title"); }, }); diff --git a/package.json b/package.json index cea0cb4..abf97f8 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,8 @@ "autoprefixer": "^10.4.20", "eslint": "^9.24.0", "eslint-config-prettier": "^10.1.1", - "h3": "^1.15.1", + "h3": "^1.15.3", + "nitropack": "^2.11.12", "ofetch": "^1.4.1", "postcss": "^8.4.47", "prettier": "^3.5.3", diff --git a/pages/account/index.vue b/pages/account/index.vue index 2358092..38070ff 100644 --- a/pages/account/index.vue +++ b/pages/account/index.vue @@ -1,4 +1,8 @@ diff --git a/pages/admin/index.vue b/pages/admin/index.vue index 60dac71..feb6cd0 100644 --- a/pages/admin/index.vue +++ b/pages/admin/index.vue @@ -1,170 +1,6 @@ - + diff --git a/pages/admin/library/[id]/index.vue b/pages/admin/library/[id]/index.vue index 6e94827..4dd18a1 100644 --- a/pages/admin/library/[id]/index.vue +++ b/pages/admin/library/[id]/index.vue @@ -1,163 +1,132 @@ - diff --git a/pages/admin/library/index.vue b/pages/admin/library/index.vue index 82e8a29..2bf3346 100644 --- a/pages/admin/library/index.vue +++ b/pages/admin/library/index.vue @@ -98,19 +98,13 @@
    - - - - - - + @@ -169,7 +163,7 @@

    - {{ $t("library.admin.noVersions") }} + {{ $t("library.admin.version.noVersions") }}

    diff --git a/pages/admin/metadata/games/index.vue b/pages/admin/metadata/games/index.vue deleted file mode 100644 index db54e43..0000000 --- a/pages/admin/metadata/games/index.vue +++ /dev/null @@ -1,122 +0,0 @@ - - - diff --git a/pages/admin/metadata/index.vue b/pages/admin/metadata/index.vue deleted file mode 100644 index 1465bda..0000000 --- a/pages/admin/metadata/index.vue +++ /dev/null @@ -1,60 +0,0 @@ - - - diff --git a/pages/admin/settings/index.vue b/pages/admin/settings/index.vue index 3a7c59e..8366f2f 100644 --- a/pages/admin/settings/index.vue +++ b/pages/admin/settings/index.vue @@ -1,5 +1,5 @@ diff --git a/prisma/migrations/20250606013242_add_tasks_to_database/migration.sql b/prisma/migrations/20250606013242_add_tasks_to_database/migration.sql new file mode 100644 index 0000000..e3aa449 --- /dev/null +++ b/prisma/migrations/20250606013242_add_tasks_to_database/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "Task" ( + "id" TEXT NOT NULL, + "taskGroup" TEXT NOT NULL, + "started" TIMESTAMP(3) NOT NULL, + "ended" TIMESTAMP(3) NOT NULL, + "success" BOOLEAN NOT NULL, + "error" JSONB, + "progress" DOUBLE PRECISION NOT NULL, + "log" TEXT[], + "acls" TEXT[], + + CONSTRAINT "Task_pkey" PRIMARY KEY ("id") +); diff --git a/prisma/migrations/20250606023802_add_name_to_task/migration.sql b/prisma/migrations/20250606023802_add_name_to_task/migration.sql new file mode 100644 index 0000000..db4f781 --- /dev/null +++ b/prisma/migrations/20250606023802_add_name_to_task/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `name` to the `Task` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Task" ADD COLUMN "name" TEXT NOT NULL; diff --git a/prisma/models/task.prisma b/prisma/models/task.prisma new file mode 100644 index 0000000..6d3aed1 --- /dev/null +++ b/prisma/models/task.prisma @@ -0,0 +1,15 @@ +model Task { + id String @id + taskGroup String + name String + + started DateTime + ended DateTime + + success Boolean + error Json? + progress Float + log String[] + + acls String[] +} diff --git a/server/api/v1/admin/task/index.get.ts b/server/api/v1/admin/task/index.get.ts new file mode 100644 index 0000000..b9b7bdd --- /dev/null +++ b/server/api/v1/admin/task/index.get.ts @@ -0,0 +1,35 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; +import taskHandler from "~/server/internal/tasks"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["task:read"]); + if (!allowed) throw createError({ statusCode: 403 }); + const allAcls = await aclManager.fetchAllACLs(h3); + if (!allAcls) + throw createError({ + statusCode: 403, + statusMessage: "Somehow no ACLs on authenticated request.", + }); + + const runningTasks = (await taskHandler.runningTasks()).map((e) => e.id); + const historicalTasks = await prisma.task.findMany({ + where: { + OR: [ + { + acls: { hasSome: allAcls }, + }, + { + acls: { isEmpty: true }, + }, + ], + }, + orderBy: { + ended: "desc", + }, + take: 10, + }); + const dailyTasks = await taskHandler.dailyTasks(); + + return { runningTasks, historicalTasks, dailyTasks }; +}); diff --git a/server/internal/acls/descriptions.ts b/server/internal/acls/descriptions.ts index 80c1b92..762a355 100644 --- a/server/internal/acls/descriptions.ts +++ b/server/internal/acls/descriptions.ts @@ -80,4 +80,10 @@ export const systemACLDescriptions: ObjectFromList = { "news:read": "Read news articles.", "news:create": "Create a new news article.", "news:delete": "Delete a news article.", + + "task:read": "Read all tasks currently running on server.", + "task:start": "Manually execute scheduled tasks.", + + "maintenance:read": + "Read tasks and maintenance information, like updates available and cleanup.", }; diff --git a/server/internal/acls/index.ts b/server/internal/acls/index.ts index 9f96b5b..f37451c 100644 --- a/server/internal/acls/index.ts +++ b/server/internal/acls/index.ts @@ -74,6 +74,11 @@ export const systemACLs = [ "news:read", "news:create", "news:delete", + + "task:read", + "task:start", + + "maintenance:read", ] as const; const systemACLPrefix = "system:"; diff --git a/server/internal/tasks/index.ts b/server/internal/tasks/index.ts index 61d26a6..599f9eb 100644 --- a/server/internal/tasks/index.ts +++ b/server/internal/tasks/index.ts @@ -1,5 +1,6 @@ import droplet from "@drop-oss/droplet"; import type { MinimumRequestObject } from "~/server/h3"; +import type { GlobalACL } from "../acls"; import aclManager from "../acls"; import cleanupInvites from "./registry/invitations"; @@ -7,6 +8,7 @@ import cleanupSessions from "./registry/sessions"; import checkUpdate from "./registry/update"; import cleanupObjects from "./registry/objects"; import { taskGroups, type TaskGroup } from "./group"; +import prisma from "../db/database"; // a task that has been run type FinishedTask = { @@ -36,15 +38,19 @@ type TaskPoolEntry = FinishedTask & { */ class TaskHandler { // registry of schedualed tasks to be created - private scheduledTasks: Map Task> = new Map(); - // list of all finished tasks - private finishedTasks: Map = new Map(); + private taskCreators: Map Task> = new Map(); // list of all currently running tasks private taskPool = new Map(); // list of all clients currently connected to tasks private clientRegistry = new Map(); + private scheduledTasks: TaskGroup[] = [ + "cleanup:invitations", + "cleanup:sessions", + "check:update", + ]; + constructor() { // register the cleanup invitations task this.saveScheduledTask(cleanupInvites); @@ -58,7 +64,7 @@ class TaskHandler { * @param createTask */ private saveScheduledTask(task: DropTask) { - this.scheduledTasks.set(task.taskGroup, task.build); + this.taskCreators.set(task.taskGroup, task.build); } create(task: Task) { @@ -129,7 +135,7 @@ class TaskHandler { const taskEntry = this.taskPool.get(task.id); if (!taskEntry) return; taskEntry.log.push(entry); - console.log(`[Task ${task.taskGroup}]: ${entry}`); + // console.log(`[Task ${task.taskGroup}]: ${entry}`); updateAllClients(); }; @@ -171,10 +177,25 @@ class TaskHandler { this.disconnect(clientId, task.id); } - // so we can drop the clients from the task entry - const { clients, ...copied } = taskEntry; - this.finishedTasks.set(task.id, { - ...copied, + await prisma.task.create({ + data: { + id: task.id, + taskGroup: taskEntry.taskGroup, + name: taskEntry.name, + + started: taskEntry.startTime, + ended: taskEntry.endTime, + + success: taskEntry.success, + progress: taskEntry.progress, + log: taskEntry.log, + + acls: taskEntry.acls, + + ...(taskEntry.error + ? { error: JSON.stringify(taskEntry.error) } + : undefined), + }, }); this.taskPool.delete(task.id); @@ -187,7 +208,9 @@ class TaskHandler { peer: PeerImpl, request: MinimumRequestObject, ) { - const task = this.taskPool.get(taskId); + const task = + this.taskPool.get(taskId) ?? + (await prisma.task.findUnique({ where: { id: taskId } })); if (!task) { peer.send( `error/${taskId}/Unknown task/Drop couldn't find the task you're looking for.`, @@ -205,13 +228,17 @@ class TaskHandler { } this.clientRegistry.set(clientId, peer); - task.clients.set(clientId, true); // Uniquely insert client to avoid sending duplicate traffic + if ("clients" in task) { + task.clients.set(clientId, true); // Uniquely insert client to avoid sending duplicate traffic + } const catchupMessage: TaskMessage = { id: taskId, name: task.name, success: task.success, - error: task.error, + error: task.error as unknown as + | { title: string; description: string } + | undefined, log: task.log, progress: task.progress, }; @@ -253,8 +280,19 @@ class TaskHandler { return true; } + runningTasks() { + return this.taskPool + .entries() + .map(([id, value]) => ({ ...value, id, log: undefined })) + .toArray(); + } + + dailyTasks() { + return this.scheduledTasks; + } + runTaskGroupByName(name: TaskGroup) { - const task = this.scheduledTasks.get(name); + const task = this.taskCreators.get(name); if (!task) { console.warn(`No task found for group ${name}`); return; @@ -262,13 +300,30 @@ class TaskHandler { this.create(task()); } - /**] + /** * Runs all daily tasks that are scheduled to run once a day. */ - triggerDailyTasks() { - this.runTaskGroupByName("cleanup:invitations"); - this.runTaskGroupByName("cleanup:sessions"); - this.runTaskGroupByName("check:update"); + async triggerDailyTasks() { + for (const taskGroup of this.scheduledTasks) { + const mostRecent = await prisma.task.findFirst({ + where: { + taskGroup, + }, + orderBy: { + ended: "desc", + }, + }); + if (mostRecent) { + const currentTime = Date.now(); + const lastRun = mostRecent.ended.getTime(); + const difference = currentTime - lastRun; + if (difference < 1000 * 60 * 60 * 24) { + // If it's been less than one day + continue; // skip + } + } + await this.runTaskGroupByName(taskGroup); + } } } @@ -282,7 +337,7 @@ export interface Task { taskGroup: TaskGroup; name: string; run: (context: TaskRunContext) => Promise; - acls: string[]; + acls: GlobalACL[]; } export type TaskMessage = { @@ -304,7 +359,7 @@ export interface BuildTask { taskGroup: TaskGroup; name: string; run: (context: TaskRunContext) => Promise; - acls: string[]; + acls: GlobalACL[]; } interface DropTask { diff --git a/server/internal/tasks/registry/invitations.ts b/server/internal/tasks/registry/invitations.ts index 4d809ac..7cac166 100644 --- a/server/internal/tasks/registry/invitations.ts +++ b/server/internal/tasks/registry/invitations.ts @@ -4,7 +4,7 @@ import { defineDropTask } from ".."; export default defineDropTask({ buildId: () => `cleanup:invitations:${new Date().toISOString()}`, name: "Cleanup Invitations", - acls: [], + acls: ["system:maintenance:read"], taskGroup: "cleanup:invitations", async run({ log }) { log("Cleaning invitations"); diff --git a/server/internal/tasks/registry/objects.ts b/server/internal/tasks/registry/objects.ts index 3d90170..a59e409 100644 --- a/server/internal/tasks/registry/objects.ts +++ b/server/internal/tasks/registry/objects.ts @@ -13,7 +13,7 @@ type FieldReferenceMap = { export default defineDropTask({ buildId: () => `cleanup:objects:${new Date().toISOString()}`, name: "Cleanup Objects", - acls: [], + acls: ["system:maintenance:read"], taskGroup: "cleanup:objects", async run({ progress, log }) { log("Cleaning unreferenced objects"); diff --git a/server/internal/tasks/registry/sessions.ts b/server/internal/tasks/registry/sessions.ts index 17b7f80..99372c2 100644 --- a/server/internal/tasks/registry/sessions.ts +++ b/server/internal/tasks/registry/sessions.ts @@ -4,7 +4,7 @@ import { defineDropTask } from ".."; export default defineDropTask({ buildId: () => `cleanup:sessions:${new Date().toISOString()}`, name: "Cleanup Sessions", - acls: [], + acls: ["system:maintenance:read"], taskGroup: "cleanup:sessions", async run({ log }) { log("Cleaning up sessions"); diff --git a/server/internal/tasks/registry/update.ts b/server/internal/tasks/registry/update.ts index 814c4e8..39d4134 100644 --- a/server/internal/tasks/registry/update.ts +++ b/server/internal/tasks/registry/update.ts @@ -19,7 +19,7 @@ const latestRelease = type({ export default defineDropTask({ buildId: () => `check:update:${new Date().toISOString()}`, name: "Check for Update", - acls: [], + acls: ["system:maintenance:read"], taskGroup: "check:update", async run({ log }) { // 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? diff --git a/server/tasks/dailyTasks.ts b/server/tasks/dailyTasks.ts index 3088e49..e36735c 100644 --- a/server/tasks/dailyTasks.ts +++ b/server/tasks/dailyTasks.ts @@ -5,7 +5,7 @@ export default defineTask({ name: "dailyTasks", }, async run() { - taskHandler.triggerDailyTasks(); + await taskHandler.triggerDailyTasks(); return {}; }, From ca8ad37adf59d3ed19a39f65474370f8ddd1c247 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Sat, 7 Jun 2025 16:27:43 +1000 Subject: [PATCH 081/156] Fix unauthenticated /api/v1/client/chunk route Critical fix, no issue or PR --- server/api/v1/client/chunk.get.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/api/v1/client/chunk.get.ts b/server/api/v1/client/chunk.get.ts index 4f70e05..0766496 100644 --- a/server/api/v1/client/chunk.get.ts +++ b/server/api/v1/client/chunk.get.ts @@ -1,4 +1,5 @@ import cacheHandler from "~/server/internal/cache"; +import { defineClientEventHandler } from "~/server/internal/clients/event-handler"; import prisma from "~/server/internal/db/database"; import libraryManager from "~/server/internal/library"; @@ -9,7 +10,7 @@ const gameLookupCache = cacheHandler.createCache<{ libraryPath: string; }>("downloadGameLookupCache"); -export default defineEventHandler(async (h3) => { +export default defineClientEventHandler(async (h3) => { const query = getQuery(h3); const gameId = query.id?.toString(); const versionName = query.version?.toString(); From ed90ae277518b28543427db02a7de27e0d58e3fd Mon Sep 17 00:00:00 2001 From: DecDuck Date: Sat, 7 Jun 2025 17:21:07 +1000 Subject: [PATCH 082/156] Fix: Broken footer links #96 (#98) * fix: footer links * fix: lint --- components/UserFooter.vue | 10 +++++----- i18n/locales/en_us.json | 4 +--- nuxt.config.ts | 2 +- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/components/UserFooter.vue b/components/UserFooter.vue index 1fb1fff..87a5fd6 100644 --- a/components/UserFooter.vue +++ b/components/UserFooter.vue @@ -105,20 +105,20 @@ const navigation = { { name: t("common.servers"), href: "#" }, ], documentation: [ - { name: t("footer.api"), href: "https://api.droposs.org/" }, + // TODO: public API docs + // { name: t("footer.api"), href: "https://api.droposs.org/" }, { name: t("footer.docs.server"), - href: "https://wiki.droposs.org/guides/quickstart.html", + href: "https://docs.droposs.org/docs/guides/quickstart", }, { name: t("footer.docs.client"), - href: "https://wiki.droposs.org/guides/client.html", + href: "https://docs.droposs.org/docs/guides/client", }, ], about: [ { name: t("footer.aboutDrop"), href: "https://droposs.org/" }, - { name: t("footer.features"), href: "https://droposs.org/features" }, - { name: t("footer.faq"), href: "https://droposs.org/faq" }, + { name: t("footer.comparison"), href: "https://droposs.org/comparison" }, ], social: [ { diff --git a/i18n/locales/en_us.json b/i18n/locales/en_us.json index a67470f..0abe06c 100644 --- a/i18n/locales/en_us.json +++ b/i18n/locales/en_us.json @@ -188,14 +188,12 @@ "footer": { "about": "About", "aboutDrop": "About Drop", - "api": "API", "docs": { "client": "Client Docs", "server": "Server Docs" }, "documentation": "Documentation", - "faq": "FAQ", - "features": "Features", + "comparison": "Comparison", "findGame": "Find a Game", "footer": "Footer", "games": "Games", diff --git a/nuxt.config.ts b/nuxt.config.ts index fd1df57..c89265f 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -15,8 +15,8 @@ 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", From a199393e298c7d22df2ffc18bc3eb7238d468658 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Sat, 7 Jun 2025 17:26:09 +1000 Subject: [PATCH 083/156] Fix: Sign out functionality #95 (#99) * fix: signout button on user widget * fix: add comment explaining --- components/UserHeader/UserWidget.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/components/UserHeader/UserWidget.vue b/components/UserHeader/UserWidget.vue index dc8d517..59d2daf 100644 --- a/components/UserHeader/UserWidget.vue +++ b/components/UserHeader/UserWidget.vue @@ -64,6 +64,8 @@ active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400', 'w-full text-left transition block px-4 py-2 text-sm', ]" + :data-comment="'external=true is required because we implemented the signout as a route on the server for performance'" + :external="true" @click="close" > {{ $t("auth.signout") }} From 0e023534a76c2ef0e00db696f3d9836fbf0f2d8c Mon Sep 17 00:00:00 2001 From: DecDuck Date: Sun, 8 Jun 2025 10:33:41 +1000 Subject: [PATCH 084/156] Redesign of language selector #100 (#101) * feat: add new language selector in footer, add pirate language * fix: translations in title bar not updating * chore: refactor into separate component * fix: update translate url * fix: update pirate translation to use "ship" instead of "plank" for platform a very very necessary change --- components/LanguageSelector.vue | 142 ++++++++ components/LibraryDirectory.vue | 2 +- components/UserFooter.vue | 4 + components/UserHeader.vue | 11 +- components/UserHeader/SelectLang.vue | 78 ----- i18n/locales/en_pirate.json | 481 ++++++++++++++++++++++++++- i18n/locales/en_us.json | 2 + pages/store/index.vue | 4 - 8 files changed, 633 insertions(+), 91 deletions(-) create mode 100644 components/LanguageSelector.vue delete mode 100644 components/UserHeader/SelectLang.vue diff --git a/components/LanguageSelector.vue b/components/LanguageSelector.vue new file mode 100644 index 0000000..106439b --- /dev/null +++ b/components/LanguageSelector.vue @@ -0,0 +1,142 @@ + + + diff --git a/components/LibraryDirectory.vue b/components/LibraryDirectory.vue index 1a677ef..40ddf8c 100644 --- a/components/LibraryDirectory.vue +++ b/components/LibraryDirectory.vue @@ -13,7 +13,7 @@ name="search" autocomplete="off" class="block w-full rounded-md bg-zinc-900 py-2 pl-9 pr-2 text-sm text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600" - placeholder="$t('library.search')" + :placeholder="$t('library.search')" /> {{ $t("drop.desc") }}

    + + +
    import { IconsDiscordLogo, IconsGithubLogo } from "#components"; + const { t } = useI18n(); const navigation = { diff --git a/components/UserHeader.vue b/components/UserHeader.vue index 1e13cdd..9f60cb6 100644 --- a/components/UserHeader.vue +++ b/components/UserHeader.vue @@ -54,7 +54,6 @@
  • - @@ -176,9 +175,7 @@
  • - - - +
  • @@ -209,7 +206,7 @@ const router = useRouter(); const { t } = useI18n(); const homepageURL = "/store"; -const navigation: Array = [ +const navigation: Ref> = computed(() => [ { prefix: "/store", route: "/store", @@ -230,9 +227,9 @@ const navigation: Array = [ route: "/news", label: t("userHeader.links.news"), }, -]; +]); -const currentPageIndex = useCurrentNavigationIndex(navigation); +const currentPageIndex = useCurrentNavigationIndex(navigation.value); const notifications = useNotifications(); const unreadNotifications = computed(() => diff --git a/components/UserHeader/SelectLang.vue b/components/UserHeader/SelectLang.vue deleted file mode 100644 index b379341..0000000 --- a/components/UserHeader/SelectLang.vue +++ /dev/null @@ -1,78 +0,0 @@ - - - - diff --git a/i18n/locales/en_pirate.json b/i18n/locales/en_pirate.json index 2018cf9..f0fe74c 100644 --- a/i18n/locales/en_pirate.json +++ b/i18n/locales/en_pirate.json @@ -1,3 +1,482 @@ { - "welcome": "Arr matey, Welcome!" + "account": { + "devices": { + "capabilities": "Aye, yer Capabilities", + "lastConnected": "Last Linked", + "noDevices": "No contraptions tied to yer coffers, eh?", + "platform": "Ship", + "revoke": "Scuttle 'em", + "subheader": "Manage the contraptions allowed access to yer Drop booty.", + "title": "Contraptions" + }, + "notifications": { + "all": "Gaze upon all {arrow}", + "desc": "View and manage yer messages from the crows' nest.", + "markAllAsRead": "Mark all as read, aye!", + "markAsRead": "Mark as read, matey!", + "none": "No messages, savvy?", + "notifications": "Crows' Nest", + "title": "Messages from the Crows' Nest", + "unread": "Unread Messages" + }, + "settings": "Account Settings, savvy?", + "title": "Yer Own Coffer" + }, + "actions": "Deeds", + "adminTitle": "Cap'n's Quarters | Drop", + "adminTitleTemplate": "{0} | Cap'n | Drop", + "title": "Drop", + "titleTemplate": "{0} | Drop", + "auth": { + "callback": { + "authClient": "Grant passage to this scallywag?", + "authorize": "Grant Passage", + "authorizedClient": "Drop has granted passage to the scallywag. Ye may now shut this porthole.", + "issues": "Troubles be brewin', matey?", + "learn": "Learn more {arrow}", + "paste": "Scribble this code into the scallywag to carry on:", + "permWarning": "Grantin' this request allows \"{name}\" on \"{platform}\" to:", + "requestedAccess": "\"{name}\" has requested passage to yer Drop coffer.", + "success": "Shiver me timbers, it worked!" + }, + "confirmPassword": "Confirm @:auth.password", + "displayName": "Yer Scallywag Name", + "email": "Salty Mail", + "password": "Secret Word", + "register": { + "confirmPasswordFormat": "Must be the same as above, savvy?", + "emailFormat": "Must be in the fashion of a true scallywag {'@'} example.com", + "passwordFormat": "Must be 14 or more marks, ye landlubber!", + "subheader": "Fill in yer details below to make yer mark.", + "title": "Forge yer Drop Mark", + "usernameFormat": "Must be 5 or more marks, and all lowercase, argh!" + }, + "signin": { + "externalProvider": "Sign in with another ship's captain {arrow}", + "forgot": "Forgot yer secret word, eh?", + "noAccount": "No mark in the logbook? Beg a cap'n to make ye one, argh!", + "or": "OR", + "pageTitle": "Sign in to Drop, ye dog!", + "rememberMe": "Remember me, savvy?", + "signin": "Sign in, ye scurvy dog!", + "title": "Sign in to yer mark" + }, + "signout": "Cast off!", + "username": "Scallywag Name" + }, + "cancel": "Belay that!", + "chars": { + "arrow": "→", + "arrowBack": "←", + "quoted": "\"\"", + "srComma": ", {0}" + }, + "close": "Shut yer trap!", + "common": { + "cannotUndo": "This deed cannot be undone, ye hear!", + "date": "Date", + "deleteConfirm": "Are ye sure ye want to scuttle \"{0}\", ye rogue?", + "friends": "Shipmates", + "groups": "Crews", + "noResults": "No plunder found!", + "servers": "Ships", + "tags": "Marks", + "today": "Today", + "divider": "{'|'}", + "srLoading": "Loading, loading, argh..." + }, + "create": "Forge!", + "delete": "Scuttle!", + "drop": { + "desc": "An open-source game distribution platform built for speed, flexibility and beauty, like a swift brigantine!", + "drop": "Drop" + }, + "edit": "Amend", + "editor": { + "bold": "Bold, like a cannonball!", + "boldPlaceholder": "bold text, matey", + "code": "Code, ye scallywag!", + "codePlaceholder": "code, argh!", + "heading": "Heading, to the horizon!", + "headingPlaceholder": "heading, savvy?", + "italic": "Italic, like a wobbly deck!", + "italicPlaceholder": "italic text, arrr!", + "link": "Link, a chain to adventure!", + "linkPlaceholder": "link text, ye dog!", + "listItem": "List Item, for yer plunder!", + "listItemPlaceholder": "list item, eh?" + }, + "errors": { + "backHome": "{arrow} Back to yer safe harbor", + "invalidBody": "Invalid request, ye barnacle-encrusted body: {0}", + "inviteRequired": "Invitation demanded to sign up, ye landlubber.", + "library": { + "add": { + "desc": "Drop couldn't add this game to yer treasure hoard: {0}", + "title": "Failed to add game to yer treasure hoard" + }, + "collection": { + "create": { + "desc": "Drop couldn't forge yer collection, argh: {0}", + "title": "Failed to forge collection" + } + }, + "source": { + "delete": { + "desc": "Drop couldn't scuttle this source: {0}", + "title": "Failed to scuttle treasure hoard source" + } + } + }, + "news": { + "article": { + "delete": { + "desc": "Drop couldn't scuttle this article: {0}", + "title": "Failed to scuttle article" + } + } + }, + "occurred": "An error occurred whilst answerin' yer plea. If ye believe this be a bug, report it, ye dog! Try signin' in and see if it clears the decks.", + "ohNo": "Blimey!", + "pageTitle": "{0} | Drop", + "revokeClient": "Failed to scuttle scallywag", + "revokeClientFull": "Failed to scuttle scallywag {0}", + "signIn": "Sign in {arrow}, ye scurvy dog!", + "support": "Support Discord, arrr!", + "unknown": "An unknown blunder occurred, savvy?", + "version": { + "delete": { + "desc": "Drop met a squall whilst scuttlin' the version: {error}", + "title": "There was a squall whilst scuttlin' the version" + }, + "order": { + "desc": "Drop met a squall whilst updatin' the version: {error}", + "title": "There was a squall whilst updatin' the version order" + } + }, + "upload": { + "title": "Failed to hoist the file", + "description": "Drop couldn't hoist the file: {0}" + }, + "game": { + "metadata": { + "title": "Failed to update yer charts", + "description": "Drop failed to update the game's charts: {0}" + }, + "description": { + "title": "Failed to update game description", + "description": "Drop failed to update the game description: {0}" + }, + "banner": { + "title": "Failed to hoist the banner image", + "description": "Drop failed to hoist the banner image: {0}" + }, + "cover": { + "title": "Failed to hoist the cover image", + "description": "Drop failed to hoist the cover image: {0}" + }, + "deleteImage": { + "title": "Failed to scuttle the image", + "description": "Drop failed to scuttle the image: {0}" + }, + "carousel": { + "title": "Failed to update image carousel", + "description": "Drop failed to update the image carousel: {0}" + } + } + }, + "footer": { + "about": "About, savvy?", + "aboutDrop": "About Drop, argh!", + "docs": { + "client": "Scallywag's Docs", + "server": "Cap'n's Docs" + }, + "documentation": "Charts and Scrolls", + "comparison": "Comparison, matey!", + "findGame": "Find a Game, ye dog!", + "footer": "Keel", + "games": "Games", + "social": { + "discord": "Discord, argh!", + "github": "GitHub, savvy?" + }, + "topSellers": "Top Plunderers" + }, + "header": { + "admin": { + "admin": "Cap'n", + "tasks": "Duties", + "users": "Crew" + }, + "back": "Aft!", + "openSidebar": "Open the side-hatch!" + }, + "highest": "highest", + "home": "Home Port", + "users": { + "admin": { + "description": "Manage the crew on yer Drop vessel, and set yer passage methods, savvy?", + "authLink": "Passage {arrow}", + "displayNameHeader": "Scallywag Name", + "usernameHeader": "Crew Name", + "emailHeader": "Salty Mail", + "adminHeader": "Cap'n, eh?", + "authoptionsHeader": "Passage Options", + "srEditLabel": "Amend", + "adminUserLabel": "Cap'n of the crew", + "normalUserLabel": "Common crewman", + "authentication": { + "title": "Passage", + "description": "Drop backs many 'passage ways'. As ye enable or disable 'em, they show on the sign-in screen for the crew to pick. Click the dot menu to rig the passage way.", + "enabledKey": "Enabled, argh?", + "enabled": "Enabled", + "disabled": "Disabled", + "srOpenOptions": "Open options", + "configure": "Rig", + "simple": "Simple (crew name/secret word)", + "oidc": "OpenID Connect, savvy?" + }, + "simple": { + "title": "Simple passage", + "description": "Simple passage uses a system of 'invitations' to create crew. Ye can forge an invitation, and optionally name a crew name or salty mail for the crew, then it'll make a magic scroll that can be used to make a mark.", + "invitationTitle": "Invitations", + "createInvitation": "Forge Invitation", + "noUsernameEnforced": "No crew name forced, argh.", + "noEmailEnforced": "No salty mail forced, matey.", + "adminInvitation": "Cap'n's Invitation", + "userInvitation": "Crewman's Invitation", + "expires": "Expires: {expiry}", + "neverExpires": "Never expires, savvy.", + "noInvitations": "No invitations, argh.", + "inviteTitle": "Invite crew to Drop", + "inviteDescription": "Drop will make a scroll ye can send to the scallywag ye want to invite. Ye can optionally name a crew name or salty mail for them to use.", + "inviteUsernameLabel": "Crew Name (optional)", + "inviteUsernameFormat": "Must be 5 or more marks", + "inviteUsernamePlaceholder": "myScallywagName", + "inviteEmailLabel": "Salty mail address (optional)", + "inviteEmailDescription": "Must be in the fashion of a scallywag {'@'} example.com", + "inviteEmailPlaceholder": "me{'@'}example.com", + "inviteAdminSwitchLabel": "Cap'n's invitation", + "inviteAdminSwitchDescription": "Make this crewman a cap'n, argh!", + "inviteExpiryLabel": "Expires", + "inviteButton": "Invite, ye dog!", + "invite3Days": "3 suns", + "inviteWeek": "1 week", + "inviteMonth": "1 moon", + "invite6Months": "6 moons", + "inviteYear": "1 year", + "inviteNever": "Never" + } + } + }, + "library": { + "addGames": "All Plunder", + "addToLib": "Add to Yer Treasure Hoard", + "admin": { + "detectedGame": "Drop has found new plunder to import, argh!", + "detectedVersion": "Drop has found new versions of this plunder to import, savvy!", + "gameLibrary": "Game Treasure Hoard", + "import": { + "import": "Import, ye dog!", + "link": "Import {arrow}", + "loading": "Loadin' plunder results, arrr...", + "search": "Search", + "searchPlaceholder": "Fallout 4, savvy?", + "selectDir": "Pick a directory, ye landlubber...", + "selectGame": "Pick plunder to import", + "selectGamePlaceholder": "Pick a game, ye dog...", + "selectGameSearch": "Pick game", + "selectPlatform": "Pick a ship, ye scallywag...", + "version": { + "advancedOptions": "Advanced options, savvy?", + "import": "Import version", + "installDir": "(install_dir)/", + "launchCmd": "Launch executable/command, argh!", + "launchDesc": "Executable to launch the game, matey!", + "launchPlaceholder": "game.exe, aye!", + "loadingVersion": "Loading version charts...", + "noAdv": "No advanced options for this rig, argh.", + "noVersions": "No versions to import, savvy!", + "platform": "Ship type", + "setupCmd": "Setup executable/command", + "setupDesc": "Ran once when the game is installed, ye hear!", + "setupMode": "Setup mode, savvy?", + "setupModeDesc": "When enabled, this version has no launch command, and merely runs the executable on the crew's computer. Useful for games that only give installers and not portable files, argh!", + "setupPlaceholder": "setup.exe, aye!", + "umuLauncherId": "UMU Launcher ID", + "umuOverride": "Override UMU Launcher Game ID", + "umuOverrideDesc": "By default, Drop uses a non-ID when launchin' with UMU Launcher. To get the right patches for some games, ye might have to set this field by hand, savvy.", + "updateMode": "Update mode, argh!", + "updateModeDesc": "When enabled, these files will be installed atop (overwritin') the previous version's. If many 'update modes' be chained together, they be applied in order, ye hear!", + "version": "Pick version to import" + }, + "withoutMetadata": "Import without charts" + }, + "metadataProvider": "Charts Provider", + "noGames": "No plunder imported, savvy!", + "openEditor": "Open in Editor {arrow}", + "openStore": "Open in Store, argh!", + "shortDesc": "Short Description", + "version": { + "noVersions": "Ye have no versions of this plunder available, ye dog!", + "noVersionsAdded": "no versions added, argh!", + "delta": "Upgrade mode" + }, + "game": { + "imageCarousel": "Image Carousel", + "imageCarouselDescription": "Customize what images and what order be shown on the store page, savvy.", + "addImageCarousel": "Add from image treasure hoard", + "imageCarouselEmpty": "No images added to the carousel yet, argh.", + "removeImageCarousel": "Remove image", + "addCarouselNoImages": "No images to add, ye dog.", + "imageLibrary": "Image treasure hoard", + "imageLibraryDescription": "Please note all images hoisted be accessible to all crew through browser dev-tools, savvy.", + "setBanner": "Set as banner", + "setCover": "Set as cover", + "deleteImage": "Scuttle image", + "currentBanner": "banner", + "currentCover": "cover", + "addDescriptionNoImages": "No images to add, argh.", + "editGameName": "Plunder Name", + "editGameDescription": "Plunder Description" + }, + "sources": { + "create": "Forge source", + "createDesc": "Drop will use this source to get to yer game treasure hoard, and make 'em available, argh.", + "desc": "Rig yer treasure hoard sources, where Drop will look for new plunder and versions to import, savvy.", + "fsDesc": "Imports games from a path on disk. Needs version-based folder structure, and backs archived games, ye hear!", + "fsPath": "Path", + "fsPathDesc": "An absolute path to yer game treasure hoard.", + "fsPathPlaceholder": "/mnt/games, aye!", + "link": "Sources {arrow}", + "nameDesc": "The name of yer source, for yer own reckonin', argh.", + "namePlaceholder": "My New Source, savvy?", + "sources": "Treasure Hoard Sources", + "typeDesc": "The type of yer source. Changes the demanded options, ye dog!", + "working": "Workin', eh?" + }, + "subheader": "As ye add folders to yer treasure hoard sources, Drop will find 'em and ask ye to import 'em. Each game needs to be imported before ye can import a version, savvy.", + "title": "Treasure Hoards", + "versionPriority": "Version priority" + }, + "back": "Aft to Treasure Hoard", + "collection": { + "addToNew": "Add to new collection", + "collections": "Collections", + "create": "Forge Collection", + "createDesc": "Collections can be used to sort yer plunder and find 'em easier, especially if ye have a grand treasure hoard, argh!", + "delete": "Scuttle Collection", + "namePlaceholder": "Collection name, matey!", + "noCollections": "No collections, savvy!", + "notFound": "Collection not found, argh!", + "subheader": "Add a new collection to sort yer plunder", + "title": "Collection" + }, + "gameCount": "{0} plunder | {0} plunder | {0} plunder", + "inLib": "In Treasure Hoard", + "launcherOpen": "Open in Launcher, argh!", + "noGames": "No plunder in treasure hoard, savvy!", + "notFound": "Plunder not found, matey!", + "search": "Search treasure hoard, ye dog...", + "subheader": "Sort yer plunder into collections for easy access, and get to all yer plunder, savvy!" + }, + "tasks": { + "admin": { + "scheduled": { + "cleanupInvitationsName": "Clean up invitations", + "cleanupInvitationsDescription": "Cleans up expired invitations from the logbook to save space, savvy.", + "cleanupObjectsName": "Clean up objects", + "cleanupObjectsDescription": "Finds and scuttles unreferenced and unused objects to save space, argh.", + "cleanupSessionsName": "Clean up sessions.", + "cleanupSessionsDescription": "Cleans up expired sessions to save space and keep ye safe, ye dog!", + "checkUpdateName": "Check for new charts.", + "checkUpdateDescription": "Check if Drop has new charts." + }, + "runningTasksTitle": "Duties underway", + "noTasksRunning": "No duties currently underway", + "completedTasksTitle": "Duties completed", + "dailyScheduledTitle": "Daily scheduled duties", + "viewTask": "View {arrow}", + "back": "{arrow} Aft to Duties" + } + }, + "lowest": "lowest", + "name": "Name, argh!", + "news": { + "article": { + "add": "Add, ye dog!", + "content": "Content (Markdown), savvy!", + "create": "Forge New Article", + "editor": "Editor", + "editorGuide": "Use the quick ways above or scribble Markdown directly. Backs **bold**, *italic*, [links](url), and more, argh!", + "new": "New article, savvy!", + "preview": "Preview, matey!", + "shortDesc": "Short description", + "submit": "Submit, ye scurvy dog!", + "tagPlaceholder": "Add a mark, ye dog...", + "titles": "Title, argh!", + "uploadCover": "Hoist cover image" + }, + "back": "Aft to News", + "checkLater": "Check back later for new charts, matey!", + "delete": "Scuttle Article", + "filter": { + "all": "All time, savvy!", + "month": "This moon", + "week": "This week", + "year": "This year, argh!" + }, + "none": "No articles, savvy!", + "notFound": "Article not found, matey!", + "search": "Search articles, ye dog!", + "searchPlaceholder": "Search articles, argh...", + "subheader": "Stay up to date with the latest charts and announcements, savvy!", + "title": "Latest News from the High Seas" + }, + "options": "Options, matey!", + "save": "Stow it!", + "add": "Add", + "insert": "Insert", + "security": "Safety", + "settings": "Settings", + "store": { + "commingSoon": "comin' soon, argh!", + "exploreMore": "Explore more {arrow}, ye dog!", + "images": "Plunder Images", + "lookAt": "Look at it, ye scurvy dog!", + "noGame": "no plunder", + "noImages": "No images, savvy!", + "openAdminDashboard": "Open in Cap'n's Quarters", + "platform": "Ship | Ship | Ships", + "rating": "Rating, argh!", + "readLess": "Click to read less, matey!", + "readMore": "Click to read more, ye dog!", + "recentlyAdded": "Recently Added Plunder", + "recentlyReleased": "Recently set sail", + "recentlyUpdated": "Recently Updated", + "released": "Released, argh!", + "reviews": "({0} Sea Tales)", + "title": "Store", + "view": "View in Store" + }, + "type": "Type", + "upload": "Hoist!", + "uploadFile": "Hoist file", + "userHeader": { + "closeSidebar": "Close side-hatch!", + "links": { + "community": "Shipmates", + "library": "Treasure Hoard", + "news": "News from the High Seas" + }, + "profile": { + "admin": "Cap'n's Quarters", + "settings": "Account settings, savvy!" + } + }, + "todo": "Todo, argh!", + "selectLanguage": "Pick yer tongue", + "helpUsTranslate": "Help us translate Drop {arrow}, argh!", + "welcome": "Ahoy, Welcome!" } diff --git a/i18n/locales/en_us.json b/i18n/locales/en_us.json index 0abe06c..dbdfd55 100644 --- a/i18n/locales/en_us.json +++ b/i18n/locales/en_us.json @@ -502,5 +502,7 @@ } }, "todo": "Todo", + "selectLanguage": "Select language", + "helpUsTranslate": "Help us translate Drop {arrow}", "welcome": "American, Welcome!" } diff --git a/pages/store/index.vue b/pages/store/index.vue index ca78389..16c2faa 100644 --- a/pages/store/index.vue +++ b/pages/store/index.vue @@ -1,9 +1,5 @@ diff --git a/pages/news/[id]/index.vue b/pages/news/[id]/index.vue index 37559ce..7ae3919 100644 --- a/pages/news/[id]/index.vue +++ b/pages/news/[id]/index.vue @@ -1,12 +1,16 @@ @@ -335,7 +339,7 @@ class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" @click="() => insertImageAtCursor(image)" > - {{ $t("insert") }} + {{ $t("common.insert") }} @@ -424,7 +428,7 @@ :class="['inline-flex w-full shadow-sm sm:ml-3 sm:w-auto']" @click="() => coreMetadataUpdate_wrapper()" > - {{ $t("save") }} + {{ $t("common.save") }} @@ -49,8 +49,11 @@ import type { NotificationModel } from "~/prisma/client/models"; const props = defineProps<{ notification: NotificationModel }>(); async function deleteMe() { - await $dropFetch(`/api/v1/notifications/${props.notification.id}`, { + await $dropFetch(`/api/v1/notifications/:id`, { method: "DELETE", + params: { + id: props.notification.id, + }, }); const notifications = useNotifications(); const indexOfMe = notifications.value.findIndex( diff --git a/components/StoreView.vue b/components/StoreView.vue new file mode 100644 index 0000000..97e91fb --- /dev/null +++ b/components/StoreView.vue @@ -0,0 +1,488 @@ + + + diff --git a/components/UserFooter.vue b/components/UserFooter.vue index b62cb77..3060021 100644 --- a/components/UserFooter.vue +++ b/components/UserFooter.vue @@ -116,7 +116,7 @@ const { t } = useI18n(); const versionInfo = await $dropFetch("/api/v1"); -const navigation = { +const navigation = computed(() => ({ games: [ { name: t("store.recentlyAdded"), href: "#" }, { name: t("store.recentlyReleased"), href: "#" }, @@ -156,5 +156,5 @@ const navigation = { icon: IconsDiscordLogo, }, ], -}; +})); diff --git a/composables/request.ts b/composables/request.ts index af42dfc..963de6d 100644 --- a/composables/request.ts +++ b/composables/request.ts @@ -4,6 +4,7 @@ import type { NitroFetchRequest, TypedInternalResponse, } from "nitropack/types"; +import type { FetchError } from "ofetch"; interface DropFetch< DefaultT = unknown, @@ -15,7 +16,7 @@ interface DropFetch< O extends NitroFetchOptions = NitroFetchOptions, >( request: R, - opts?: O, + opts?: O & { failTitle?: string }, ): Promise< // sometimes there is an error, other times there isn't // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -28,12 +29,29 @@ interface DropFetch< >; } -export const $dropFetch: DropFetch = async (request, opts) => { +export const $dropFetch: DropFetch = async (rawRequest, opts) => { + const requestParts = rawRequest.toString().split("/"); + requestParts.forEach((part, index) => { + if (!part.startsWith(":")) { + return; + } + const partName = part.slice(1); + const replacement = opts?.params?.[partName] as string | undefined; + if (!replacement) { + return; + } + requestParts[index] = replacement; + + delete opts?.params?.[partName]; + }); + const request = requestParts.join("/"); + if (!getCurrentInstance()?.proxy) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore Excessive stack depth comparing types return await $fetch(request, opts); } + const id = request.toString(); const state = useState(id); @@ -41,15 +59,31 @@ export const $dropFetch: DropFetch = async (request, opts) => { // Deep copy const object = JSON.parse(JSON.stringify(state.value)); // Never use again on client - state.value = undefined; + if (import.meta.client) state.value = undefined; return object; } const headers = useRequestHeaders(["cookie", "authorization"]); - const data = await $fetch(request, { - ...opts, - headers: { ...opts?.headers, ...headers }, - }); - if (import.meta.server) state.value = data; - return data; + try { + const data = await $fetch(request, { + ...opts, + headers: { ...opts?.headers, ...headers }, + }); + if (import.meta.server) state.value = data; + return data; + } catch (e) { + if (import.meta.client && opts?.failTitle) { + createModal( + ModalType.Notification, + { + title: opts.failTitle, + description: + (e as FetchError)?.statusMessage ?? (e as string).toString(), + buttonText: $t("common.close"), + }, + (_, c) => c(), + ); + } + throw e; + } }; diff --git a/composables/store.ts b/composables/store.ts new file mode 100644 index 0000000..8b29867 --- /dev/null +++ b/composables/store.ts @@ -0,0 +1,11 @@ +export type StoreFilterOption = { + name: string; + param: string; + options: Array; + multiple?: boolean; +}; + +export type StoreSortOption = { + name: string; + param: string; +}; diff --git a/drop-base b/drop-base index a14d1b7..04125e8 160000 --- a/drop-base +++ b/drop-base @@ -1 +1 @@ -Subproject commit a14d1b7081cf2e6aa5174e3cfd7b7fe6904ab7bf +Subproject commit 04125e89bef517411e103cdabcfa64a1bb563423 diff --git a/eslint.config.mjs b/eslint.config.mjs index b4c5e3c..0576c43 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -18,6 +18,7 @@ export default withNuxt([ extensions: [".js", ".vue", ".ts"], }, ], + "@intlify/vue-i18n/no-missing-keys": "error", }, settings: { "vue-i18n": { diff --git a/i18n/locales/en_pirate.json b/i18n/locales/en_pirate.json index 7857762..36001de 100644 --- a/i18n/locales/en_pirate.json +++ b/i18n/locales/en_pirate.json @@ -24,8 +24,8 @@ }, "actions": "Deeds", "add": "Add", - "adminTitle": "Cap'n's Quarters | Drop", - "adminTitleTemplate": "{0} | Cap'n | Drop", + "adminTitle": "Cap'n's Quarters - Drop", + "adminTitleTemplate": "{0} - Cap'n - Drop", "auth": { "callback": { "authClient": "Grant passage to this scallywag?", @@ -70,27 +70,30 @@ "quoted": "\"\"", "srComma": ", {0}" }, - "close": "Shut yer trap!", "common": { "cannotUndo": "This deed cannot be undone, ye hear!", + "close": "Shut yer trap!", + "create": "Forge!", "date": "Date", "deleteConfirm": "Are ye sure ye want to scuttle \"{0}\", ye rogue?", "divider": "{'|'}", + "edit": "Amend", "friends": "Shipmates", "groups": "Crews", + "insert": "Insert", + "name": "Name, argh!", "noResults": "No plunder found!", + "save": "Stow it!", "servers": "Ships", "srLoading": "Loading, loading, argh...", "tags": "Marks", "today": "Today" }, - "create": "Forge!", "delete": "Scuttle!", "drop": { "desc": "An open-source game distribution platform built for speed, flexibility and beauty, like a swift brigantine!", "drop": "Drop" }, - "edit": "Amend", "editor": { "bold": "Bold, like a cannonball!", "boldPlaceholder": "bold text, matey", @@ -214,7 +217,6 @@ "helpUsTranslate": "Help us translate Drop {arrow}, argh!", "highest": "highest", "home": "Home Port", - "insert": "Insert", "library": { "addGames": "All Plunder", "addToLib": "Add to Yer Treasure Hoard", @@ -327,7 +329,6 @@ "subheader": "Sort yer plunder into collections for easy access, and get to all yer plunder, savvy!" }, "lowest": "lowest", - "name": "Name, argh!", "news": { "article": { "add": "Add, ye dog!", @@ -360,7 +361,6 @@ "title": "Latest News from the High Seas" }, "options": "Options, matey!", - "save": "Stow it!", "security": "Safety", "selectLanguage": "Pick yer tongue", "settings": "Settings", diff --git a/i18n/locales/en_us.json b/i18n/locales/en_us.json index 0df185c..67408f9 100644 --- a/i18n/locales/en_us.json +++ b/i18n/locales/en_us.json @@ -23,10 +23,9 @@ "title": "Account Settings" }, "actions": "Actions", - "adminTitle": "Admin Dashboard | Drop", - "adminTitleTemplate": "{0} | Admin | Drop", - "title": "Drop", - "titleTemplate": "{0} | Drop", + "add": "Add", + "adminTitle": "Admin Dashboard - Drop", + "adminTitleTemplate": "{0} - Admin - Drop", "auth": { "callback": { "authClient": "Authorize client?", @@ -71,27 +70,34 @@ "quoted": "\"\"", "srComma": ", {0}" }, - "close": "Close", "common": { "cannotUndo": "This action cannot be undone.", + "close": "Close", + "create": "Create", "date": "Date", "deleteConfirm": "Are you sure you want to delete \"{0}\"?", + "divider": "{'|'}", + "edit": "Edit", "friends": "Friends", "groups": "Groups", + "insert": "Insert", + "name": "Name", "noResults": "No results", + "noSelected": "No items selected.", + "remove": "Remove", + "save": "Save", + "saved": "Saved", "servers": "Servers", + "srLoading": "Loading...", "tags": "Tags", "today": "Today", - "divider": "{'|'}", - "srLoading": "Loading..." + "add": "Add" }, - "create": "Create", "delete": "Delete", "drop": { "desc": "An open-source game distribution platform built for speed, flexibility and beauty.", "drop": "Drop" }, - "edit": "Edit", "editor": { "bold": "Bold", "boldPlaceholder": "bold text", @@ -107,17 +113,6 @@ "listItemPlaceholder": "list item" }, "errors": { - "auth": { - "method": { - "signinDisabled": "Sign in method not enabled" - }, - "invalidUserOrPass": "Invalid username or password.", - "disabled": "Invalid or disabled account. Please contact the server administrator.", - "invalidPassState": "Invalid password state. Please contact the server administrator.", - "inviteIdRequired": "id required in fetching invitation", - "invalidInvite": "Invalid or expired invitation", - "usernameTaken": "Username already taken." - }, "admin": { "user": { "delete": { @@ -126,7 +121,44 @@ } } }, + "auth": { + "disabled": "Invalid or disabled account. Please contact the server administrator.", + "invalidInvite": "Invalid or expired invitation", + "invalidPassState": "Invalid password state. Please contact the server administrator.", + "invalidUserOrPass": "Invalid username or password.", + "inviteIdRequired": "id required in fetching invitation", + "method": { + "signinDisabled": "Sign in method not enabled" + }, + "usernameTaken": "Username already taken." + }, "backHome": "{arrow} Back to home", + "game": { + "banner": { + "description": "Drop failed to update the banner image: {0}", + "title": "Failed to update the banner image" + }, + "carousel": { + "description": "Drop failed to update the image carousel: {0}", + "title": "Failed to update image carousel" + }, + "cover": { + "description": "Drop failed to update the cover image: {0}", + "title": "Failed to update the cover image" + }, + "deleteImage": { + "description": "Drop failed to delete the image: {0}", + "title": "Failed to delete the image" + }, + "description": { + "description": "Drop failed to update the game description: {0}", + "title": "Failed to update game description" + }, + "metadata": { + "description": "Drop failed to update the game's metadata: {0}", + "title": "Failed to update metadata" + } + }, "invalidBody": "Invalid request body: {0}", "inviteRequired": "Invitation required to sign up.", "library": { @@ -163,6 +195,10 @@ "signIn": "Sign in {arrow}", "support": "Support Discord", "unknown": "An unknown error occurred", + "upload": { + "description": "Drop couldn't upload the file: {0}", + "title": "Failed to upload file" + }, "version": { "delete": { "desc": "Drop encountered an error while deleting the version: {error}", @@ -172,47 +208,17 @@ "desc": "Drop encountered an error while updating the version: {error}", "title": "There an error while updating the version order" } - }, - "upload": { - "title": "Failed to upload file", - "description": "Drop couldn't upload the file: {0}" - }, - "game": { - "metadata": { - "title": "Failed to update metadata", - "description": "Drop failed to update the game's metadata: {0}" - }, - "description": { - "title": "Failed to update game description", - "description": "Drop failed to update the game description: {0}" - }, - "banner": { - "title": "Failed to update the banner image", - "description": "Drop failed to update the banner image: {0}" - }, - "cover": { - "title": "Failed to update the cover image", - "description": "Drop failed to update the cover image: {0}" - }, - "deleteImage": { - "title": "Failed to delete the image", - "description": "Drop failed to delete the image: {0}" - }, - "carousel": { - "title": "Failed to update image carousel", - "description": "Drop failed to update the image carousel: {0}" - } } }, "footer": { "about": "About", "aboutDrop": "About Drop", + "comparison": "Comparison", "docs": { "client": "Client Docs", "server": "Server Docs" }, "documentation": "Documentation", - "comparison": "Comparison", "findGame": "Find a Game", "footer": "Footer", "games": "Games", @@ -226,81 +232,43 @@ "header": { "admin": { "admin": "Admin", + "metadata": "Meta", + "settings": "Settings", "tasks": "Tasks", - "users": "Users", - "settings": "Settings" + "users": "Users" }, "back": "Back", "openSidebar": "Open sidebar" }, + "helpUsTranslate": "Help us translate Drop {arrow}", "highest": "highest", "home": "Home", - "users": { - "admin": { - "description": "Manage the users on your Drop instance, and configure your authentication methods.", - "authLink": "Authentication {arrow}", - "displayNameHeader": "Display Name", - "usernameHeader": "Username", - "emailHeader": "Email", - "adminHeader": "Admin?", - "authoptionsHeader": "Auth Options", - "srEditLabel": "Edit", - "adminUserLabel": "Admin user", - "normalUserLabel": "Normal user", - - "delete": "Delete", - "deleteUser": "Delete user {0}", - - "authentication": { - "title": "Authentication", - "description": "Drop supports a variety of \"authentication mechanisms\". As you enable or disable them, they are shown on the sign in screen for users to select from. Click the dot menu to configure the authentication mechanism.", - "enabledKey": "Enabled?", - "enabled": "Enabled", - "disabled": "Disabled", - "srOpenOptions": "Open options", - "configure": "Configure", - "simple": "Simple (username/password)", - "oidc": "OpenID Connect" - }, - "simple": { - "title": "Simple authentication", - "description": "Simple authentication uses a system of 'invitations' to create users. You can create an invitation, and optionally specify a username or email for the user, and then it will generate a magic URL that can be used to create an account.", - "invitationTitle": "invitations", - "createInvitation": "Create invitation", - "noUsernameEnforced": "No username enforced.", - "noEmailEnforced": "No email enforced.", - "adminInvitation": "Admin invitation", - "userInvitation": "User invitation", - "expires": "Expires: {expiry}", - "neverExpires": "Never expires.", - "noInvitations": "No invitations.", - "inviteTitle": "Invite user to Drop", - "inviteDescription": "Drop will generate a URL that you can send to the person you want to invite. You can optionally specify a username or email for them to use.", - "inviteUsernameLabel": "Username (optional)", - "inviteUsernameFormat": "Must be 5 or more characters", - "inviteUsernamePlaceholder": "myUsername", - "inviteEmailLabel": "Email address (optional)", - "inviteEmailDescription": "Must be in the format user{'@'}example.com", - "inviteEmailPlaceholder": "me{'@'}example.com", - "inviteAdminSwitchLabel": "Admin invitation", - "inviteAdminSwitchDescription": "Create this user as an administrator", - "inviteExpiryLabel": "Expires", - "inviteButton": "Invite", - "invite3Days": "3 days", - "inviteWeek": "1 week", - "inviteMonth": "1 month", - "invite6Months": "6 months", - "inviteYear": "1 year", - "inviteNever": "Never" - } - } - }, "library": { "addGames": "All Games", "addToLib": "Add to Library", "admin": { "detectedGame": "Drop has detected you have new games to import.", "detectedVersion": "Drop has detected you have new verions of this game to import.", + "offlineTitle": "Game offline", + "offline": "Drop couldn't access this game.", + "game": { + "addCarouselNoImages": "No images to add.", + "addDescriptionNoImages": "No images to add.", + "addImageCarousel": "Add from image library", + "currentBanner": "banner", + "currentCover": "cover", + "deleteImage": "Delete image", + "editGameDescription": "Game Description", + "editGameName": "Game Name", + "imageCarousel": "Image Carousel", + "imageCarouselDescription": "Customise what images and what order are shown on the store page.", + "imageCarouselEmpty": "No images added to the carousel yet.", + "imageLibrary": "Image library", + "imageLibraryDescription": "Please note all images uploaded are accessible to all users through browser dev-tools.", + "removeImageCarousel": "Remove image", + "setBanner": "Set as banner", + "setCover": "Set as cover" + }, "gameLibrary": "Game Library", "import": { "import": "Import", @@ -313,6 +281,8 @@ "selectGamePlaceholder": "Please select a game...", "selectGameSearch": "Select game", "selectPlatform": "Please select a platform...", + "bulkImportTitle": "Bulk import mode", + "bulkImportDescription": "When on, this page won't redirect you to the import task, so you can import multiple games in succession.", "version": { "advancedOptions": "Advanced options", "import": "Import version", @@ -343,38 +313,16 @@ "openEditor": "Open in Editor {arrow}", "openStore": "Open in Store", "shortDesc": "Short Description", - "version": { - "noVersions": "You have no versions of this game available.", - "noVersionsAdded": "no versions added", - "delta": "Upgrade mode" - }, - "game": { - "imageCarousel": "Image Carousel", - "imageCarouselDescription": "Customise what images and what order are shown on the store page.", - "addImageCarousel": "Add from image library", - "imageCarouselEmpty": "No images added to the carousel yet.", - "removeImageCarousel": "Remove image", - "addCarouselNoImages": "No images to add.", - "imageLibrary": "Image library", - "imageLibraryDescription": "Please note all images uploaded are accessible to all users through browser dev-tools.", - "setBanner": "Set as banner", - "setCover": "Set as cover", - "deleteImage": "Delete image", - "currentBanner": "banner", - "currentCover": "cover", - "addDescriptionNoImages": "No images to add.", - "editGameName": "Game Name", - "editGameDescription": "Game Description" - }, "sources": { "create": "Create source", + "edit": "Edit source", "createDesc": "Drop will use this source to access your game library, and make them available.", "desc": "Configure your library sources, where Drop will look for new games and versions to import.", "fsDesc": "Imports games from a path on disk. Requires version-based folder structure, and supports archived games.", + "fsFlatDesc": "Imports games from a path on disk, but without a separate version subfolder. Useful when migrating an existing library to Drop.", "fsPath": "Path", "fsPathDesc": "An absolute path to your game library.", "fsPathPlaceholder": "/mnt/games", - "fsFlatDesc": "Imports games from a path on disk, but without a separate version subfolder. Useful when migrating an existing library to Drop.", "link": "Sources {arrow}", "nameDesc": "The name of your source, for reference.", "namePlaceholder": "My New Source", @@ -384,7 +332,58 @@ }, "subheader": "As you add folders to your library sources, Drop will detect it and prompt you to import it. Each game needs to be imported before you can import a version.", "title": "Libraries", - "versionPriority": "Version priority" + "version": { + "delta": "Upgrade mode", + "noVersions": "You have no versions of this game available.", + "noVersionsAdded": "no versions added" + }, + "versionPriority": "Version priority", + "metadata": { + "tags": { + "title": "Tags", + "description": "Tags are automatically created from imported genres. You can add custom tags to add categorisation to your game library.", + "action": "Manage {arrow}", + "create": "Create", + "modal": { + "title": "Create Tag", + "description": "Create a tag to organize your library." + } + }, + "companies": { + "title": "Companies", + "description": "Companies organize games by who they were developed or published by.", + "action": "Manage {arrow}", + "search": "Search companies...", + "searchGames": "Search company games...", + "noCompanies": "No companies", + "noGames": "No games", + "editor": { + "libraryTitle": "Game Library", + "libraryDescription": "Add, remove, or customise what this company has developed and/or published.", + "action": "Add Game {plus}", + "published": "Published", + "developed": "Developed", + "uploadIcon": "Upload icon", + "uploadBanner": "Upload banner", + "noDescription": "(no description)" + }, + "addGame": { + "title": "Connect game to this company", + "description": "Pick a game to add to the company, and whether it should be listed as a developer, publisher, or both.", + "publisher": "Publisher?", + "developer": "Developer?", + "noGames": "No games to add" + }, + "modals": { + "nameTitle": "Edit company name", + "nameDescription": "Edit the company's name. Used to match to new game imports.", + "shortDeckTitle": "Edit company description", + "shortDeckDescription": "Edit the company's description. Doesn't affect long (markdown) description.", + "websiteTitle": "Edit company website", + "websiteDescription": "Edit the company's website. Note: this will be a link, and won't have redirect protection." + } + } + } }, "back": "Back to Library", "collection": { @@ -407,29 +406,7 @@ "search": "Search library...", "subheader": "Organize your games into collections for easy access, and access all your games." }, - "tasks": { - "admin": { - "scheduled": { - "cleanupInvitationsName": "Clean up invitations", - "cleanupInvitationsDescription": "Cleans up expired invitations from the database to save space.", - "cleanupObjectsName": "Clean up objects", - "cleanupObjectsDescription": "Detects and deletes unreferenced and unused objects to save space.", - "cleanupSessionsName": "Clean up sessions.", - "cleanupSessionsDescription": "Cleans up expired sessions to save space and ensure security.", - "checkUpdateName": "Check update.", - "checkUpdateDescription": "Check if Drop has an update." - }, - "runningTasksTitle": "Running tasks", - "noTasksRunning": "No tasks currently running", - "completedTasksTitle": "Completed tasks", - "dailyScheduledTitle": "Daily scheduled tasks", - "weeklyScheduledTitle": "Weekly scheduled tasks", - "viewTask": "View {arrow}", - "back": "{arrow} Back to Tasks" - } - }, "lowest": "lowest", - "name": "Name", "news": { "article": { "add": "Add", @@ -462,34 +439,37 @@ "title": "Latest News" }, "options": "Options", - "save": "Save", - "saved": "Saved", - "add": "Add", - "insert": "Insert", "security": "Security", + "selectLanguage": "Select language", "settings": { "admin": { - "title": "Settings", "description": "Configure Drop settings", - "store": { - "title": "Store", - "showGamePanelTextDecoration": "Show title and description on game tiles (default: on)", - "dropGameNamePlaceholder": "Example Game", + "dropGameAltPlaceholder": "Example Game icon", "dropGameDescriptionPlaceholder": "This is an example game. It will be replaced if you import a game.", - "dropGameAltPlaceholder": "Example Game icon" - } + "dropGameNamePlaceholder": "Example Game", + "showGamePanelTextDecoration": "Show title and description on game tiles (default: on)", + "title": "Store" + }, + "title": "Settings" } }, "store": { + "about": "About", "commingSoon": "coming soon", + "developers": "Developers | Developer | Developers", "exploreMore": "Explore more {arrow}", + "featured": "Featured", "images": "Game Images", "lookAt": "Check it out", + "noDevelopers": "No developers", "noGame": "no game", "noImages": "No images", + "noPublishers": "No publishers.", + "noTags": "No tags", "openAdminDashboard": "Open in Admin Dashboard", "platform": "Platform | Platform | Platforms", + "publishers": "Publishers | Publisher | Publishers", "rating": "Rating", "readLess": "Click to read less", "readMore": "Click to read more", @@ -498,9 +478,41 @@ "recentlyUpdated": "Recently Updated", "released": "Released", "reviews": "({0} Reviews)", + "tags": "Tags", "title": "Store", - "view": "View in Store" + "view": { + "sort": "Sort", + "srFilters": "Filters", + "srGames": "Games", + "srViewGrid": "View grid" + }, + "viewInStore": "View in Store", + "website": "Website" }, + "tasks": { + "admin": { + "back": "{arrow} Back to Tasks", + "completedTasksTitle": "Completed tasks", + "dailyScheduledTitle": "Daily scheduled tasks", + "noTasksRunning": "No tasks currently running", + "runningTasksTitle": "Running tasks", + "scheduled": { + "checkUpdateDescription": "Check if Drop has an update.", + "checkUpdateName": "Check update.", + "cleanupInvitationsDescription": "Cleans up expired invitations from the database to save space.", + "cleanupInvitationsName": "Clean up invitations", + "cleanupObjectsDescription": "Detects and deletes unreferenced and unused objects to save space.", + "cleanupObjectsName": "Clean up objects", + "cleanupSessionsDescription": "Cleans up expired sessions to save space and ensure security.", + "cleanupSessionsName": "Clean up sessions." + }, + "viewTask": "View {arrow}", + "weeklyScheduledTitle": "Weekly scheduled tasks" + } + }, + "title": "Drop", + "titleTemplate": "{0} - Drop", + "todo": "Todo", "type": "Type", "upload": "Upload", "uploadFile": "Upload file", @@ -516,8 +528,63 @@ "settings": "Account settings" } }, - "todo": "Todo", - "selectLanguage": "Select language", - "helpUsTranslate": "Help us translate Drop {arrow}", + "users": { + "admin": { + "adminHeader": "Admin?", + "adminUserLabel": "Admin user", + "authentication": { + "configure": "Configure", + "description": "Drop supports a variety of \"authentication mechanisms\". As you enable or disable them, they are shown on the sign in screen for users to select from. Click the dot menu to configure the authentication mechanism.", + "disabled": "Disabled", + "enabled": "Enabled", + "enabledKey": "Enabled?", + "oidc": "OpenID Connect", + "simple": "Simple (username/password)", + "srOpenOptions": "Open options", + "title": "Authentication" + }, + "authLink": "Authentication {arrow}", + "authoptionsHeader": "Auth Options", + "delete": "Delete", + "deleteUser": "Delete user {0}", + "description": "Manage the users on your Drop instance, and configure your authentication methods.", + "displayNameHeader": "Display Name", + "emailHeader": "Email", + "normalUserLabel": "Normal user", + "simple": { + "adminInvitation": "Admin invitation", + "createInvitation": "Create invitation", + "description": "Simple authentication uses a system of 'invitations' to create users. You can create an invitation, and optionally specify a username or email for the user, and then it will generate a magic URL that can be used to create an account.", + "expires": "Expires: {expiry}", + "invitationTitle": "invitations", + "invite3Days": "3 days", + "invite6Months": "6 months", + "inviteAdminSwitchDescription": "Create this user as an administrator", + "inviteAdminSwitchLabel": "Admin invitation", + "inviteButton": "Invite", + "inviteDescription": "Drop will generate a URL that you can send to the person you want to invite. You can optionally specify a username or email for them to use.", + "inviteEmailDescription": "Must be in the format user{'@'}example.com", + "inviteEmailLabel": "Email address (optional)", + "inviteEmailPlaceholder": "me{'@'}example.com", + "inviteExpiryLabel": "Expires", + "inviteMonth": "1 month", + "inviteNever": "Never", + "inviteTitle": "Invite user to Drop", + "inviteUsernameFormat": "Must be 5 or more characters", + "inviteUsernameLabel": "Username (optional)", + "inviteUsernamePlaceholder": "myUsername", + "inviteWeek": "1 week", + "inviteYear": "1 year", + "neverExpires": "Never expires.", + "noEmailEnforced": "No email enforced.", + "noInvitations": "No invitations.", + "noUsernameEnforced": "No username enforced.", + "title": "Simple authentication", + "userInvitation": "User invitation" + }, + "srEditLabel": "Edit", + "usernameHeader": "Username" + } + }, "welcome": "American, Welcome!" } diff --git a/layouts/admin.vue b/layouts/admin.vue index aaf5dba..24cef9b 100644 --- a/layouts/admin.vue +++ b/layouts/admin.vue @@ -164,6 +164,7 @@ import { Cog6ToothIcon, UserGroupIcon, RectangleStackIcon, + DocumentIcon, } from "@heroicons/vue/24/outline"; import type { NavigationItem } from "~/composables/types"; import { useCurrentNavigationIndex } from "~/composables/current-page-engine"; @@ -180,6 +181,12 @@ const navigation: Array = [ prefix: "/admin/library", icon: ServerStackIcon, }, + { + label: $t("header.admin.metadata"), + route: "/admin/metadata", + prefix: "/admin/metadata", + icon: DocumentIcon, + }, { label: $t("header.admin.users"), route: "/admin/users", diff --git a/nuxt.config.ts b/nuxt.config.ts index e3384e1..2c73239 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -36,11 +36,11 @@ export default defineNuxtConfig({ modules: [ "vue3-carousel-nuxt", - "nuxt-security", - // "@nuxt/image", + "nuxt-security", // "@nuxt/image", "@nuxt/fonts", "@nuxt/eslint", "@nuxtjs/i18n", + "@vueuse/nuxt", ], // Nuxt-only config diff --git a/package.json b/package.json index 479038f..84a1836 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@nuxtjs/i18n": "^9.5.5", "@prisma/client": "^6.11.1", "@tailwindcss/vite": "^4.0.6", + "@vueuse/nuxt": "13.6.0", "argon2": "^0.43.0", "arktype": "^2.1.10", "axios": "^1.7.7", diff --git a/pages/account/devices.vue b/pages/account/devices.vue index 62d9f27..9d592d1 100644 --- a/pages/account/devices.vue +++ b/pages/account/devices.vue @@ -24,7 +24,7 @@ scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6" > - {{ $t("name") }} + {{ $t("common.name") }} (GameEditorMode.Metadata); + +useHead({ + // To do a title with the game name in it, we need some sort of watch + title: `${currentMode.value} - ${game.value.mName}`, +}); + +watch(currentMode, (v) => { + useHead({ + title: `${v} - ${game.value.mName}`, + }); +}); diff --git a/pages/admin/library/import.vue b/pages/admin/library/import.vue index 5f32f54..d6eede4 100644 --- a/pages/admin/library/import.vue +++ b/pages/admin/library/import.vue @@ -12,9 +12,15 @@ - {{ - games.unimportedGames[currentlySelectedGame].game - }} + {{ games.unimportedGames[currentlySelectedGame].game }} + {{ + games.unimportedGames[currentlySelectedGame].library.name + }} {{ $t("library.admin.import.selectDir") }} @@ -37,9 +43,9 @@ class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-zinc-800 focus:outline-none sm:text-sm" > @@ -51,14 +57,20 @@ > {{ game }}{{ game }} + {{ library.name }} + + + {{ + $t("library.admin.import.bulkImportDescription") + }} + +
    + + +
    +
    @@ -277,18 +317,20 @@ definePageMeta({ const { t } = useI18n(); -const games = await $dropFetch("/api/v1/admin/import/game"); +const rawGames = await $dropFetch("/api/v1/admin/import/game"); +const games = ref(rawGames); const currentlySelectedGame = ref(-1); const gameSearchResultsLoading = ref(false); const gameSearchResultsError = ref(); const gameSearchTerm = ref(""); const gameSearchLoading = ref(false); +const bulkImportMode = ref(false); async function updateSelectedGame(value: number) { if (currentlySelectedGame.value == value) return; currentlySelectedGame.value = value; if (currentlySelectedGame.value == -1) return; - const option = games.unimportedGames[currentlySelectedGame.value]; + const option = games.value.unimportedGames[currentlySelectedGame.value]; if (!option) return; metadataResults.value = undefined; @@ -299,12 +341,19 @@ async function updateSelectedGame(value: number) { } async function searchGame() { + gameSearchResultsError.value = undefined; gameSearchLoading.value = true; - const results = await $dropFetch( - `/api/v1/admin/import/game/search?q=${encodeURIComponent(gameSearchTerm.value)}`, - ); - metadataResults.value = results; - gameSearchLoading.value = false; + try { + const results = await $dropFetch( + `/api/v1/admin/import/game/search?q=${encodeURIComponent(gameSearchTerm.value)}`, + ); + metadataResults.value = results; + gameSearchLoading.value = false; + } catch (e) { + gameSearchLoading.value = false; + + throw e; + } } function updateSelectedGame_wrapper(value: number) { @@ -332,18 +381,24 @@ async function importGame(useMetadata: boolean) { useMetadata && metadataResults.value ? metadataResults.value[currentlySelectedMetadata.value] : undefined; - const option = games.unimportedGames[currentlySelectedGame.value]; + const option = games.value.unimportedGames[currentlySelectedGame.value]; const { taskId } = await $dropFetch("/api/v1/admin/import/game", { method: "POST", body: { path: option.game, - library: option.library, + library: option.library.id, metadata, }, }); - router.push(`/admin/task/${taskId}`); + if (!bulkImportMode.value) { + router.push(`/admin/task/${taskId}`); + } else { + games.value.unimportedGames.splice(currentlySelectedGame.value, 1); + currentlySelectedGame.value = -1; + gameSearchResultsError.value = undefined; + } } function importGame_wrapper(metadata = true) { importLoading.value = true; diff --git a/pages/admin/library/index.vue b/pages/admin/library/index.vue index 3adf807..f113509 100644 --- a/pages/admin/library/index.vue +++ b/pages/admin/library/index.vue @@ -78,20 +78,55 @@
  • -

    +

    {{ game.mName }} + {{ game.metadataSource }}{{ game.library!.name }}

    @@ -180,6 +215,24 @@
  • +
    +
    +
    +
    +
    +

    + {{ $t("library.admin.offline") }} +

    +
    +
    +

    diff --git a/pages/admin/library/sources/index.vue b/pages/admin/library/sources/index.vue index 046cf88..251da85 100644 --- a/pages/admin/library/sources/index.vue +++ b/pages/admin/library/sources/index.vue @@ -14,7 +14,7 @@ class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" @click="() => (actionSourceOpen = true)" > - {{ $t("create") }} + {{ $t("common.create") }} @@ -28,7 +28,7 @@ scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-3" > - {{ $t("name") }} + {{ $t("common.name") }} - {{ $t("edit") }} + {{ $t("common.edit") }} @@ -84,7 +84,7 @@ class="text-blue-500 hover:text-blue-400" @click="() => edit(sourceIdx)" > - {{ $t("edit") }} + {{ $t("common.edit") }} {{ $t("chars.srComma", [source.name]) }} @@ -110,9 +110,20 @@ + + diff --git a/pages/admin/metadata/companies/index.vue b/pages/admin/metadata/companies/index.vue new file mode 100644 index 0000000..12698eb --- /dev/null +++ b/pages/admin/metadata/companies/index.vue @@ -0,0 +1,150 @@ + + + diff --git a/pages/admin/metadata/index.vue b/pages/admin/metadata/index.vue new file mode 100644 index 0000000..ab43365 --- /dev/null +++ b/pages/admin/metadata/index.vue @@ -0,0 +1,62 @@ + + + diff --git a/pages/admin/metadata/tags.vue b/pages/admin/metadata/tags.vue new file mode 100644 index 0000000..a41cba2 --- /dev/null +++ b/pages/admin/metadata/tags.vue @@ -0,0 +1,75 @@ + + + diff --git a/pages/admin/settings/index.vue b/pages/admin/settings/index.vue index caf4609..3a80db9 100644 --- a/pages/admin/settings/index.vue +++ b/pages/admin/settings/index.vue @@ -59,7 +59,7 @@ :loading="saving" :disabled="!allowSave" > - {{ allowSave ? $t("save") : $t("saved") }} + {{ allowSave ? $t("common.save") : $t("common.saved") }} @@ -78,7 +78,7 @@ useHead({ title: t("settings.admin.title"), }); -const settings = await $dropFetch("/api/v1/admin/settings"); +const settings = await $dropFetch("/api/v1/settings"); const { game } = await $dropFetch("/api/v1/admin/settings/dummy-data"); const allowSave = ref(false); diff --git a/pages/admin/task/index.vue b/pages/admin/task/index.vue index 710fd39..29b9740 100644 --- a/pages/admin/task/index.vue +++ b/pages/admin/task/index.vue @@ -47,7 +47,7 @@ />

    - {{ task.value.log.at(-1) }} + {{ parseTaskLog(task.value.log.at(-1) ?? "").message }}

    - + diff --git a/pages/auth/register.vue b/pages/auth/register.vue index 9704f2e..d2820ae 100644 --- a/pages/auth/register.vue +++ b/pages/auth/register.vue @@ -157,7 +157,7 @@
    - {{ $t("create") }} + {{ $t("common.create") }}
    diff --git a/pages/library.vue b/pages/library.vue index 336f371..bdaa344 100644 --- a/pages/library.vue +++ b/pages/library.vue @@ -51,7 +51,7 @@
    - +
    @@ -64,7 +64,7 @@ class="hidden lg:flex lg:inset-y-0 lg:z-50 lg:shrink-0 lg:basis-[18rem] lg:flex-col lg:border-r-2 lg:border-zinc-800" > - +
    - {{ $t("store.view") }} + {{ $t("store.viewInStore") }}
    diff --git a/pages/library/index.vue b/pages/library/index.vue index 3f17099..8d87bd7 100644 --- a/pages/library/index.vue +++ b/pages/library/index.vue @@ -88,8 +88,8 @@ - - + + diff --git a/pages/news.vue b/pages/news.vue index 110ea65..a984018 100644 --- a/pages/news.vue +++ b/pages/news.vue @@ -51,7 +51,7 @@
    - +
    @@ -64,7 +64,7 @@ class="hidden lg:block lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col lg:border-r-2 lg:border-zinc-800" > - +
    - + diff --git a/pages/store/[id]/index.vue b/pages/store/[id]/index.vue index 2a2ef25..4bcd942 100644 --- a/pages/store/[id]/index.vue +++ b/pages/store/[id]/index.vue @@ -108,6 +108,72 @@ }}
    + + + {{ $t("store.tags") }} + + + + {{ tag.name }} + + {{ $t("store.noTags") }} + + + + + {{ $t("store.developers", game.developers.length) }} + + + + {{ developer.mName }} + + {{ $t("store.noDevelopers") }} + + + + + {{ $t("store.publishers", game.publishers.length) }} + + + + {{ publisher.mName }} + + {{ $t("store.noPublishers") }} + + @@ -225,6 +291,7 @@ const ratingArray = Array(5) useHead({ title: game.mName, + link: [{ rel: "icon", href: useObject(game.mIconObjectId) }], }); diff --git a/pages/store/c/[id]/index.vue b/pages/store/c/[id]/index.vue new file mode 100644 index 0000000..a4c62c3 --- /dev/null +++ b/pages/store/c/[id]/index.vue @@ -0,0 +1,65 @@ + + + + diff --git a/pages/store/index.vue b/pages/store/index.vue index b9c1f80..4254e1a 100644 --- a/pages/store/index.vue +++ b/pages/store/index.vue @@ -24,14 +24,16 @@ >

    - {{ $t("store.recentlyAdded") }} + {{ $t("store.featured") }}

    {{ game.mName }}

    -

    +

    {{ game.mShortDescription }}

    @@ -66,49 +68,12 @@
    - -
    -

    - {{ $t("store.recentlyReleased") }} -

    - - - - - -
    - -
    -
    - - -
    -

    - {{ $t("store.recentlyUpdated") }} -

    - - - - - -
    - -
    -
    +
    diff --git a/prisma/migrations/20250720070939_static_genres/migration.sql b/prisma/migrations/20250720070939_static_genres/migration.sql new file mode 100644 index 0000000..df31459 --- /dev/null +++ b/prisma/migrations/20250720070939_static_genres/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "Genre" AS ENUM ('Action', 'Strategy', 'Sports', 'Adventure', 'Roleplay', 'Racing', 'Simulation', 'Educational', 'Fighting', 'Shooter', 'RealTimeStrategy', 'CardGame', 'BoardGame', 'Compilation', 'MMORPG', 'MinigameCollection', 'Puzzle', 'MusicRhythm'); + +-- AlterTable +ALTER TABLE "Game" ADD COLUMN "genres" "Genre"[]; diff --git a/prisma/migrations/20250721053244_update_genre_names/migration.sql b/prisma/migrations/20250721053244_update_genre_names/migration.sql new file mode 100644 index 0000000..63accfe --- /dev/null +++ b/prisma/migrations/20250721053244_update_genre_names/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - The values [RealTimeStrategy,CardGame,BoardGame,MinigameCollection,MusicRhythm] on the enum `Genre` will be removed. If these variants are still used in the database, this will fail. + +*/ +-- AlterEnum +BEGIN; +CREATE TYPE "Genre_new" AS ENUM ('Action', 'Strategy', 'Sports', 'Adventure', 'Roleplay', 'Racing', 'Simulation', 'Educational', 'Fighting', 'Shooter', 'RTS', 'Card', 'Board', 'Compilation', 'MMORPG', 'Minigames', 'Puzzle', 'Rhythm'); +ALTER TABLE "Game" ALTER COLUMN "genres" TYPE "Genre_new"[] USING ("genres"::text::"Genre_new"[]); +ALTER TYPE "Genre" RENAME TO "Genre_old"; +ALTER TYPE "Genre_new" RENAME TO "Genre"; +DROP TYPE "Genre_old"; +COMMIT; diff --git a/prisma/migrations/20250721053514_add_featured/migration.sql b/prisma/migrations/20250721053514_add_featured/migration.sql new file mode 100644 index 0000000..89aeeb4 --- /dev/null +++ b/prisma/migrations/20250721053514_add_featured/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Game" ADD COLUMN "featured" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/migrations/20250721061200_remove_genres/migration.sql b/prisma/migrations/20250721061200_remove_genres/migration.sql new file mode 100644 index 0000000..4f4f1df --- /dev/null +++ b/prisma/migrations/20250721061200_remove_genres/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - You are about to drop the column `genres` on the `Game` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Game" DROP COLUMN "genres"; + +-- DropEnum +DROP TYPE "Genre"; diff --git a/prisma/migrations/20250721062509_add_pg_trgm/migration.sql b/prisma/migrations/20250721062509_add_pg_trgm/migration.sql new file mode 100644 index 0000000..567fbd0 --- /dev/null +++ b/prisma/migrations/20250721062509_add_pg_trgm/migration.sql @@ -0,0 +1,5 @@ +-- Add pg_trgm +CREATE EXTENSION pg_trgm; + +-- Create index for tag names +-- CREATE INDEX trgm_tag_name ON "Tag" USING GIST (name gist_trgm_ops(siglen=32)); \ No newline at end of file diff --git a/prisma/migrations/20250721063518_add_index_for_tag_name/migration.sql b/prisma/migrations/20250721063518_add_index_for_tag_name/migration.sql new file mode 100644 index 0000000..02b93b5 --- /dev/null +++ b/prisma/migrations/20250721063518_add_index_for_tag_name/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "Tag_name_idx" ON "Tag" USING GIST ("name" gist_trgm_ops(siglen=32)); diff --git a/prisma/migrations/20250721070713_split_game_and_news_tags/migration.sql b/prisma/migrations/20250721070713_split_game_and_news_tags/migration.sql new file mode 100644 index 0000000..cbd80c5 --- /dev/null +++ b/prisma/migrations/20250721070713_split_game_and_news_tags/migration.sql @@ -0,0 +1,87 @@ +/* + Warnings: + + - You are about to drop the `Tag` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `_ArticleToTag` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `_GameToTag` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "_ArticleToTag" DROP CONSTRAINT "_ArticleToTag_A_fkey"; + +-- DropForeignKey +ALTER TABLE "_ArticleToTag" DROP CONSTRAINT "_ArticleToTag_B_fkey"; + +-- DropForeignKey +ALTER TABLE "_GameToTag" DROP CONSTRAINT "_GameToTag_A_fkey"; + +-- DropForeignKey +ALTER TABLE "_GameToTag" DROP CONSTRAINT "_GameToTag_B_fkey"; + +-- DropTable +DROP TABLE "Tag"; + +-- DropTable +DROP TABLE "_ArticleToTag"; + +-- DropTable +DROP TABLE "_GameToTag"; + +-- CreateTable +CREATE TABLE "GameTag" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "GameTag_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "NewsTag" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "NewsTag_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_GameToGameTag" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_GameToGameTag_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateTable +CREATE TABLE "_ArticleToNewsTag" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_ArticleToNewsTag_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE UNIQUE INDEX "GameTag_name_key" ON "GameTag"("name"); + +-- CreateIndex +CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32)); + +-- CreateIndex +CREATE UNIQUE INDEX "NewsTag_name_key" ON "NewsTag"("name"); + +-- CreateIndex +CREATE INDEX "_GameToGameTag_B_index" ON "_GameToGameTag"("B"); + +-- CreateIndex +CREATE INDEX "_ArticleToNewsTag_B_index" ON "_ArticleToNewsTag"("B"); + +-- AddForeignKey +ALTER TABLE "_GameToGameTag" ADD CONSTRAINT "_GameToGameTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Game"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_GameToGameTag" ADD CONSTRAINT "_GameToGameTag_B_fkey" FOREIGN KEY ("B") REFERENCES "GameTag"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ArticleToNewsTag" ADD CONSTRAINT "_ArticleToNewsTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ArticleToNewsTag" ADD CONSTRAINT "_ArticleToNewsTag_B_fkey" FOREIGN KEY ("B") REFERENCES "NewsTag"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/models/content.prisma b/prisma/models/content.prisma index f53d612..587f2a6 100644 --- a/prisma/models/content.prisma +++ b/prisma/models/content.prisma @@ -23,6 +23,8 @@ model Game { ratings GameRating[] + featured Boolean @default(false) + mIconObjectId String // linked to objects in s3 mBannerObjectId String // linked to objects in s3 mCoverObjectId String @@ -40,7 +42,7 @@ model Game { collections CollectionEntry[] saves SaveSlot[] screenshots Screenshot[] - tags Tag[] + tags GameTag[] playtime Playtime[] developers Company[] @relation(name: "developers") @@ -50,6 +52,16 @@ model Game { @@unique([libraryId, libraryPath], name: "libraryKey") } +model GameTag { + id String @id @default(uuid()) + name String @unique + + games Game[] + + @@index([name(ops: raw("gist_trgm_ops(siglen=32)"))], type: Gist) +} + + model GameRating { id String @id @default(uuid()) diff --git a/prisma/models/news.prisma b/prisma/models/news.prisma index 79363f0..496994f 100644 --- a/prisma/models/news.prisma +++ b/prisma/models/news.prisma @@ -1,9 +1,8 @@ -model Tag { +model NewsTag { id String @id @default(uuid()) name String @unique articles Article[] - games Game[] } model Article { @@ -12,7 +11,7 @@ model Article { description String content String @db.Text - tags Tag[] + tags NewsTag[] imageObjectId String? // Object ID publishedAt DateTime @default(now()) diff --git a/server/api/v1/admin/company/[id]/banner.post.ts b/server/api/v1/admin/company/[id]/banner.post.ts new file mode 100644 index 0000000..0c8744a --- /dev/null +++ b/server/api/v1/admin/company/[id]/banner.post.ts @@ -0,0 +1,51 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; +import objectHandler from "~/server/internal/objects"; +import { handleFileUpload } from "~/server/internal/utils/handlefileupload"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["company:update"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const companyId = getRouterParam(h3, "id")!; + const company = await prisma.company.findUnique({ + where: { + id: companyId, + }, + }); + + if (!company) + throw createError({ statusCode: 400, statusMessage: "Invalid company id" }); + + const result = await handleFileUpload(h3, {}, ["internal:read"], 1); + if (!result) + throw createError({ + statusCode: 400, + statusMessage: "File upload required (multipart form)", + }); + + const [ids, , pull, dump] = result; + const id = ids.at(0); + if (!id) + throw createError({ + statusCode: 400, + statusMessage: "Upload at least one file.", + }); + + try { + await objectHandler.deleteAsSystem(company.mBannerObjectId); + await prisma.company.update({ + where: { + id: companyId, + }, + data: { + mBannerObjectId: id, + }, + }); + await pull(); + } catch { + await dump(); + } + + return { id: id }; +}); diff --git a/server/api/v1/admin/company/[id]/game.delete.ts b/server/api/v1/admin/company/[id]/game.delete.ts new file mode 100644 index 0000000..0a9fe21 --- /dev/null +++ b/server/api/v1/admin/company/[id]/game.delete.ts @@ -0,0 +1,37 @@ +import { type } from "arktype"; +import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +const GameDelete = type({ + id: "string", +}).configure(throwingArktype); + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["company:update"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const companyId = getRouterParam(h3, "id")!; + + const body = await readDropValidatedBody(h3, GameDelete); + + await prisma.game.update({ + where: { + id: body.id, + }, + data: { + publishers: { + disconnect: { + id: companyId, + }, + }, + developers: { + disconnect: { + id: companyId, + }, + }, + }, + }); + + return; +}); diff --git a/server/api/v1/admin/company/[id]/game.patch.ts b/server/api/v1/admin/company/[id]/game.patch.ts new file mode 100644 index 0000000..9e0260f --- /dev/null +++ b/server/api/v1/admin/company/[id]/game.patch.ts @@ -0,0 +1,37 @@ +import { type } from "arktype"; +import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +const GamePatch = type({ + action: "'developed' | 'published'", + enabled: "boolean", + id: "string", +}).configure(throwingArktype); + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["company:update"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const companyId = getRouterParam(h3, "id")!; + + const body = await readDropValidatedBody(h3, GamePatch); + + const action = body.action === "developed" ? "developers" : "publishers"; + const actionType = body.enabled ? "connect" : "disconnect"; + + await prisma.game.update({ + where: { + id: body.id, + }, + data: { + [action]: { + [actionType]: { + id: companyId, + }, + }, + }, + }); + + return; +}); diff --git a/server/api/v1/admin/company/[id]/game.post.ts b/server/api/v1/admin/company/[id]/game.post.ts new file mode 100644 index 0000000..f873118 --- /dev/null +++ b/server/api/v1/admin/company/[id]/game.post.ts @@ -0,0 +1,69 @@ +import { type } from "arktype"; +import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +const GamePost = type({ + published: "boolean", + developed: "boolean", + id: "string", +}).configure(throwingArktype); + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["company:update"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const companyId = getRouterParam(h3, "id")!; + + const body = await readDropValidatedBody(h3, GamePost); + + if (!body.published && !body.developed) + throw createError({ + statusCode: 400, + statusMessage: "Must be related (either developed or published).", + }); + + const publisherConnect = body.published + ? { + publishers: { + connect: { + id: companyId, + }, + }, + } + : undefined; + + const developerConnect = body.developed + ? { + developers: { + connect: { + id: companyId, + }, + }, + } + : undefined; + + const game = await prisma.game.update({ + where: { + id: body.id, + }, + data: { + ...publisherConnect, + ...developerConnect, + }, + include: { + publishers: { + select: { + id: true, + }, + }, + developers: { + select: { + id: true, + }, + }, + }, + }); + + return game; +}); diff --git a/server/api/v1/admin/company/[id]/icon.post.ts b/server/api/v1/admin/company/[id]/icon.post.ts new file mode 100644 index 0000000..0a4cefd --- /dev/null +++ b/server/api/v1/admin/company/[id]/icon.post.ts @@ -0,0 +1,51 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; +import objectHandler from "~/server/internal/objects"; +import { handleFileUpload } from "~/server/internal/utils/handlefileupload"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["company:update"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const companyId = getRouterParam(h3, "id")!; + const company = await prisma.company.findUnique({ + where: { + id: companyId, + }, + }); + + if (!company) + throw createError({ statusCode: 400, statusMessage: "Invalid company id" }); + + const result = await handleFileUpload(h3, {}, ["internal:read"], 1); + if (!result) + throw createError({ + statusCode: 400, + statusMessage: "File upload required (multipart form)", + }); + + const [ids, , pull, dump] = result; + const id = ids.at(0); + if (!id) + throw createError({ + statusCode: 400, + statusMessage: "Upload at least one file.", + }); + + try { + await objectHandler.deleteAsSystem(company.mLogoObjectId); + await prisma.company.update({ + where: { + id: companyId, + }, + data: { + mLogoObjectId: id, + }, + }); + await pull(); + } catch { + await dump(); + } + + return { id: id }; +}); diff --git a/server/api/v1/admin/company/[id]/index.delete.ts b/server/api/v1/admin/company/[id]/index.delete.ts new file mode 100644 index 0000000..b27d393 --- /dev/null +++ b/server/api/v1/admin/company/[id]/index.delete.ts @@ -0,0 +1,14 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["company:delete"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const id = getRouterParam(h3, "id")!; + + const company = await prisma.company.deleteMany({ where: { id } }); + if (company.count == 0) + throw createError({ statusCode: 404, statusMessage: "Company not found" }); + return; +}); diff --git a/server/api/v1/admin/company/[id]/index.get.ts b/server/api/v1/admin/company/[id]/index.get.ts new file mode 100644 index 0000000..50c4115 --- /dev/null +++ b/server/api/v1/admin/company/[id]/index.get.ts @@ -0,0 +1,54 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["company:read"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const id = getRouterParam(h3, "id")!; + + const company = await prisma.company.findUnique({ + where: { id }, + include: { + published: { + select: { + id: true, + }, + }, + developed: { + select: { + id: true, + }, + }, + }, + }); + if (!company) + throw createError({ statusCode: 404, statusMessage: "Company not found" }); + const games = await prisma.game.findMany({ + where: { + OR: [ + { + developers: { + some: { + id: company.id, + }, + }, + }, + { + publishers: { + some: { + id: company.id, + }, + }, + }, + ], + }, + distinct: ["id"], + }); + const companyFlatten = { + ...company, + developed: company.developed.map((e) => e.id), + published: company.published.map((e) => e.id), + }; + return { company: companyFlatten, games }; +}); diff --git a/server/api/v1/admin/company/[id]/index.patch.ts b/server/api/v1/admin/company/[id]/index.patch.ts new file mode 100644 index 0000000..74511e2 --- /dev/null +++ b/server/api/v1/admin/company/[id]/index.patch.ts @@ -0,0 +1,23 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["company:update"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const body = await readBody(h3); + const id = getRouterParam(h3, "id")!; + + const restOfTheBody = { ...body }; + delete restOfTheBody["id"]; + + const newObj = await prisma.company.update({ + where: { + id: id, + }, + data: restOfTheBody, + // I would put a select here, but it would be based on the body, and muck up the types + }); + + return newObj; +}); diff --git a/server/api/v1/admin/company/index.get.ts b/server/api/v1/admin/company/index.get.ts new file mode 100644 index 0000000..dac5ae2 --- /dev/null +++ b/server/api/v1/admin/company/index.get.ts @@ -0,0 +1,10 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["company:read"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const companies = await prisma.company.findMany({}); + return companies; +}); diff --git a/server/api/v1/admin/game/index.delete.ts b/server/api/v1/admin/game/[id]/index.delete.ts similarity index 55% rename from server/api/v1/admin/game/index.delete.ts rename to server/api/v1/admin/game/[id]/index.delete.ts index 730763c..2a0645d 100644 --- a/server/api/v1/admin/game/index.delete.ts +++ b/server/api/v1/admin/game/[id]/index.delete.ts @@ -1,17 +1,11 @@ import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; -export default defineEventHandler<{ query: { id: string } }>(async (h3) => { +export default defineEventHandler(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, ["game:delete"]); if (!allowed) throw createError({ statusCode: 403 }); - const query = getQuery(h3); - const gameId = query.id?.toString(); - if (!gameId) - throw createError({ - statusCode: 400, - statusMessage: "Missing id in query", - }); + const gameId = getRouterParam(h3, "id")!; await prisma.game.delete({ where: { diff --git a/server/api/v1/admin/game/[id]/index.get.ts b/server/api/v1/admin/game/[id]/index.get.ts new file mode 100644 index 0000000..44693dc --- /dev/null +++ b/server/api/v1/admin/game/[id]/index.get.ts @@ -0,0 +1,40 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; +import libraryManager from "~/server/internal/library"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["game:read"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const gameId = getRouterParam(h3, "id")!; + + const game = await prisma.game.findUnique({ + where: { + id: gameId, + }, + include: { + versions: { + orderBy: { + versionIndex: "asc", + }, + select: { + versionIndex: true, + versionName: true, + platform: true, + delta: true, + }, + }, + tags: true, + }, + }); + + if (!game || !game.libraryId) + throw createError({ statusCode: 404, statusMessage: "Game ID not found" }); + + const unimportedVersions = await libraryManager.fetchUnimportedGameVersions( + game.libraryId, + game.libraryPath, + ); + + return { game, unimportedVersions }; +}); diff --git a/server/api/v1/admin/game/index.patch.ts b/server/api/v1/admin/game/[id]/index.patch.ts similarity index 84% rename from server/api/v1/admin/game/index.patch.ts rename to server/api/v1/admin/game/[id]/index.patch.ts index 238dc8e..410adee 100644 --- a/server/api/v1/admin/game/index.patch.ts +++ b/server/api/v1/admin/game/[id]/index.patch.ts @@ -6,9 +6,7 @@ export default defineEventHandler(async (h3) => { if (!allowed) throw createError({ statusCode: 403 }); const body = await readBody(h3); - const id = body.id; - if (!id) - throw createError({ statusCode: 400, statusMessage: "Missing id in body" }); + const id = getRouterParam(h3, "id")!; const restOfTheBody = { ...body }; delete restOfTheBody["id"]; diff --git a/server/api/v1/admin/game/metadata.post.ts b/server/api/v1/admin/game/[id]/metadata.post.ts similarity index 97% rename from server/api/v1/admin/game/metadata.post.ts rename to server/api/v1/admin/game/[id]/metadata.post.ts index b6f4e21..cd1dcbc 100644 --- a/server/api/v1/admin/game/metadata.post.ts +++ b/server/api/v1/admin/game/[id]/metadata.post.ts @@ -14,6 +14,8 @@ export default defineEventHandler(async (h3) => { statusMessage: "This endpoint requires multipart form data.", }); + const gameId = getRouterParam(h3, "id")!; + const uploadResult = await handleFileUpload(h3, {}, ["internal:read"], 1); if (!uploadResult) throw createError({ @@ -28,7 +30,6 @@ export default defineEventHandler(async (h3) => { // handleFileUpload reads the rest of the options for us. const name = options.name; const description = options.description; - const gameId = options.id; const updateModel: Prisma.GameUpdateInput = { mName: name, diff --git a/server/api/v1/admin/game/[id]/tags.patch.ts b/server/api/v1/admin/game/[id]/tags.patch.ts new file mode 100644 index 0000000..d7192ad --- /dev/null +++ b/server/api/v1/admin/game/[id]/tags.patch.ts @@ -0,0 +1,29 @@ +import { type } from "arktype"; +import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +const PatchTags = type({ + tags: "string[]", +}).configure(throwingArktype); + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["game:update"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const body = await readDropValidatedBody(h3, PatchTags); + const id = getRouterParam(h3, "id")!; + + await prisma.game.update({ + where: { + id, + }, + data: { + tags: { + connect: body.tags.map((e) => ({ id: e })), + }, + }, + }); + + return; +}); diff --git a/server/api/v1/admin/game/index.get.ts b/server/api/v1/admin/game/index.get.ts index e52a495..c7ca363 100644 --- a/server/api/v1/admin/game/index.get.ts +++ b/server/api/v1/admin/game/index.get.ts @@ -1,45 +1,16 @@ import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; -import libraryManager from "~/server/internal/library"; export default defineEventHandler(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, ["game:read"]); if (!allowed) throw createError({ statusCode: 403 }); - const query = getQuery(h3); - const gameId = query.id?.toString(); - if (!gameId) - throw createError({ - statusCode: 400, - statusMessage: "Missing id in query", - }); - - const game = await prisma.game.findUnique({ - where: { - id: gameId, - }, - include: { - versions: { - orderBy: { - versionIndex: "asc", - }, - select: { - versionIndex: true, - versionName: true, - platform: true, - delta: true, - }, - }, + return await prisma.game.findMany({ + select: { + id: true, + mName: true, + mShortDescription: true, + mIconObjectId: true, }, }); - - if (!game || !game.libraryId) - throw createError({ statusCode: 404, statusMessage: "Game ID not found" }); - - const unimportedVersions = await libraryManager.fetchUnimportedGameVersions( - game.libraryId, - game.libraryPath, - ); - - return { game, unimportedVersions }; }); diff --git a/server/api/v1/admin/import/game/index.get.ts b/server/api/v1/admin/import/game/index.get.ts index 95373ef..8ef65fb 100644 --- a/server/api/v1/admin/import/game/index.get.ts +++ b/server/api/v1/admin/import/game/index.get.ts @@ -5,10 +5,13 @@ export default defineEventHandler(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, ["import:game:read"]); if (!allowed) throw createError({ statusCode: 403 }); - const unimportedGames = await libraryManager.fetchAllUnimportedGames(); + const unimportedGames = await libraryManager.fetchUnimportedGames(); + const libraries = Object.fromEntries( + (await libraryManager.fetchLibraries()).map((e) => [e.id, e]), + ); const iterableUnimportedGames = Object.entries(unimportedGames) .map(([libraryId, gameArray]) => - gameArray.map((e) => ({ game: e, library: libraryId })), + gameArray.map((e) => ({ game: e, library: libraries[libraryId] })), ) .flat(); return { unimportedGames: iterableUnimportedGames }; diff --git a/server/api/v1/admin/library/index.get.ts b/server/api/v1/admin/library/index.get.ts index 192a7c2..d0a0047 100644 --- a/server/api/v1/admin/library/index.get.ts +++ b/server/api/v1/admin/library/index.get.ts @@ -5,7 +5,7 @@ export default defineEventHandler(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, ["library:read"]); if (!allowed) throw createError({ statusCode: 403 }); - const unimportedGames = await libraryManager.fetchAllUnimportedGames(); + const unimportedGames = await libraryManager.fetchUnimportedGames(); const games = await libraryManager.fetchGamesWithStatus(); // Fetch other library data here diff --git a/server/api/v1/admin/tags/[id]/index.delete.ts b/server/api/v1/admin/tags/[id]/index.delete.ts new file mode 100644 index 0000000..4279257 --- /dev/null +++ b/server/api/v1/admin/tags/[id]/index.delete.ts @@ -0,0 +1,14 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["tags:delete"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const id = getRouterParam(h3, "id")!; + + const tag = await prisma.gameTag.deleteMany({ where: { id } }); + if (tag.count == 0) + throw createError({ statusCode: 404, statusMessage: "Tag not found" }); + return; +}); diff --git a/server/api/v1/admin/tags/index.get.ts b/server/api/v1/admin/tags/index.get.ts new file mode 100644 index 0000000..d8f1c1e --- /dev/null +++ b/server/api/v1/admin/tags/index.get.ts @@ -0,0 +1,10 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["tags:read"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const tags = await prisma.gameTag.findMany({ orderBy: { name: "asc" } }); + return tags; +}); diff --git a/server/api/v1/admin/tags/index.post.ts b/server/api/v1/admin/tags/index.post.ts new file mode 100644 index 0000000..15698e7 --- /dev/null +++ b/server/api/v1/admin/tags/index.post.ts @@ -0,0 +1,22 @@ +import { type } from "arktype"; +import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +const CreateTag = type({ + name: "string", +}).configure(throwingArktype); + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["tags:read"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const body = await readDropValidatedBody(h3, CreateTag); + + const tag = await prisma.gameTag.create({ + data: { + ...body, + }, + }); + return tag; +}); diff --git a/server/api/v1/companies/[id]/index.get.ts b/server/api/v1/companies/[id]/index.get.ts new file mode 100644 index 0000000..a326371 --- /dev/null +++ b/server/api/v1/companies/[id]/index.get.ts @@ -0,0 +1,23 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const userId = await aclManager.getUserIdACL(h3, ["store:read"]); + if (!userId) throw createError({ statusCode: 403 }); + + const companyId = getRouterParam(h3, "id"); + if (!companyId) + throw createError({ + statusCode: 400, + statusMessage: "Missing gameId in route params (somehow...?)", + }); + + const company = await prisma.company.findUnique({ + where: { id: companyId }, + }); + + if (!company) + throw createError({ statusCode: 404, statusMessage: "Company not found" }); + + return { company }; +}); diff --git a/server/api/v1/games/[id]/index.get.ts b/server/api/v1/games/[id]/index.get.ts index f12e7ba..029b9b2 100644 --- a/server/api/v1/games/[id]/index.get.ts +++ b/server/api/v1/games/[id]/index.get.ts @@ -16,6 +16,23 @@ export default defineEventHandler(async (h3) => { where: { id: gameId }, include: { versions: true, + publishers: { + select: { + id: true, + mName: true, + mShortDescription: true, + mLogoObjectId: true, + }, + }, + developers: { + select: { + id: true, + mName: true, + mShortDescription: true, + mLogoObjectId: true, + }, + }, + tags: true, }, }); diff --git a/server/api/v1/admin/settings/index.get.ts b/server/api/v1/settings/index.get.ts similarity index 100% rename from server/api/v1/admin/settings/index.get.ts rename to server/api/v1/settings/index.get.ts diff --git a/server/api/v1/store/recent.get.ts b/server/api/v1/store/featured.get.ts similarity index 94% rename from server/api/v1/store/recent.get.ts rename to server/api/v1/store/featured.get.ts index f4bb39a..fb353e4 100644 --- a/server/api/v1/store/recent.get.ts +++ b/server/api/v1/store/featured.get.ts @@ -6,6 +6,9 @@ export default defineEventHandler(async (h3) => { if (!userId) throw createError({ statusCode: 403 }); const games = await prisma.game.findMany({ + where: { + featured: true, + }, select: { id: true, mName: true, @@ -28,7 +31,6 @@ export default defineEventHandler(async (h3) => { orderBy: { created: "desc", }, - take: 8, }); return games; diff --git a/server/api/v1/store/index.get.ts b/server/api/v1/store/index.get.ts new file mode 100644 index 0000000..9a61e67 --- /dev/null +++ b/server/api/v1/store/index.get.ts @@ -0,0 +1,122 @@ +import { ArkErrors, type } from "arktype"; +import type { Prisma } from "~/prisma/client/client"; +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; +import { parsePlatform } from "~/server/internal/utils/parseplatform"; + +const StoreRead = type({ + skip: type("string") + .pipe((s) => Number.parseInt(s)) + .default("0"), + take: type("string") + .pipe((s) => Number.parseInt(s)) + .default("10"), + + tags: "string?", + platform: "string?", + + company: "string?", + companyActions: "string = 'published,developed'", + + sort: "'default' | 'newest' | 'recent' = 'default'", +}); + +export default defineEventHandler(async (h3) => { + const userId = await aclManager.getUserIdACL(h3, ["store:read"]); + if (!userId) throw createError({ statusCode: 403 }); + + const query = getQuery(h3); + const options = StoreRead(query); + if (options instanceof ArkErrors) + throw createError({ statusCode: 400, statusMessage: options.summary }); + + /** + * Generic filters + */ + const tagFilter = options.tags + ? { + tags: { + some: { + id: { + in: options.tags.split(","), + }, + }, + }, + } + : undefined; + const platformFilter = options.platform + ? { + versions: { + some: { + platform: { + in: options.platform + .split(",") + .map(parsePlatform) + .filter((e) => e !== undefined), + }, + }, + }, + } + : undefined; + + /** + * Company filtering + */ + const companyActions = options.companyActions.split(","); + const developedFilter = companyActions.includes("developed") + ? { + developers: { + some: { + id: options.company!, + }, + }, + } + : undefined; + const publishedFilter = companyActions.includes("published") + ? { + publishers: { + some: { + id: options.company!, + }, + }, + } + : undefined; + const companyFilter = options.company + ? ({ + OR: [developedFilter, publishedFilter].filter((e) => e !== undefined), + } satisfies Prisma.GameWhereInput) + : undefined; + + /** + * Query + */ + + const finalFilter: Prisma.GameWhereInput = { + ...tagFilter, + ...platformFilter, + ...companyFilter, + }; + + const sort: Prisma.GameOrderByWithRelationInput = {}; + switch (options.sort) { + case "default": + case "newest": + sort.mReleased = "desc"; + break; + case "recent": + sort.created = "desc"; + break; + } + + const [results, count] = await prisma.$transaction([ + prisma.game.findMany({ + skip: options.skip, + take: Math.min(options.take, 50), + where: finalFilter, + orderBy: sort, + }), + prisma.game.count({ where: finalFilter }), + ]); + + return { results, count }; +}); diff --git a/server/api/v1/store/released.get.ts b/server/api/v1/store/tags.get.ts similarity index 52% rename from server/api/v1/store/released.get.ts rename to server/api/v1/store/tags.get.ts index 37c729c..07f4030 100644 --- a/server/api/v1/store/released.get.ts +++ b/server/api/v1/store/tags.get.ts @@ -2,15 +2,9 @@ import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; export default defineEventHandler(async (h3) => { - const userId = await aclManager.getUserACL(h3, ["store:read"]); + const userId = await aclManager.getUserIdACL(h3, ["store:read"]); if (!userId) throw createError({ statusCode: 403 }); - const games = await prisma.game.findMany({ - orderBy: { - mReleased: "desc", - }, - take: 12, - }); - - return games; + const tags = await prisma.gameTag.findMany({ orderBy: { name: "asc" } }); + return tags; }); diff --git a/server/api/v1/store/updated.get.ts b/server/api/v1/store/updated.get.ts deleted file mode 100644 index 520033e..0000000 --- a/server/api/v1/store/updated.get.ts +++ /dev/null @@ -1,28 +0,0 @@ -import aclManager from "~/server/internal/acls"; -import prisma from "~/server/internal/db/database"; - -export default defineEventHandler(async (h3) => { - const userId = await aclManager.getUserACL(h3, ["store:read"]); - if (!userId) throw createError({ statusCode: 403 }); - - const versions = await prisma.gameVersion.findMany({ - where: { - versionIndex: { - gte: 1, - }, - }, - select: { - game: true, - }, - orderBy: { - created: "desc", - }, - take: 12, - }); - - const games = versions - .map((e) => e.game) - .filter((v, i, a) => a.findIndex((e) => e.id === v.id) === i); - - return games; -}); diff --git a/server/api/v1/tags/[id]/index.get.ts b/server/api/v1/tags/[id]/index.get.ts new file mode 100644 index 0000000..5d5d686 --- /dev/null +++ b/server/api/v1/tags/[id]/index.get.ts @@ -0,0 +1,23 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const userId = await aclManager.getUserIdACL(h3, ["store:read"]); + if (!userId) throw createError({ statusCode: 403 }); + + const tagId = getRouterParam(h3, "id"); + if (!tagId) + throw createError({ + statusCode: 400, + statusMessage: "Missing gameId in route params (somehow...?)", + }); + + const tag = await prisma.gameTag.findUnique({ + where: { id: tagId }, + }); + + if (!tag) + throw createError({ statusCode: 404, statusMessage: "Tag not found" }); + + return { tag }; +}); diff --git a/server/internal/acls/descriptions.ts b/server/internal/acls/descriptions.ts index 59d7d27..98b919d 100644 --- a/server/internal/acls/descriptions.ts +++ b/server/internal/acls/descriptions.ts @@ -70,6 +70,11 @@ export const systemACLDescriptions: ObjectFromList = { "game:image:new": "Upload an image for a game.", "game:image:delete": "Delete an image for a game.", + "company:read": "Fetch companies.", + "company:create": "Create a new company.", + "company:update": "Update existing companies.", + "company:delete": "Delete companies.", + "import:version:read": "Fetch versions to be imported, and information about versions to be imported.", "import:version:new": "Import a game version.", @@ -77,6 +82,10 @@ export const systemACLDescriptions: ObjectFromList = { "Fetch games to be imported, and search the metadata for games.", "import:game:new": "Import a game.", + "tags:read": "Fetch all tags", + "tags:create": "Create a tag", + "tags:delete": "Delete a tag", + "user:read": "Fetch any user's information.", "user:delete": "Delete a user.", diff --git a/server/internal/acls/index.ts b/server/internal/acls/index.ts index 169d34c..2c2f1c6 100644 --- a/server/internal/acls/index.ts +++ b/server/internal/acls/index.ts @@ -65,6 +65,11 @@ export const systemACLs = [ "game:image:new", "game:image:delete", + "company:read", + "company:update", + "company:create", + "company:delete", + "import:version:read", "import:version:new", @@ -78,6 +83,10 @@ export const systemACLs = [ "news:create", "news:delete", + "tags:read", + "tags:create", + "tags:delete", + "task:read", "task:start", diff --git a/server/internal/library/index.ts b/server/internal/library/index.ts index 75a1140..ad6b17e 100644 --- a/server/internal/library/index.ts +++ b/server/internal/library/index.ts @@ -11,11 +11,15 @@ import { fuzzy } from "fast-fuzzy"; import taskHandler from "../tasks"; import { parsePlatform } from "../utils/parseplatform"; import notificationSystem from "../notifications"; -import type { LibraryProvider } from "./provider"; +import { GameNotFoundError, type LibraryProvider } from "./provider"; +import { logger } from "../logging"; class LibraryManager { private libraries: Map> = new Map(); + private gameImportLocks: Map> = new Map(); // Library ID to Library Path + private versionImportLocks: Map> = new Map(); // Game ID to Version Name + addLibrary(library: LibraryProvider) { this.libraries.set(library.id(), library); } @@ -33,7 +37,7 @@ class LibraryManager { return libraryWithMetadata; } - async fetchAllUnimportedGames() { + async fetchUnimportedGames() { const unimportedGames: { [key: string]: string[] } = {}; for (const [id, library] of this.libraries.entries()) { @@ -48,7 +52,9 @@ class LibraryManager { }, }); const providerUnimportedGames = games.filter( - (e) => validGames.findIndex((v) => v.libraryPath == e) == -1, + (e) => + validGames.findIndex((v) => v.libraryPath == e) == -1 && + !(this.gameImportLocks.get(id) ?? []).includes(e), ); unimportedGames[id] = providerUnimportedGames; } @@ -67,30 +73,34 @@ class LibraryManager { }, }, select: { + id: true, versions: true, }, }); if (!game) return undefined; - const versions = await provider.listVersions(libraryPath); - const unimportedVersions = versions.filter( - (e) => game.versions.findIndex((v) => v.versionName == e) == -1, - ); - - return unimportedVersions; + try { + const versions = await provider.listVersions(libraryPath); + const unimportedVersions = versions.filter( + (e) => + game.versions.findIndex((v) => v.versionName == e) == -1 && + !(this.versionImportLocks.get(game.id) ?? []).includes(e), + ); + return unimportedVersions; + } catch (e) { + if (e instanceof GameNotFoundError) { + logger.warn(e); + return undefined; + } + throw e; + } } async fetchGamesWithStatus() { const games = await prisma.game.findMany({ - select: { - id: true, + include: { versions: true, - mName: true, - mShortDescription: true, - metadataSource: true, - mIconObjectId: true, - libraryId: true, - libraryPath: true, + library: true, }, orderBy: { mName: "asc", @@ -98,19 +108,30 @@ class LibraryManager { }); return await Promise.all( - games.map(async (e) => ({ - game: e, - status: { - noVersions: e.versions.length == 0, - unimportedVersions: (await this.fetchUnimportedGameVersions( - e.libraryId ?? "", - e.libraryPath, - ))!, - }, - })), + games.map(async (e) => { + const versions = await this.fetchUnimportedGameVersions( + e.libraryId ?? "", + e.libraryPath, + ); + return { + game: e, + status: versions + ? { + noVersions: e.versions.length == 0, + unimportedVersions: versions, + } + : ("offline" as const), + }; + }), ); } + /** + * Fetches recommendations and extra data about the version. Doesn't actually check if it's been imported. + * @param gameId + * @param versionName + * @returns + */ async fetchUnimportedVersionInformation(gameId: string, versionName: string) { const game = await prisma.game.findUnique({ where: { id: gameId }, @@ -130,10 +151,7 @@ class LibraryManager { // No extension is common for Linux binaries "", ], - Windows: [ - // Pretty much the only one - ".exe", - ], + Windows: [".exe", ".bat"], macOS: [ // App files ".app", @@ -188,6 +206,70 @@ class LibraryManager { } */ + /** + * Locks the game so you can't be imported + * @param libraryId + * @param libraryPath + */ + async lockGame(libraryId: string, libraryPath: string) { + let games = this.gameImportLocks.get(libraryId); + if (!games) this.gameImportLocks.set(libraryId, (games = [])); + + if (!games.includes(libraryPath)) games.push(libraryPath); + + this.gameImportLocks.set(libraryId, games); + } + + /** + * Unlocks the game, call once imported + * @param libraryId + * @param libraryPath + */ + async unlockGame(libraryId: string, libraryPath: string) { + let games = this.gameImportLocks.get(libraryId); + if (!games) this.gameImportLocks.set(libraryId, (games = [])); + + if (games.includes(libraryPath)) + games.splice( + games.findIndex((e) => e === libraryPath), + 1, + ); + + this.gameImportLocks.set(libraryId, games); + } + + /** + * Locks a version so it can't be imported + * @param gameId + * @param versionName + */ + async lockVersion(gameId: string, versionName: string) { + let versions = this.versionImportLocks.get(gameId); + if (!versions) this.versionImportLocks.set(gameId, (versions = [])); + + if (!versions.includes(versionName)) versions.push(versionName); + + this.versionImportLocks.set(gameId, versions); + } + + /** + * Unlocks the version, call once imported + * @param libraryId + * @param libraryPath + */ + async unlockVersion(gameId: string, versionName: string) { + let versions = this.versionImportLocks.get(gameId); + if (!versions) this.versionImportLocks.set(gameId, (versions = [])); + + if (versions.includes(gameId)) + versions.splice( + versions.findIndex((e) => e === versionName), + 1, + ); + + this.versionImportLocks.set(gameId, versions); + } + async importVersion( gameId: string, versionName: string, @@ -218,6 +300,8 @@ class LibraryManager { const library = this.libraries.get(game.libraryId); if (!library) return undefined; + await this.lockVersion(gameId, versionName); + taskHandler.create({ id: taskId, taskGroup: "import:game", @@ -294,6 +378,9 @@ class LibraryManager { progress(100); }, + async finally() { + await libraryManager.unlockVersion(gameId, versionName); + }, }); return taskId; diff --git a/server/internal/metadata/giantbomb.ts b/server/internal/metadata/giantbomb.ts index 621aa93..aa7e6d6 100644 --- a/server/internal/metadata/giantbomb.ts +++ b/server/internal/metadata/giantbomb.ts @@ -65,6 +65,11 @@ interface GameResult { reviews?: Array<{ api_detail_url: string; }>; + + genres?: Array<{ + name: string; + id: number; + }>; } interface ReviewResult { @@ -189,7 +194,7 @@ export class GiantBombProvider implements MetadataProvider { context?.logger.warn(`Failed to import publisher "${pub}"`); continue; } - context?.logger.info(`Imported publisher "${pub}"`); + context?.logger.info(`Imported publisher "${pub.name}"`); publishers.push(res); } } @@ -224,11 +229,7 @@ export class GiantBombProvider implements MetadataProvider { const releaseDate = gameData.original_release_date ? DateTime.fromISO(gameData.original_release_date).toJSDate() - : DateTime.fromISO( - `${gameData.expected_release_year ?? new Date().getFullYear()}-${ - gameData.expected_release_month ?? 1 - }-${gameData.expected_release_day ?? 1}`, - ).toJSDate(); + : new Date(); context?.progress(85); @@ -249,6 +250,8 @@ export class GiantBombProvider implements MetadataProvider { } } + const tags = (gameData.genres ?? []).map((e) => e.name); + const metadata: GameMetadata = { id: gameData.guid, name: gameData.name, @@ -256,7 +259,7 @@ export class GiantBombProvider implements MetadataProvider { description: longDescription, released: releaseDate, - tags: [], + tags, reviews, diff --git a/server/internal/metadata/igdb.ts b/server/internal/metadata/igdb.ts index 20f1a5f..52e47b2 100644 --- a/server/internal/metadata/igdb.ts +++ b/server/internal/metadata/igdb.ts @@ -450,7 +450,7 @@ export class IGDBProvider implements MetadataProvider { mReviewHref: currentGame.url, }; - const tags = await this.getGenres(currentGame.genres); + const genres = await this.getGenres(currentGame.genres); const deck = this.trimMessage(currentGame.summary, 280); @@ -461,12 +461,13 @@ export class IGDBProvider implements MetadataProvider { description: currentGame.summary, released, + genres, reviews: [review], publishers, developers, - tags, + tags: [], icon, bannerId: banner, diff --git a/server/internal/metadata/index.ts b/server/internal/metadata/index.ts index e202bc0..05a2267 100644 --- a/server/internal/metadata/index.ts +++ b/server/internal/metadata/index.ts @@ -18,6 +18,8 @@ import taskHandler, { wrapTaskContext } from "../tasks"; import { randomUUID } from "crypto"; import { fuzzy } from "fast-fuzzy"; import { logger } from "~/server/internal/logging"; +import libraryManager from "../library"; +import type { GameTagModel } from "~/prisma/client/models"; export class MissingMetadataProviderConfig extends Error { private providerName: string; @@ -124,19 +126,22 @@ export class MetadataHandler { ); } - private parseTags(tags: string[]) { - const results: Array = []; + private async parseTags(tags: string[]) { + const results: Array = []; - tags.forEach((t) => - results.push({ - where: { - name: t, - }, - create: { - name: t, - }, - }), - ); + for (const tag of tags) { + const rawResults: GameTagModel[] = + await prisma.$queryRaw`SELECT * FROM "GameTag" WHERE SIMILARITY(name, ${tag}) > 0.45;`; + let resultTag = rawResults.at(0); + if (!resultTag) { + resultTag = await prisma.gameTag.create({ + data: { + name: tag, + }, + }); + } + results.push(resultTag); + } return results; } @@ -180,6 +185,8 @@ export class MetadataHandler { }); if (existing) return undefined; + await libraryManager.lockGame(libraryId, libraryPath); + const gameId = randomUUID(); const taskId = `import:${gameId}`; @@ -262,7 +269,7 @@ export class MetadataHandler { connectOrCreate: metadataHandler.parseRatings(metadata.reviews), }, tags: { - connectOrCreate: metadataHandler.parseTags(metadata.tags), + connect: await metadataHandler.parseTags(metadata.tags), }, libraryId, @@ -271,6 +278,10 @@ export class MetadataHandler { }); logger.info(`Finished game import.`); + progress(100); + }, + async finally() { + await libraryManager.unlockGame(libraryId, libraryPath); }, }); diff --git a/server/internal/tasks/index.ts b/server/internal/tasks/index.ts index 44d8ef4..bb79301 100644 --- a/server/internal/tasks/index.ts +++ b/server/internal/tasks/index.ts @@ -206,6 +206,8 @@ class TaskHandler { }; } + if (task.finally) await task.finally(); + taskEntry.endTime = new Date().toISOString(); await updateAllClients(); @@ -427,6 +429,7 @@ export interface Task { taskGroup: TaskGroup; name: string; run: (context: TaskRunContext) => Promise; + finally?: () => Promise | void; acls: GlobalACL[]; } diff --git a/yarn.lock b/yarn.lock index 0d93798..d6bd269 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2436,6 +2436,11 @@ resolved "https://registry.yarnpkg.com/@types/turndown/-/turndown-5.0.5.tgz#614de24fc9ace4d8c0d9483ba81dc8c1976dd26f" integrity sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w== +"@types/web-bluetooth@^0.0.21": + version "0.0.21" + resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz#525433c784aed9b457aaa0ee3d92aeb71f346b63" + integrity sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA== + "@types/yauzl@^2.9.1": version "2.10.3" resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.3.tgz#e9b2808b4f109504a03cda958259876f61017999" @@ -2893,6 +2898,35 @@ resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.18.tgz#529f24a88d3ed678d50fd5c07455841fbe8ac95e" integrity sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA== +"@vueuse/core@13.6.0": + version "13.6.0" + resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-13.6.0.tgz#4137f63dc4cef2ff8ae74ee146d6b6070d707878" + integrity sha512-DJbD5fV86muVmBgS9QQPddVX7d9hWYswzlf4bIyUD2dj8GC46R1uNClZhVAmsdVts4xb2jwp1PbpuiA50Qee1A== + dependencies: + "@types/web-bluetooth" "^0.0.21" + "@vueuse/metadata" "13.6.0" + "@vueuse/shared" "13.6.0" + +"@vueuse/metadata@13.6.0": + version "13.6.0" + resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-13.6.0.tgz#49196025c96c7daeb591c20a54b61cc336af99b6" + integrity sha512-rnIH7JvU7NjrpexTsl2Iwv0V0yAx9cw7+clymjKuLSXG0QMcLD0LDgdNmXic+qL0SGvgSVPEpM9IDO/wqo1vkQ== + +"@vueuse/nuxt@13.6.0": + version "13.6.0" + resolved "https://registry.yarnpkg.com/@vueuse/nuxt/-/nuxt-13.6.0.tgz#96dfa26021bc17e1c5020c1c42ba425a9d00112f" + integrity sha512-zOZ5XkA7Svsx90934UWwKUsThAjKSD48Ks/mjEzl2gJm5d5zYJg+CJxPi7Wv5XECtCBOX18GpmTKqanWlbA1aQ== + dependencies: + "@nuxt/kit" "^4.0.1" + "@vueuse/core" "13.6.0" + "@vueuse/metadata" "13.6.0" + local-pkg "^1.1.1" + +"@vueuse/shared@13.6.0": + version "13.6.0" + resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-13.6.0.tgz#872fdbd725fb4e3a12bd5aab85af9a5db0b1e481" + integrity sha512-pDykCSoS2T3fsQrYqf9SyF0QXWHmcGPQ+qiOVjlYSzlWd9dgppB2bFSM1GgKKkt7uzn0BBMV3IbJsUfHG2+BCg== + "@whatwg-node/disposablestack@^0.0.6": version "0.0.6" resolved "https://registry.yarnpkg.com/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz#2064a1425ea66194def6df0c7a1851b6939c82bb" From e4c8d42cc8776bf987b708db45635882ad296ab6 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Thu, 31 Jul 2025 20:41:02 +1000 Subject: [PATCH 108/156] Setup wizard & 0.3.0 release (#146) * fix: small merge fixes * feat: initial setup wizard * fix: last few localization items * fix: lint * fix: bump version --- changelog.md | 8 +- components/LanguageSelector.vue | 153 +----------- components/LanguageSelectorListbox.vue | 155 ++++++++++++ components/Setup/Account.vue | 159 ++++++++++++ components/Setup/Library.vue | 15 ++ composables/request.ts | 2 +- i18n/locales/en_au.json | 6 +- i18n/locales/en_us.json | 32 +++ package.json | 2 +- pages/admin/library/sources/index.vue | 11 +- pages/auth/register.vue | 4 + pages/news.vue | 1 - pages/setup.vue | 227 ++++++++++++++++++ prisma/schema/content.prisma | 101 -------- server/api/v1/admin/auth/index.get.ts | 2 +- .../v1/admin/auth/invitation/index.post.ts | 1 + server/api/v1/admin/index.get.ts | 7 + .../v1/admin/library/sources/index.delete.ts | 1 + .../api/v1/admin/library/sources/index.get.ts | 5 +- .../v1/admin/library/sources/index.patch.ts | 1 + .../v1/admin/library/sources/index.post.ts | 1 + server/api/v1/setup.post.ts | 20 ++ server/internal/acls/descriptions.ts | 3 + server/internal/acls/index.ts | 14 +- server/plugins/02.setup-admin.ts | 32 ++- 25 files changed, 684 insertions(+), 279 deletions(-) create mode 100644 components/LanguageSelectorListbox.vue create mode 100644 components/Setup/Account.vue create mode 100644 components/Setup/Library.vue create mode 100644 pages/setup.vue delete mode 100644 prisma/schema/content.prisma create mode 100644 server/api/v1/admin/index.get.ts create mode 100644 server/api/v1/setup.post.ts diff --git a/changelog.md b/changelog.md index 18266e7..ac147b2 100644 --- a/changelog.md +++ b/changelog.md @@ -193,6 +193,7 @@ _changelog generated by_ [go-conventional-commits](https://github.com/joselitofi ## Release 0.2.0-beta ### Fixes + - fix recursive dirs util #02d6346 - Fix username length requirement #0a5a649 - remove dynamic imports #0f10626 @@ -223,8 +224,8 @@ _changelog generated by_ [go-conventional-commits](https://github.com/joselitofi - fix FATAL: "root"... message #dbb315a - only show versions that are directories #ef8f3ae - ### Features + - update prisma & delete games #089c3e0 - manual handshake #12e3125 - fetch game endpoint #1f4d075 @@ -271,9 +272,9 @@ _changelog generated by_ [go-conventional-commits](https://github.com/joselitofi - add support for overriding UMU id #fd4a7d1 - add .sh for linux #fe9373a - ### Other Changes -- quexeky + +- quexeky - fixed manifest generation #03a37f7 - manual ci/cd #03b0b0c - ability to fetch client certs for p2p #0a715fe @@ -379,7 +380,6 @@ _changelog generated by_ [go-conventional-commits](https://github.com/joselitofi - better server side signin redirects #ef13b68 - patch signin #f3672f8 - _changelog generated by_ [go-conventional-commits](https://github.com/joselitofilho/go-conventional-commits) ## Release 0.1.0-beta diff --git a/components/LanguageSelector.vue b/components/LanguageSelector.vue index 496f8e1..9d873c1 100644 --- a/components/LanguageSelector.vue +++ b/components/LanguageSelector.vue @@ -1,80 +1,6 @@ - - diff --git a/components/LanguageSelectorListbox.vue b/components/LanguageSelectorListbox.vue new file mode 100644 index 0000000..b86bf82 --- /dev/null +++ b/components/LanguageSelectorListbox.vue @@ -0,0 +1,155 @@ + + + diff --git a/components/Setup/Account.vue b/components/Setup/Account.vue new file mode 100644 index 0000000..017125b --- /dev/null +++ b/components/Setup/Account.vue @@ -0,0 +1,159 @@ + + + diff --git a/components/Setup/Library.vue b/components/Setup/Library.vue new file mode 100644 index 0000000..3d4b3f5 --- /dev/null +++ b/components/Setup/Library.vue @@ -0,0 +1,15 @@ + + + diff --git a/composables/request.ts b/composables/request.ts index 963de6d..8396b8d 100644 --- a/composables/request.ts +++ b/composables/request.ts @@ -67,7 +67,7 @@ export const $dropFetch: DropFetch = async (rawRequest, opts) => { try { const data = await $fetch(request, { ...opts, - headers: { ...opts?.headers, ...headers }, + headers: { ...headers, ...opts?.headers }, }); if (import.meta.server) state.value = data; return data; diff --git a/i18n/locales/en_au.json b/i18n/locales/en_au.json index 0967ef4..27d8b00 100644 --- a/i18n/locales/en_au.json +++ b/i18n/locales/en_au.json @@ -1 +1,5 @@ -{} +{ + "setup": { + "welcome": "G'day." + } +} diff --git a/i18n/locales/en_us.json b/i18n/locales/en_us.json index 67408f9..58e16c9 100644 --- a/i18n/locales/en_us.json +++ b/i18n/locales/en_us.json @@ -63,6 +63,38 @@ "signout": "Signout", "username": "Username" }, + "setup": { + "welcome": "Hey there.", + "welcomeDescription": "Welcome to Drop setup wizard. It will walk you through configuring Drop for the first time, and how it works.", + "finish": "Let's go {arrow}", + "noPage": "no page", + "auth": { + "title": "Authentication", + "description": "Authentication in Drop happens through multiple configured 'providers'. Each one can allow users to sign-in through their method. To get started, have at least one authentication provider enabled, and create an account through it.", + "docs": "Documentation {arrow}", + "enabled": "Enabled?", + "simple": { + "title": "Simple authentication", + "description": "Simple authentication uses username/password to authentication users. It is enabled by default if no other authentication provider is enabled.", + "register": "Register as admin {arrow}" + }, + "openid": { + "title": "OpenID Connect", + "description": "OpenID Connect (OIDC) is an OAuth2 extension commonly supported. Drop requires OIDC configuration to be done via environment variables.", + "skip": "I have a user with OIDC" + } + }, + "stages": { + "account": { + "name": "Setup your admin account.", + "description": "You need at least one account to start using Drop." + }, + "library": { + "name": "Create a library.", + "description": "Add at least one library source to use Drop." + } + } + }, "cancel": "Cancel", "chars": { "arrow": "→", diff --git a/package.json b/package.json index 84a1836..77fb6c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "drop", - "version": "0.3.0-alpha.0", + "version": "0.3.0", "private": true, "type": "module", "license": "AGPL-3.0-or-later", diff --git a/pages/admin/library/sources/index.vue b/pages/admin/library/sources/index.vue index 251da85..d7b6c14 100644 --- a/pages/admin/library/sources/index.vue +++ b/pages/admin/library/sources/index.vue @@ -296,8 +296,15 @@ useHead({ const { t } = useI18n(); +// Optional token for setup wizard +const { token = undefined } = defineProps<{ token?: string }>(); + +const headers = token ? { Authorization: token } : undefined; + const sources = ref( - await $dropFetch("/api/v1/admin/library/sources"), + await $dropFetch("/api/v1/admin/library/sources", { + headers, + }), ); const editIndex = ref(undefined); @@ -345,6 +352,7 @@ async function performActionSource() { options: sourceConfig.value, }, method: createMode ? "POST" : "PATCH", + headers, }, ); if (createMode) { @@ -394,6 +402,7 @@ async function deleteSource(index: number) { await $dropFetch("/api/v1/admin/library/sources", { method: "DELETE", body: { id: source.id }, + headers, }); } catch (e) { createModal( diff --git a/pages/auth/register.vue b/pages/auth/register.vue index d2820ae..0784eb1 100644 --- a/pages/auth/register.vue +++ b/pages/auth/register.vue @@ -264,6 +264,10 @@ function register_wrapper() { loading.value = true; register() .then(() => { + if (route.query.after == "close") { + window.close(); + return; + } router.push("/auth/signin"); }) .catch((response) => { diff --git a/pages/news.vue b/pages/news.vue index a984018..ee9fac4 100644 --- a/pages/news.vue +++ b/pages/news.vue @@ -105,7 +105,6 @@ const news = useNews(); if (!news.value) { await fetchNews(); - console.log("fetched news"); } const { t } = useI18n(); diff --git a/pages/setup.vue b/pages/setup.vue new file mode 100644 index 0000000..2bf06f8 --- /dev/null +++ b/pages/setup.vue @@ -0,0 +1,227 @@ + + + + + diff --git a/prisma/schema/content.prisma b/prisma/schema/content.prisma deleted file mode 100644 index f64fdac..0000000 --- a/prisma/schema/content.prisma +++ /dev/null @@ -1,101 +0,0 @@ -enum MetadataSource { - Manual - GiantBomb -} - -model Game { - id String @id @default(uuid()) - - metadataSource MetadataSource - metadataId String - created DateTime @default(now()) - - // Any field prefixed with m is filled in from metadata - // Acts as a cache so we can search and filter it - mName String // Name of game - mShortDescription String // Short description - mDescription String // Supports markdown - mDevelopers Developer[] - mPublishers Publisher[] - mReleased DateTime // When the game was released - - mReviewCount Int - mReviewRating Float // 0 to 1 - - mIconId String // linked to objects in s3 - mBannerId String // linked to objects in s3 - mCoverId String - mImageCarousel String[] // linked to below array - mImageLibrary String[] // linked to objects in s3 - - versions GameVersion[] - libraryBasePath String @unique // Base dir for all the game versions - - collections CollectionEntry[] - - @@unique([metadataSource, metadataId], name: "metadataKey") -} - -// A particular set of files that relate to the version -model GameVersion { - gameId String - game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) - versionName String // Sub directory for the game files - - created DateTime @default(now()) - - platform Platform - - launchCommand String @default("") // Command to run to start. Platform-specific. Windows games on Linux will wrap this command in Proton/Wine - launchArgs String[] - setupCommand String @default("") // Command to setup game (dependencies and such) - setupArgs String[] - onlySetup Boolean @default(false) - - umuIdOverride String? - - dropletManifest Json // Results from droplet - - versionIndex Int - delta Boolean @default(false) - - @@id([gameId, versionName]) -} - -model Developer { - id String @id @default(uuid()) - - metadataSource MetadataSource - metadataId String - metadataOriginalQuery String - - mName String - mShortDescription String - mDescription String - mLogo String - mBanner String - mWebsite String - - games Game[] - - @@unique([metadataSource, metadataId, metadataOriginalQuery], name: "metadataKey") -} - -model Publisher { - id String @id @default(uuid()) - - metadataSource MetadataSource - metadataId String - metadataOriginalQuery String - - mName String - mShortDescription String - mDescription String - mLogo String - mBanner String - mWebsite String - - games Game[] - - @@unique([metadataSource, metadataId, metadataOriginalQuery], name: "metadataKey") -} diff --git a/server/api/v1/admin/auth/index.get.ts b/server/api/v1/admin/auth/index.get.ts index 6d975e9..4ea1523 100644 --- a/server/api/v1/admin/auth/index.get.ts +++ b/server/api/v1/admin/auth/index.get.ts @@ -3,7 +3,7 @@ import aclManager from "~/server/internal/acls"; import authManager from "~/server/internal/auth"; export default defineEventHandler(async (h3) => { - const allowed = await aclManager.allowSystemACL(h3, ["auth:read"]); + const allowed = await aclManager.allowSystemACL(h3, ["auth:read", "setup"]); if (!allowed) throw createError({ statusCode: 403 }); const enabledAuthManagers = authManager.getAuthProviders(); diff --git a/server/api/v1/admin/auth/invitation/index.post.ts b/server/api/v1/admin/auth/invitation/index.post.ts index 50696c2..69d3649 100644 --- a/server/api/v1/admin/auth/invitation/index.post.ts +++ b/server/api/v1/admin/auth/invitation/index.post.ts @@ -14,6 +14,7 @@ const CreateInvite = SharedRegisterValidator.partial() export default defineEventHandler(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, [ "auth:simple:invitation:new", + "setup", ]); if (!allowed) throw createError({ statusCode: 403 }); diff --git a/server/api/v1/admin/index.get.ts b/server/api/v1/admin/index.get.ts new file mode 100644 index 0000000..9d52d0f --- /dev/null +++ b/server/api/v1/admin/index.get.ts @@ -0,0 +1,7 @@ +import aclManager from "~/server/internal/acls"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, []); + if (!allowed) return false; + return true; +}); diff --git a/server/api/v1/admin/library/sources/index.delete.ts b/server/api/v1/admin/library/sources/index.delete.ts index 8067f64..165a9b2 100644 --- a/server/api/v1/admin/library/sources/index.delete.ts +++ b/server/api/v1/admin/library/sources/index.delete.ts @@ -12,6 +12,7 @@ export default defineEventHandler<{ body: typeof DeleteLibrarySource.infer }>( async (h3) => { const allowed = await aclManager.allowSystemACL(h3, [ "library:sources:delete", + "setup", ]); if (!allowed) throw createError({ statusCode: 403 }); diff --git a/server/api/v1/admin/library/sources/index.get.ts b/server/api/v1/admin/library/sources/index.get.ts index 573be59..bf56134 100644 --- a/server/api/v1/admin/library/sources/index.get.ts +++ b/server/api/v1/admin/library/sources/index.get.ts @@ -5,7 +5,10 @@ import libraryManager from "~/server/internal/library"; export type WorkingLibrarySource = LibraryModel & { working: boolean }; export default defineEventHandler(async (h3) => { - const allowed = await aclManager.allowSystemACL(h3, ["library:sources:read"]); + const allowed = await aclManager.allowSystemACL(h3, [ + "library:sources:read", + "setup", + ]); if (!allowed) throw createError({ statusCode: 403 }); const sources = await libraryManager.fetchLibraries(); diff --git a/server/api/v1/admin/library/sources/index.patch.ts b/server/api/v1/admin/library/sources/index.patch.ts index be0ccc7..f098b7a 100644 --- a/server/api/v1/admin/library/sources/index.patch.ts +++ b/server/api/v1/admin/library/sources/index.patch.ts @@ -16,6 +16,7 @@ export default defineEventHandler<{ body: typeof UpdateLibrarySource.infer }>( async (h3) => { const allowed = await aclManager.allowSystemACL(h3, [ "library:sources:update", + "setup", ]); if (!allowed) throw createError({ statusCode: 403 }); diff --git a/server/api/v1/admin/library/sources/index.post.ts b/server/api/v1/admin/library/sources/index.post.ts index 31a703f..ae32b75 100644 --- a/server/api/v1/admin/library/sources/index.post.ts +++ b/server/api/v1/admin/library/sources/index.post.ts @@ -18,6 +18,7 @@ export default defineEventHandler<{ body: typeof CreateLibrarySource.infer }>( async (h3) => { const allowed = await aclManager.allowSystemACL(h3, [ "library:sources:new", + "setup", ]); if (!allowed) throw createError({ statusCode: 403 }); diff --git a/server/api/v1/setup.post.ts b/server/api/v1/setup.post.ts new file mode 100644 index 0000000..acd83f6 --- /dev/null +++ b/server/api/v1/setup.post.ts @@ -0,0 +1,20 @@ +import { APITokenMode } from "~/prisma/client/enums"; +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["setup"]); + if (!allowed) + throw createError({ + statusCode: 403, + statusMessage: "Must use a setup token.", + }); + await prisma.aPIToken.deleteMany({ + where: { + mode: APITokenMode.System, + acls: { + hasSome: ["setup"], + }, + }, + }); +}); diff --git a/server/internal/acls/descriptions.ts b/server/internal/acls/descriptions.ts index 98b919d..ebb8d50 100644 --- a/server/internal/acls/descriptions.ts +++ b/server/internal/acls/descriptions.ts @@ -44,6 +44,9 @@ export const userACLDescriptions: ObjectFromList = { }; export const systemACLDescriptions: ObjectFromList = { + setup: + "All permissions required to setup a new Drop instance (setup wizard).", + "auth:read": "Fetch the list of enabled authentication mechanisms configured.", "auth:simple:invitation:read": "Fetch simple auth invitations.", diff --git a/server/internal/acls/index.ts b/server/internal/acls/index.ts index 2c2f1c6..d6997c5 100644 --- a/server/internal/acls/index.ts +++ b/server/internal/acls/index.ts @@ -41,6 +41,8 @@ const userACLPrefix = "user:"; export type UserACL = Array<(typeof userACLs)[number]>; export const systemACLs = [ + "setup", + "auth:read", "auth:simple:invitation:read", "auth:simple:invitation:new", @@ -167,9 +169,11 @@ class ACLManager { const user = await prisma.user.findUnique({ where: { id: userSession.userId }, }); - if (!user) return false; - if (user.admin) return true; - return false; + if (user) { + if (!user) return false; + if (user.admin) return true; + return false; + } } const authorizationToken = this.getAuthorizationToken(request); @@ -179,6 +183,10 @@ class ACLManager { }); if (!token) return false; if (token.mode != APITokenMode.System) return false; + + // If empty, we just want to check we are an admin *at all*, not specific ACLs + if (acls.length == 0) return true; + for (const acl of acls) { const tokenACLIndex = token.acls.findIndex((e) => e == acl); if (tokenACLIndex != -1) return true; diff --git a/server/plugins/02.setup-admin.ts b/server/plugins/02.setup-admin.ts index 1d4a128..25a8704 100644 --- a/server/plugins/02.setup-admin.ts +++ b/server/plugins/02.setup-admin.ts @@ -1,6 +1,18 @@ +import { APITokenMode } from "~/prisma/client/enums"; import prisma from "~/server/internal/db/database"; +import { systemConfig } from "../internal/config/sys-conf"; +import { logger } from "../internal/logging"; export default defineNitroPlugin(async (_nitro) => { + await prisma.aPIToken.deleteMany({ + where: { + acls: { + hasSome: ["setup"], + }, + mode: APITokenMode.System, + }, + }); + const userCount = await prisma.user.count({ where: { id: { not: "system" } }, }); @@ -10,18 +22,14 @@ export default defineNitroPlugin(async (_nitro) => { // but has not been configured // so it should be in-place - // Create admin invitation - await prisma.invitation.upsert({ - where: { - id: "admin", - }, - create: { - id: "admin", - isAdmin: true, - expires: new Date("4096-01-01"), - }, - update: { - isAdmin: true, + const token = await prisma.aPIToken.create({ + data: { + name: "Setup Wizard", + mode: APITokenMode.System, + acls: ["setup"], }, }); + + const setupUrl = `${systemConfig.getExternalUrl()}/setup?token=${token.token}`; + logger.info(`Open ${setupUrl} in a browser to get started with Drop.`); }); From 4e901164fb46027810949e62f616b80bd0b51e7d Mon Sep 17 00:00:00 2001 From: DecDuck Date: Thu, 31 Jul 2025 21:36:01 +1000 Subject: [PATCH 109/156] Fix Github Release CI (#144) * fix: attempt fix from https://github.com/reproducible-containers/buildkit-cache-dance * fix: lint * fix: migrate to pnpm to see if it builds * fix: comment out unified deps builder * fix: remove dependency on deps * fix: shamefully hoist deps --- .github/workflows/release.yml | 16 ++++++++++++++++ Dockerfile | 31 ++++++++++++++++++------------- build/launch.sh | 2 +- package.json | 1 - yarn.lock | 2 +- 5 files changed, 36 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed29fb5..9bb632b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,7 @@ jobs: uses: actions/checkout@v4 with: submodules: true + fetch-depth: 1 # fix for when this gets triggered by tag fetch-tags: true token: ${{ secrets.GITHUB_TOKEN }} @@ -78,6 +79,21 @@ jobs: # set latest tag for stable releases type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }} + - name: Cache + uses: actions/cache@v4 + id: cache + with: + path: cache-mount + key: cache-mount-${{ hashFiles('Dockerfile') }} + + - name: Restore Docker cache mounts + uses: reproducible-containers/buildkit-cache-dance@v3 + with: + builder: ${{ steps.setup-buildx.outputs.name }} + cache-dir: cache-mount + dockerfile: Dockerfile + skip-extraction: ${{ steps.cache.outputs.cache-hit }} + - name: Build and push image id: build-and-push uses: docker/build-push-action@v6 diff --git a/Dockerfile b/Dockerfile index 19e6a84..5eb5512 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,42 +1,47 @@ # syntax=docker/dockerfile:1 -# Unified deps builder -FROM node:lts-alpine AS deps -WORKDIR /app -COPY package.json yarn.lock ./ -RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --network-timeout 1000000 --ignore-scripts +### Unified deps builder +# FROM node:lts-alpine AS deps +# WORKDIR /app +# COPY package.json yarn.lock ./ +# RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --network-timeout 1000000 --ignore-scripts -# Build for app +### 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 ENV NODE_ENV=production ENV NUXT_TELEMETRY_DISABLED=1 -ENV YARN_CACHE_FOLDER=/root/.yarn +# ENV YARN_CACHE_FOLDER=/root/.yarn # add git so drop can determine its git ref at build -RUN apk add --no-cache git +# pnpm for build +RUN apk add --no-cache git pnpm # copy deps and rest of project files -COPY --from=deps /app/node_modules ./node_modules +# COPY --from=deps /app/node_modules ./node_modules COPY . . ARG BUILD_DROP_VERSION ARG BUILD_GIT_REF # build -RUN --mount=type=cache,target=/root/.yarn yarn postinstall && \ - yarn build +RUN pnpm import +RUN pnpm install --shamefully-hoist +RUN pnpm run build +# RUN --mount=type=cache,target=/root/.yarn yarn postinstall && yarn build -# create run environment for Drop +### create run environment for Drop FROM node:lts-alpine AS run-system WORKDIR /app ENV NODE_ENV=production ENV NUXT_TELEMETRY_DISABLED=1 -RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn add --network-timeout 1000000 --no-lockfile --ignore-scripts prisma@6.11.1 +# RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn add --network-timeout 1000000 --no-lockfile --ignore-scripts prisma@6.11.1 +RUN apk add --no-cache pnpm +RUN pnpm install prisma@6.11.1 COPY --from=build-system /app/package.json ./ COPY --from=build-system /app/.output ./app diff --git a/build/launch.sh b/build/launch.sh index 173b311..4a44e90 100755 --- a/build/launch.sh +++ b/build/launch.sh @@ -2,7 +2,7 @@ # This file starts up the Drop server by running migrations and then starting the executable echo "[Drop] performing migrations..." -yarn prisma migrate deploy +pnpm prisma migrate deploy # Actually start the application node /app/app/server/index.mjs diff --git a/package.json b/package.json index 77fb6c6..3a06bab 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,6 @@ "typescript": "^5.8.3", "vue-tsc": "^3.0.1" }, - "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e", "overrides": { "vue3-carousel-nuxt": { "vue3-carousel": "^0.16.0" diff --git a/yarn.lock b/yarn.lock index d6bd269..f0f5195 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2898,7 +2898,7 @@ resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.18.tgz#529f24a88d3ed678d50fd5c07455841fbe8ac95e" integrity sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA== -"@vueuse/core@13.6.0": +"@vueuse/core@13.6.0", "@vueuse/core@^13.6.0": version "13.6.0" resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-13.6.0.tgz#4137f63dc4cef2ff8ae74ee146d6b6070d707878" integrity sha512-DJbD5fV86muVmBgS9QQPddVX7d9hWYswzlf4bIyUD2dj8GC46R1uNClZhVAmsdVts4xb2jwp1PbpuiA50Qee1A== From 91bb7c7dd437dd215d1cc38b05f52900ef5cc23e Mon Sep 17 00:00:00 2001 From: DecDuck Date: Thu, 31 Jul 2025 21:39:01 +1000 Subject: [PATCH 110/156] Fix for Release CI --- .github/workflows/release.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9bb632b..e244c81 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,8 +23,9 @@ jobs: uses: actions/checkout@v4 with: submodules: true - fetch-depth: 1 # fix for when this gets triggered by tag + fetch-depth: 3 # fix for when this gets triggered by tag fetch-tags: true + ref: ${{ github.ref }} token: ${{ secrets.GITHUB_TOKEN }} - name: Determine final version From 371e069e204592977ec49f002e839f1d102397df Mon Sep 17 00:00:00 2001 From: DecDuck Date: Thu, 31 Jul 2025 21:44:25 +1000 Subject: [PATCH 111/156] Fix for invalid route in setup wizard --- pages/setup.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/setup.vue b/pages/setup.vue index 2bf06f8..9e0e5ed 100644 --- a/pages/setup.vue +++ b/pages/setup.vue @@ -210,7 +210,7 @@ async function finish() { headers: { Authorization: bearerToken }, method: "POST", }); - router.push("/signin"); + router.push("/auth/signin"); } From 00224bdd2f170ea5b5de13795b83b34e2a03473f Mon Sep 17 00:00:00 2001 From: DecDuck Date: Thu, 31 Jul 2025 21:49:15 +1000 Subject: [PATCH 112/156] Fix for incorrect description on company page --- pages/store/c/[id]/index.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/store/c/[id]/index.vue b/pages/store/c/[id]/index.vue index a4c62c3..b5dfd54 100644 --- a/pages/store/c/[id]/index.vue +++ b/pages/store/c/[id]/index.vue @@ -28,7 +28,7 @@ {{ company.mName }}

    - {{ company.mDescription }} + {{ company.mShortDescription }}

    From 786ad0ff82e32c7731a1046f2dd9599bc309d71a Mon Sep 17 00:00:00 2001 From: DecDuck Date: Fri, 1 Aug 2025 10:38:53 +1000 Subject: [PATCH 113/156] Translations update from Weblate (#107) * Translated using Weblate (English (Australia)) Currently translated at 1.6% (6 of 375 strings) Translation: Drop/Drop Translate-URL: http://translate.droposs.org/projects/drop/drop/en_AU/ * Translated using Weblate (English (en_PIRATE)) Currently translated at 99.4% (373 of 375 strings) Translation: Drop/Drop Translate-URL: http://translate.droposs.org/projects/drop/drop/en_PIRATE/ * Translated using Weblate (English (en_PIRATE)) Currently translated at 99.4% (373 of 375 strings) Translation: Drop/Drop Translate-URL: http://translate.droposs.org/projects/drop/drop/en_PIRATE/ * Translated using Weblate (English (en_PIRATE)) Currently translated at 100.0% (375 of 375 strings) Translation: Drop/Drop Translate-URL: http://translate.droposs.org/projects/drop/drop/en_PIRATE/ --------- Co-authored-by: Weblate Translation Memory --- i18n/locales/en_au.json | 18 ++++++++++++++++++ i18n/locales/en_pirate.json | 27 +++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/i18n/locales/en_au.json b/i18n/locales/en_au.json index 27d8b00..4965c94 100644 --- a/i18n/locales/en_au.json +++ b/i18n/locales/en_au.json @@ -1,5 +1,23 @@ { "setup": { "welcome": "G'day." + }, + "account": { + "devices": { + "subheader": "Manage the devices authorised to access your Drop account." + } + }, + "auth": { + "callback": { + "authClient": "Authorise client?", + "authorize": "Authorise", + "authorizedClient": "Drop has successfully authorised the client. You may now close this window." + } + }, + "library": { + "collection": { + "subheader": "Add a new collection to organise your games" + }, + "subheader": "Organise your games into collections for easy access, and access all your games." } } diff --git a/i18n/locales/en_pirate.json b/i18n/locales/en_pirate.json index 36001de..e93be77 100644 --- a/i18n/locales/en_pirate.json +++ b/i18n/locales/en_pirate.json @@ -109,6 +109,25 @@ "listItemPlaceholder": "list item, eh?" }, "errors": { + "admin": { + "user": { + "delete": { + "desc": "Couldn't make {0} walk the plank!", + "title": "Couldn't make 'em walk the plank" + } + } + }, + "auth": { + "disabled": "Ya' angered the Cap'n, give him a holler!", + "invalidInvite": "Boarding pass no longer valid, ya scallywag!", + "invalidPassState": "Cap'n's rolled snake eyes, see how he's doin'.", + "invalidUserOrPass": "Are ya lying to me about your username and password, ya dog?", + "inviteIdRequired": "Ya need to include the ID for your boarding pass, matey!", + "method": { + "signinDisabled": "No entrance through these parts, arr!" + }, + "usernameTaken": "We already have a scallywag with that 'ere name of yours." + }, "backHome": "{arrow} Back to yer safe harbor", "game": { "banner": { @@ -203,7 +222,8 @@ "discord": "Discord, argh!", "github": "GitHub, savvy?" }, - "topSellers": "Top Plunderers" + "topSellers": "Top Plunderers", + "version": "Drop {version} {gitRef}" }, "header": { "admin": { @@ -401,7 +421,8 @@ "cleanupSessionsDescription": "Cleans up expired sessions to save space and keep ye safe, ye dog!", "cleanupSessionsName": "Clean up sessions." }, - "viewTask": "View {arrow}" + "viewTask": "View {arrow}", + "weeklyScheduledTitle": "Weekly chores" } }, "title": "Drop", @@ -439,6 +460,8 @@ "title": "Passage" }, "authoptionsHeader": "Passage Options", + "delete": "Scuttle!", + "deleteUser": "Make {0} walk the plank!", "description": "Manage the crew on yer Drop vessel, and set yer passage methods, savvy?", "displayNameHeader": "Scallywag Name", "emailHeader": "Salty Mail", From b72e1ef7a45ea16bee1da6271daae70966fcc522 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Fri, 1 Aug 2025 13:11:56 +1000 Subject: [PATCH 114/156] Code-based authorization for Drop clients (#145) * feat: code-based authorization * fix: final touches * fix: require session on code fetch endpoint * feat: better error handling * refactor: move auth send to client handler * fix: lint --- assets/tailwindcss.css | 9 ++ components/UserHeader/UserWidget.vue | 5 + i18n/locales/en_us.json | 4 + .../{[id]/callback.vue => authorize/[id].vue} | 79 ++++++---- pages/client/code/index.vue | 135 ++++++++++++++++++ .../api/v1/client/auth/callback/index.post.ts | 10 +- server/api/v1/client/auth/code/index.get.ts | 21 +++ server/api/v1/client/auth/code/index.post.ts | 35 +++++ server/api/v1/client/auth/code/ws.get.ts | 25 ++++ .../client/auth/{callback => }/index.get.ts | 12 +- server/api/v1/client/auth/initiate.post.ts | 35 +++-- server/internal/clients/handler.ts | 77 +++++++++- server/internal/tasks/index.ts | 1 + 13 files changed, 396 insertions(+), 52 deletions(-) rename pages/client/{[id]/callback.vue => authorize/[id].vue} (78%) create mode 100644 pages/client/code/index.vue create mode 100644 server/api/v1/client/auth/code/index.get.ts create mode 100644 server/api/v1/client/auth/code/index.post.ts create mode 100644 server/api/v1/client/auth/code/ws.get.ts rename server/api/v1/client/auth/{callback => }/index.get.ts (71%) diff --git a/assets/tailwindcss.css b/assets/tailwindcss.css index 5c2596b..97cf5d4 100644 --- a/assets/tailwindcss.css +++ b/assets/tailwindcss.css @@ -2,3 +2,12 @@ @plugin "@tailwindcss/typography"; @plugin "@tailwindcss/forms"; @config "../tailwind.config.js"; + +@layer base { + input[type="number"]::-webkit-outer-spin-button, + input[type="number"]::-webkit-inner-spin-button, + input[type="number"] { + -webkit-appearance: none; + -moz-appearance: textfield !important; + } +} diff --git a/components/UserHeader/UserWidget.vue b/components/UserHeader/UserWidget.vue index 35e238d..cf75bba 100644 --- a/components/UserHeader/UserWidget.vue +++ b/components/UserHeader/UserWidget.vue @@ -100,6 +100,11 @@ const navigation = computed(() => route: "/account", prefix: "", }, + { + label: "Authorize client", + route: "/client/code", + prefix: "", + }, ].filter((e) => e !== undefined), ); diff --git a/i18n/locales/en_us.json b/i18n/locales/en_us.json index 58e16c9..84097d8 100644 --- a/i18n/locales/en_us.json +++ b/i18n/locales/en_us.json @@ -38,6 +38,10 @@ "requestedAccess": "\"{name}\" has requested access to your Drop account.", "success": "Successful!" }, + "code": { + "title": "Connect your Drop client", + "description": "Use a code to connect your Drop client if you are unable to open a web browser on your device." + }, "confirmPassword": "Confirm @:auth.password", "displayName": "Display Name", "email": "Email", diff --git a/pages/client/[id]/callback.vue b/pages/client/authorize/[id].vue similarity index 78% rename from pages/client/[id]/callback.vue rename to pages/client/authorize/[id].vue index 71277b8..a3d2a9d 100644 --- a/pages/client/[id]/callback.vue +++ b/pages/client/authorize/[id].vue @@ -1,7 +1,7 @@