From 090d2e6586affbff9e1080bfb599dcb120edd3e2 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Tue, 4 Feb 2025 13:15:34 +1100 Subject: [PATCH] feat(acls): added backend acls --- drop-base | 2 +- pages/admin/library/[id]/index.vue | 2 +- .../20250204010021_add_tokens/migration.sql | 15 + .../migration.sql | 5 + prisma/schema/auth.prisma | 15 + prisma/schema/collection.prisma | 2 +- prisma/schema/user.prisma | 2 + .../v1/admin/auth/invitation/index.delete.ts | 7 +- .../api/v1/admin/auth/invitation/index.get.ts | 7 +- .../v1/admin/auth/invitation/index.post.ts | 9 +- .../api/v1/admin/game/image/index.delete.ts | 7 +- server/api/v1/admin/game/image/index.post.ts | 7 +- server/api/v1/admin/game/index.delete.ts | 7 +- server/api/v1/admin/game/index.get.ts | 7 +- server/api/v1/admin/game/index.patch.ts | 7 +- server/api/v1/admin/game/metadata.post.ts | 7 +- .../api/v1/admin/game/version/index.delete.ts | 7 +- .../version/{index.post.ts => index.patch.ts} | 7 +- server/api/v1/admin/import/game/index.get.ts | 7 +- server/api/v1/admin/import/game/index.post.ts | 7 +- server/api/v1/admin/import/game/search.get.ts | 7 +- .../api/v1/admin/import/version/index.get.ts | 7 +- .../api/v1/admin/import/version/index.post.ts | 7 +- .../v1/admin/import/version/preload.get.ts | 7 +- server/api/v1/admin/index.get.ts | 6 - server/api/v1/admin/library/index.get.ts | 5 +- server/api/v1/{ => admin}/news/[id].delete.ts | 0 server/api/v1/{ => admin}/news/index.post.ts | 0 server/api/v1/admin/user/index.get.ts | 5 +- server/api/v1/auth/signin/simple.post.ts | 3 +- .../api/v1/client/auth/callback/index.get.ts | 3 +- .../api/v1/client/auth/callback/index.post.ts | 3 +- server/api/v1/collection/[id]/entry.delete.ts | 4 +- server/api/v1/collection/[id]/entry.post.ts | 4 +- server/api/v1/collection/[id]/index.delete.ts | 3 +- server/api/v1/collection/[id]/index.get.ts | 3 +- .../api/v1/collection/default/entry.delete.ts | 3 +- .../api/v1/collection/default/entry.post.ts | 3 +- server/api/v1/collection/default/index.get.ts | 3 +- server/api/v1/collection/index.get.ts | 4 +- server/api/v1/collection/index.post.ts | 4 +- server/api/v1/games/[id]/index.get.ts | 3 +- .../news/{[id].get.ts => [id]/index.get.ts} | 0 .../api/v1/notifications/[id]/index.delete.ts | 3 +- server/api/v1/notifications/[id]/index.get.ts | 3 +- server/api/v1/notifications/[id]/read.post.ts | 3 +- server/api/v1/notifications/index.get.ts | 3 +- server/api/v1/notifications/readall.post.ts | 3 +- server/api/v1/notifications/ws.get.ts | 15 +- server/api/v1/object/[id]/index.delete.ts | 4 +- server/api/v1/object/[id]/index.get.ts | 4 +- server/api/v1/object/[id]/index.post.ts | 4 +- server/api/v1/store/developers.ts | 3 +- server/api/v1/store/publishers.ts | 3 +- server/api/v1/store/recent.get.ts | 3 +- server/api/v1/store/released.get.ts | 3 +- server/api/v1/store/updated.get.ts | 3 +- server/api/v1/task/index.get.ts | 29 +- server/api/v1/user/index.get.ts | 4 +- server/h3.d.ts | 6 +- server/internal/acls/index.ts | 152 +++++++++ server/internal/applibrary/README.md | 11 - server/internal/applibrary/index.ts | 309 ------------------ server/internal/library/index.ts | 2 +- server/internal/objects/index.ts | 2 +- server/internal/session/index.ts | 41 +-- server/internal/tasks/index.ts | 20 +- server/plugins/redirect.ts | 4 +- server/plugins/session.ts | 7 - server/routes/signout.get.ts | 4 +- 70 files changed, 397 insertions(+), 474 deletions(-) create mode 100644 prisma/migrations/20250204010021_add_tokens/migration.sql create mode 100644 prisma/migrations/20250204020918_add_collection_entry_casacade_delete/migration.sql rename server/api/v1/admin/game/version/{index.post.ts => index.patch.ts} (83%) delete mode 100644 server/api/v1/admin/index.get.ts rename server/api/v1/{ => admin}/news/[id].delete.ts (100%) rename server/api/v1/{ => admin}/news/index.post.ts (100%) rename server/api/v1/news/{[id].get.ts => [id]/index.get.ts} (100%) create mode 100644 server/internal/acls/index.ts delete mode 100644 server/internal/applibrary/README.md delete mode 100644 server/internal/applibrary/index.ts delete mode 100644 server/plugins/session.ts diff --git a/drop-base b/drop-base index 533eb48..637b4e1 160000 --- a/drop-base +++ b/drop-base @@ -1 +1 @@ -Subproject commit 533eb483eac6cddcc18e1b2a0de3364353997535 +Subproject commit 637b4e1e9b943605e9f25234dd1f879d5a58b493 diff --git a/pages/admin/library/[id]/index.vue b/pages/admin/library/[id]/index.vue index 328ae41..d62553b 100644 --- a/pages/admin/library/[id]/index.vue +++ b/pages/admin/library/[id]/index.vue @@ -785,7 +785,7 @@ async function deleteVersion(versionName: string) { async function updateVersionOrder() { try { const newVersions = await $fetch("/api/v1/admin/game/version", { - method: "POST", + method: "PATCH", body: { id: gameId, versions: game.value.versions.map((e) => e.versionName), diff --git a/prisma/migrations/20250204010021_add_tokens/migration.sql b/prisma/migrations/20250204010021_add_tokens/migration.sql new file mode 100644 index 0000000..3023727 --- /dev/null +++ b/prisma/migrations/20250204010021_add_tokens/migration.sql @@ -0,0 +1,15 @@ +-- CreateEnum +CREATE TYPE "APITokenMode" AS ENUM ('User', 'System'); + +-- CreateTable +CREATE TABLE "APIToken" ( + "token" TEXT NOT NULL, + "mode" "APITokenMode" NOT NULL, + "userId" TEXT, + "acls" TEXT[], + + CONSTRAINT "APIToken_pkey" PRIMARY KEY ("token") +); + +-- AddForeignKey +ALTER TABLE "APIToken" ADD CONSTRAINT "APIToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20250204020918_add_collection_entry_casacade_delete/migration.sql b/prisma/migrations/20250204020918_add_collection_entry_casacade_delete/migration.sql new file mode 100644 index 0000000..846ce19 --- /dev/null +++ b/prisma/migrations/20250204020918_add_collection_entry_casacade_delete/migration.sql @@ -0,0 +1,5 @@ +-- DropForeignKey +ALTER TABLE "CollectionEntry" DROP CONSTRAINT "CollectionEntry_gameId_fkey"; + +-- AddForeignKey +ALTER TABLE "CollectionEntry" ADD CONSTRAINT "CollectionEntry_gameId_fkey" FOREIGN KEY ("gameId") REFERENCES "Game"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema/auth.prisma b/prisma/schema/auth.prisma index 436373c..64677b8 100644 --- a/prisma/schema/auth.prisma +++ b/prisma/schema/auth.prisma @@ -21,3 +21,18 @@ model Invitation { email String? expires DateTime } + +enum APITokenMode { + User + System +} + +model APIToken { + token String @id @default(uuid()) + mode APITokenMode + + userId String? + user User? @relation(fields: [userId], references: [id]) + + acls String[] +} diff --git a/prisma/schema/collection.prisma b/prisma/schema/collection.prisma index b18cf4a..179ada5 100644 --- a/prisma/schema/collection.prisma +++ b/prisma/schema/collection.prisma @@ -14,7 +14,7 @@ model CollectionEntry { collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade) gameId String - game Game @relation(fields: [gameId], references: [id]) + game Game @relation(fields: [gameId], references: [id], onDelete: Cascade) @@id([collectionId, gameId]) } diff --git a/prisma/schema/user.prisma b/prisma/schema/user.prisma index 539af45..30173fe 100644 --- a/prisma/schema/user.prisma +++ b/prisma/schema/user.prisma @@ -12,6 +12,8 @@ model User { notifications Notification[] collections Collection[] news News[] + + tokens APIToken[] } model Notification { diff --git a/server/api/v1/admin/auth/invitation/index.delete.ts b/server/api/v1/admin/auth/invitation/index.delete.ts index 065c6a4..381dea3 100644 --- a/server/api/v1/admin/auth/invitation/index.delete.ts +++ b/server/api/v1/admin/auth/invitation/index.delete.ts @@ -1,8 +1,11 @@ +import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; export default defineEventHandler(async (h3) => { - const user = await h3.context.session.getAdminUser(h3); - if (!user) throw createError({ statusCode: 403 }); + 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; diff --git a/server/api/v1/admin/auth/invitation/index.get.ts b/server/api/v1/admin/auth/invitation/index.get.ts index 0b7efb4..e0b6881 100644 --- a/server/api/v1/admin/auth/invitation/index.get.ts +++ b/server/api/v1/admin/auth/invitation/index.get.ts @@ -1,8 +1,11 @@ +import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; export default defineEventHandler(async (h3) => { - const user = await h3.context.session.getAdminUser(h3); - if (!user) throw createError({ statusCode: 403 }); + const allowed = await aclManager.allowSystemACL(h3, [ + "auth:simple:invitation:read", + ]); + if (!allowed) throw createError({ statusCode: 403 }); await runTask("cleanup:invitations"); diff --git a/server/api/v1/admin/auth/invitation/index.post.ts b/server/api/v1/admin/auth/invitation/index.post.ts index 60a700c..015557e 100644 --- a/server/api/v1/admin/auth/invitation/index.post.ts +++ b/server/api/v1/admin/auth/invitation/index.post.ts @@ -1,8 +1,11 @@ +import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; export default defineEventHandler(async (h3) => { - const user = await h3.context.session.getAdminUser(h3); - if (!user) throw createError({ statusCode: 403 }); + 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; @@ -30,7 +33,7 @@ export default defineEventHandler(async (h3) => { isAdmin: isAdmin, username: username, email: email, - expires: expiresDate + expires: expiresDate, }, }); diff --git a/server/api/v1/admin/game/image/index.delete.ts b/server/api/v1/admin/game/image/index.delete.ts index 6e84ec2..3b6252c 100644 --- a/server/api/v1/admin/game/image/index.delete.ts +++ b/server/api/v1/admin/game/image/index.delete.ts @@ -1,8 +1,11 @@ +import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; export default defineEventHandler(async (h3) => { - const user = await h3.context.session.getAdminUser(h3); - if (!user) throw createError({ statusCode: 403 }); + const allowed = await aclManager.allowSystemACL(h3, [ + "game:image:delete", + ]); + if (!allowed) throw createError({ statusCode: 403 }); const body = await readBody(h3); const gameId = body.gameId; diff --git a/server/api/v1/admin/game/image/index.post.ts b/server/api/v1/admin/game/image/index.post.ts index b94b304..8453b6f 100644 --- a/server/api/v1/admin/game/image/index.post.ts +++ b/server/api/v1/admin/game/image/index.post.ts @@ -1,9 +1,12 @@ +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 user = await h3.context.session.getAdminUser(h3); - if (!user) throw createError({ statusCode: 403 }); + const allowed = await aclManager.allowSystemACL(h3, [ + "game:image:new", + ]); + if (!allowed) throw createError({ statusCode: 403 }); const form = await readMultipartFormData(h3); if (!form) diff --git a/server/api/v1/admin/game/index.delete.ts b/server/api/v1/admin/game/index.delete.ts index 4cab69c..0f98b3e 100644 --- a/server/api/v1/admin/game/index.delete.ts +++ b/server/api/v1/admin/game/index.delete.ts @@ -1,9 +1,12 @@ +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 user = await h3.context.session.getAdminUser(h3); - if (!user) throw createError({ statusCode: 403 }); + const allowed = await aclManager.allowSystemACL(h3, [ + "game:delete", + ]); + if (!allowed) throw createError({ statusCode: 403 }); const query = getQuery(h3); const gameId = query.id?.toString(); diff --git a/server/api/v1/admin/game/index.get.ts b/server/api/v1/admin/game/index.get.ts index 7ff78c1..8878c80 100644 --- a/server/api/v1/admin/game/index.get.ts +++ b/server/api/v1/admin/game/index.get.ts @@ -1,9 +1,12 @@ +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 user = await h3.context.session.getAdminUser(h3); - if (!user) throw createError({ statusCode: 403 }); + const allowed = await aclManager.allowSystemACL(h3, [ + "game:read", + ]); + if (!allowed) throw createError({ statusCode: 403 }); const query = getQuery(h3); const gameId = query.id?.toString(); diff --git a/server/api/v1/admin/game/index.patch.ts b/server/api/v1/admin/game/index.patch.ts index 83ec5df..578bf96 100644 --- a/server/api/v1/admin/game/index.patch.ts +++ b/server/api/v1/admin/game/index.patch.ts @@ -1,8 +1,11 @@ +import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; export default defineEventHandler(async (h3) => { - const user = await h3.context.session.getAdminUser(h3); - if (!user) throw createError({ statusCode: 403 }); + const allowed = await aclManager.allowSystemACL(h3, [ + "game:update", + ]); + if (!allowed) throw createError({ statusCode: 403 }); const body = await readBody(h3); const id = body.id; diff --git a/server/api/v1/admin/game/metadata.post.ts b/server/api/v1/admin/game/metadata.post.ts index ded9eae..4540dfd 100644 --- a/server/api/v1/admin/game/metadata.post.ts +++ b/server/api/v1/admin/game/metadata.post.ts @@ -1,9 +1,12 @@ +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 user = await h3.context.session.getAdminUser(h3); - if (!user) throw createError({ statusCode: 403 }); + const allowed = await aclManager.allowSystemACL(h3, [ + "game:update", + ]); + if (!allowed) throw createError({ statusCode: 403 }); const form = await readMultipartFormData(h3); if (!form) diff --git a/server/api/v1/admin/game/version/index.delete.ts b/server/api/v1/admin/game/version/index.delete.ts index a80da87..cf85c46 100644 --- a/server/api/v1/admin/game/version/index.delete.ts +++ b/server/api/v1/admin/game/version/index.delete.ts @@ -1,8 +1,11 @@ +import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; export default defineEventHandler(async (h3) => { - const user = await h3.context.session.getAdminUser(h3); - if (!user) throw createError({ statusCode: 403 }); + const allowed = await aclManager.allowSystemACL(h3, [ + "game:version:delete", + ]); + if (!allowed) throw createError({ statusCode: 403 }); const body = await readBody(h3); const gameId = body.id.toString(); diff --git a/server/api/v1/admin/game/version/index.post.ts b/server/api/v1/admin/game/version/index.patch.ts similarity index 83% rename from server/api/v1/admin/game/version/index.post.ts rename to server/api/v1/admin/game/version/index.patch.ts index bbb2fc3..3771179 100644 --- a/server/api/v1/admin/game/version/index.post.ts +++ b/server/api/v1/admin/game/version/index.patch.ts @@ -1,8 +1,11 @@ +import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; export default defineEventHandler(async (h3) => { - const user = await h3.context.session.getAdminUser(h3); - if (!user) throw createError({ statusCode: 403 }); + const allowed = await aclManager.allowSystemACL(h3, [ + "game:version:update", + ]); + if (!allowed) throw createError({ statusCode: 403 }); const body = await readBody(h3); const gameId = body.id?.toString(); diff --git a/server/api/v1/admin/import/game/index.get.ts b/server/api/v1/admin/import/game/index.get.ts index 9137ada..b1b3c3d 100644 --- a/server/api/v1/admin/import/game/index.get.ts +++ b/server/api/v1/admin/import/game/index.get.ts @@ -1,8 +1,11 @@ +import aclManager from "~/server/internal/acls"; import libraryManager from "~/server/internal/library"; export default defineEventHandler(async (h3) => { - const user = await h3.context.session.getAdminUser(h3); - if (!user) throw createError({ statusCode: 403 }); + const allowed = await aclManager.allowSystemACL(h3, [ + "import:game:read", + ]); + if (!allowed) throw createError({ statusCode: 403 }); const unimportedGames = await libraryManager.fetchAllUnimportedGames(); return { unimportedGames }; diff --git a/server/api/v1/admin/import/game/index.post.ts b/server/api/v1/admin/import/game/index.post.ts index d771203..1599233 100644 --- a/server/api/v1/admin/import/game/index.post.ts +++ b/server/api/v1/admin/import/game/index.post.ts @@ -1,3 +1,4 @@ +import aclManager from "~/server/internal/acls"; import libraryManager from "~/server/internal/library"; import { GameMetadataSearchResult, @@ -5,8 +6,10 @@ import { } from "~/server/internal/metadata/types"; export default defineEventHandler(async (h3) => { - const user = await h3.context.session.getAdminUser(h3); - if (!user) throw createError({ statusCode: 403 }); + const allowed = await aclManager.allowSystemACL(h3, [ + "import:game:new", + ]); + if (!allowed) throw createError({ statusCode: 403 }); const body = await readBody(h3); diff --git a/server/api/v1/admin/import/game/search.get.ts b/server/api/v1/admin/import/game/search.get.ts index 90c9344..adf5109 100644 --- a/server/api/v1/admin/import/game/search.get.ts +++ b/server/api/v1/admin/import/game/search.get.ts @@ -1,8 +1,11 @@ +import aclManager from "~/server/internal/acls"; import libraryManager from "~/server/internal/library"; export default defineEventHandler(async (h3) => { - const user = await h3.context.session.getAdminUser(h3); - if (!user) throw createError({ statusCode: 403 }); + const allowed = await aclManager.allowSystemACL(h3, [ + "import:game:read", + ]); + if (!allowed) throw createError({ statusCode: 403 }); const query = getQuery(h3); const search = query.q?.toString(); diff --git a/server/api/v1/admin/import/version/index.get.ts b/server/api/v1/admin/import/version/index.get.ts index 98251a4..8d60a05 100644 --- a/server/api/v1/admin/import/version/index.get.ts +++ b/server/api/v1/admin/import/version/index.get.ts @@ -1,8 +1,11 @@ +import aclManager from "~/server/internal/acls"; import libraryManager from "~/server/internal/library"; export default defineEventHandler(async (h3) => { - const user = await h3.context.session.getAdminUser(h3); - if (!user) throw createError({ statusCode: 403 }); + const allowed = await aclManager.allowSystemACL(h3, [ + "import:version:read", + ]); + if (!allowed) throw createError({ statusCode: 403 }); const query = await getQuery(h3); const gameId = query.id?.toString(); diff --git a/server/api/v1/admin/import/version/index.post.ts b/server/api/v1/admin/import/version/index.post.ts index 1dfe99a..3460b20 100644 --- a/server/api/v1/admin/import/version/index.post.ts +++ b/server/api/v1/admin/import/version/index.post.ts @@ -1,10 +1,13 @@ +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"; export default defineEventHandler(async (h3) => { - const user = await h3.context.session.getAdminUser(h3); - if (!user) throw createError({ statusCode: 403 }); + const allowed = await aclManager.allowSystemACL(h3, [ + "import:version:new", + ]); + if (!allowed) throw createError({ statusCode: 403 }); const body = await readBody(h3); const gameId = body.id; diff --git a/server/api/v1/admin/import/version/preload.get.ts b/server/api/v1/admin/import/version/preload.get.ts index d14e0f5..b8429b3 100644 --- a/server/api/v1/admin/import/version/preload.get.ts +++ b/server/api/v1/admin/import/version/preload.get.ts @@ -1,8 +1,11 @@ +import aclManager from "~/server/internal/acls"; import libraryManager from "~/server/internal/library"; export default defineEventHandler(async (h3) => { - const user = await h3.context.session.getAdminUser(h3); - if (!user) throw createError({ statusCode: 403 }); + const allowed = await aclManager.allowSystemACL(h3, [ + "import:version:read", + ]); + if (!allowed) throw createError({ statusCode: 403 }); const query = await getQuery(h3); const gameId = query.id?.toString(); diff --git a/server/api/v1/admin/index.get.ts b/server/api/v1/admin/index.get.ts deleted file mode 100644 index 9cb0d05..0000000 --- a/server/api/v1/admin/index.get.ts +++ /dev/null @@ -1,6 +0,0 @@ -export default defineEventHandler(async (h3) => { - const user = await h3.context.session.getUser(h3); - if (!user) - throw createError({ statusCode: 403, statusMessage: "Not authenticated" }); - return { admin: user.admin }; -}); diff --git a/server/api/v1/admin/library/index.get.ts b/server/api/v1/admin/library/index.get.ts index d526c1f..192a7c2 100644 --- a/server/api/v1/admin/library/index.get.ts +++ b/server/api/v1/admin/library/index.get.ts @@ -1,8 +1,9 @@ +import aclManager from "~/server/internal/acls"; import libraryManager from "~/server/internal/library"; export default defineEventHandler(async (h3) => { - const user = await h3.context.session.getAdminUser(h3); - if (!user) throw createError({ statusCode: 403 }); + const allowed = await aclManager.allowSystemACL(h3, ["library:read"]); + if (!allowed) throw createError({ statusCode: 403 }); const unimportedGames = await libraryManager.fetchAllUnimportedGames(); const games = await libraryManager.fetchGamesWithStatus(); diff --git a/server/api/v1/news/[id].delete.ts b/server/api/v1/admin/news/[id].delete.ts similarity index 100% rename from server/api/v1/news/[id].delete.ts rename to server/api/v1/admin/news/[id].delete.ts diff --git a/server/api/v1/news/index.post.ts b/server/api/v1/admin/news/index.post.ts similarity index 100% rename from server/api/v1/news/index.post.ts rename to server/api/v1/admin/news/index.post.ts diff --git a/server/api/v1/admin/user/index.get.ts b/server/api/v1/admin/user/index.get.ts index b241860..8b1df15 100644 --- a/server/api/v1/admin/user/index.get.ts +++ b/server/api/v1/admin/user/index.get.ts @@ -1,8 +1,9 @@ +import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; export default defineEventHandler(async (h3) => { - const user = await h3.context.session.getAdminUser(h3); - if (!user) throw createError({ statusCode: 403 }); + const allowed = await aclManager.allowSystemACL(h3, ["user:read"]); + if (!allowed) throw createError({ statusCode: 403 }); const users = await prisma.user.findMany({}); diff --git a/server/api/v1/auth/signin/simple.post.ts b/server/api/v1/auth/signin/simple.post.ts index 54e9f14..996bf2b 100644 --- a/server/api/v1/auth/signin/simple.post.ts +++ b/server/api/v1/auth/signin/simple.post.ts @@ -2,6 +2,7 @@ import { AuthMec } from "@prisma/client"; import { JsonArray } from "@prisma/client/runtime/library"; import prisma from "~/server/internal/db/database"; import { checkHash } from "~/server/internal/security/simple"; +import sessionHandler from "~/server/internal/session"; export default defineEventHandler(async (h3) => { const body = await readBody(h3); @@ -31,7 +32,7 @@ export default defineEventHandler(async (h3) => { if (!await checkHash(password, hash.toString())) throw createError({ statusCode: 401, statusMessage: "Invalid username or password." }); - await h3.context.session.setUserId(h3, authMek.userId, rememberMe); + await sessionHandler.setUserId(h3, authMek.userId, rememberMe); return { result: true, userId: authMek.userId } }); \ No newline at end of file diff --git a/server/api/v1/client/auth/callback/index.get.ts b/server/api/v1/client/auth/callback/index.get.ts index fd3a617..ea8e4b3 100644 --- a/server/api/v1/client/auth/callback/index.get.ts +++ b/server/api/v1/client/auth/callback/index.get.ts @@ -1,7 +1,8 @@ import clientHandler from "~/server/internal/clients/handler"; +import sessionHandler from "~/server/internal/session"; export default defineEventHandler(async (h3) => { - const userId = await h3.context.session.getUserId(h3); + const userId = await sessionHandler.getUserId(h3); if (!userId) throw createError({ statusCode: 403 }); const query = getQuery(h3); diff --git a/server/api/v1/client/auth/callback/index.post.ts b/server/api/v1/client/auth/callback/index.post.ts index bcc151e..46eb0b3 100644 --- a/server/api/v1/client/auth/callback/index.post.ts +++ b/server/api/v1/client/auth/callback/index.post.ts @@ -1,7 +1,8 @@ import clientHandler from "~/server/internal/clients/handler"; +import sessionHandler from "~/server/internal/session"; export default defineEventHandler(async (h3) => { - const userId = await h3.context.session.getUserId(h3); + const userId = await sessionHandler.getUserId(h3); if (!userId) throw createError({ statusCode: 403 }); const body = await readBody(h3); diff --git a/server/api/v1/collection/[id]/entry.delete.ts b/server/api/v1/collection/[id]/entry.delete.ts index 17c3476..575ae3c 100644 --- a/server/api/v1/collection/[id]/entry.delete.ts +++ b/server/api/v1/collection/[id]/entry.delete.ts @@ -1,11 +1,11 @@ +import aclManager from "~/server/internal/acls"; import userLibraryManager from "~/server/internal/userlibrary"; export default defineEventHandler(async (h3) => { - const userId = await h3.context.session.getUserId(h3); + const userId = await aclManager.getUserIdACL(h3, ["collections:remove"]); if (!userId) throw createError({ statusCode: 403, - statusMessage: "Requires authentication", }); const id = getRouterParam(h3, "id"); diff --git a/server/api/v1/collection/[id]/entry.post.ts b/server/api/v1/collection/[id]/entry.post.ts index 2356e43..d6a2394 100644 --- a/server/api/v1/collection/[id]/entry.post.ts +++ b/server/api/v1/collection/[id]/entry.post.ts @@ -1,11 +1,11 @@ +import aclManager from "~/server/internal/acls"; import userLibraryManager from "~/server/internal/userlibrary"; export default defineEventHandler(async (h3) => { - const userId = await h3.context.session.getUserId(h3); + const userId = await aclManager.getUserIdACL(h3, ["collections:add"]); if (!userId) throw createError({ statusCode: 403, - statusMessage: "Requires authentication", }); const id = getRouterParam(h3, "id"); diff --git a/server/api/v1/collection/[id]/index.delete.ts b/server/api/v1/collection/[id]/index.delete.ts index d275036..e00b3d6 100644 --- a/server/api/v1/collection/[id]/index.delete.ts +++ b/server/api/v1/collection/[id]/index.delete.ts @@ -1,7 +1,8 @@ +import aclManager from "~/server/internal/acls"; import userLibraryManager from "~/server/internal/userlibrary"; export default defineEventHandler(async (h3) => { - const userId = await h3.context.session.getUserId(h3); + const userId = await aclManager.getUserIdACL(h3, ["collections:delete"]); if (!userId) throw createError({ statusCode: 403, diff --git a/server/api/v1/collection/[id]/index.get.ts b/server/api/v1/collection/[id]/index.get.ts index 04609e5..9bb1854 100644 --- a/server/api/v1/collection/[id]/index.get.ts +++ b/server/api/v1/collection/[id]/index.get.ts @@ -1,7 +1,8 @@ +import aclManager from "~/server/internal/acls"; import userLibraryManager from "~/server/internal/userlibrary"; export default defineEventHandler(async (h3) => { - const userId = await h3.context.session.getUserId(h3); + const userId = await aclManager.getUserIdACL(h3, ["collections:read"]); if (!userId) throw createError({ statusCode: 403, diff --git a/server/api/v1/collection/default/entry.delete.ts b/server/api/v1/collection/default/entry.delete.ts index d72dd74..77f3c39 100644 --- a/server/api/v1/collection/default/entry.delete.ts +++ b/server/api/v1/collection/default/entry.delete.ts @@ -1,7 +1,8 @@ +import aclManager from "~/server/internal/acls"; import userLibraryManager from "~/server/internal/userlibrary"; export default defineEventHandler(async (h3) => { - const userId = await h3.context.session.getUserId(h3); + const userId = await aclManager.getUserIdACL(h3, ["library:remove"]); if (!userId) throw createError({ statusCode: 403, diff --git a/server/api/v1/collection/default/entry.post.ts b/server/api/v1/collection/default/entry.post.ts index c4af2fc..4c8b8fd 100644 --- a/server/api/v1/collection/default/entry.post.ts +++ b/server/api/v1/collection/default/entry.post.ts @@ -1,7 +1,8 @@ +import aclManager from "~/server/internal/acls"; import userLibraryManager from "~/server/internal/userlibrary"; export default defineEventHandler(async (h3) => { - const userId = await h3.context.session.getUserId(h3); + const userId = await aclManager.getUserIdACL(h3, ["library:add"]); if (!userId) throw createError({ statusCode: 403, diff --git a/server/api/v1/collection/default/index.get.ts b/server/api/v1/collection/default/index.get.ts index 2505779..22a357d 100644 --- a/server/api/v1/collection/default/index.get.ts +++ b/server/api/v1/collection/default/index.get.ts @@ -1,7 +1,8 @@ +import aclManager from "~/server/internal/acls"; import userLibraryManager from "~/server/internal/userlibrary"; export default defineEventHandler(async (h3) => { - const userId = await h3.context.session.getUserId(h3); + const userId = await aclManager.getUserIdACL(h3, ["collections:read"]); if (!userId) throw createError({ statusCode: 403, diff --git a/server/api/v1/collection/index.get.ts b/server/api/v1/collection/index.get.ts index 76a3f51..2d4c420 100644 --- a/server/api/v1/collection/index.get.ts +++ b/server/api/v1/collection/index.get.ts @@ -1,11 +1,11 @@ +import aclManager from "~/server/internal/acls"; import userLibraryManager from "~/server/internal/userlibrary"; export default defineEventHandler(async (h3) => { - const userId = await h3.context.session.getUserId(h3); + const userId = await aclManager.getUserIdACL(h3, ["collections:new"]); if (!userId) throw createError({ statusCode: 403, - statusMessage: "Requires authentication", }); const collections = await userLibraryManager.fetchCollections(userId); diff --git a/server/api/v1/collection/index.post.ts b/server/api/v1/collection/index.post.ts index 4841b13..1d3daad 100644 --- a/server/api/v1/collection/index.post.ts +++ b/server/api/v1/collection/index.post.ts @@ -1,11 +1,11 @@ +import aclManager from "~/server/internal/acls"; import userLibraryManager from "~/server/internal/userlibrary"; export default defineEventHandler(async (h3) => { - const userId = await h3.context.session.getUserId(h3); + const userId = await aclManager.getUserIdACL(h3, ["collections:read"]); if (!userId) throw createError({ statusCode: 403, - statusMessage: "Requires authentication", }); const body = await readBody(h3); diff --git a/server/api/v1/games/[id]/index.get.ts b/server/api/v1/games/[id]/index.get.ts index 94d4b6f..d7c1ed8 100644 --- a/server/api/v1/games/[id]/index.get.ts +++ b/server/api/v1/games/[id]/index.get.ts @@ -1,7 +1,8 @@ +import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; export default defineEventHandler(async (h3) => { - const userId = await h3.context.session.getUserId(h3); + const userId = await aclManager.getUserIdACL(h3, ["store:read"]); if (!userId) throw createError({ statusCode: 403 }); const gameId = getRouterParam(h3, "id"); diff --git a/server/api/v1/news/[id].get.ts b/server/api/v1/news/[id]/index.get.ts similarity index 100% rename from server/api/v1/news/[id].get.ts rename to server/api/v1/news/[id]/index.get.ts diff --git a/server/api/v1/notifications/[id]/index.delete.ts b/server/api/v1/notifications/[id]/index.delete.ts index c89a147..a8f63ab 100644 --- a/server/api/v1/notifications/[id]/index.delete.ts +++ b/server/api/v1/notifications/[id]/index.delete.ts @@ -1,7 +1,8 @@ +import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; export default defineEventHandler(async (h3) => { - const userId = await h3.context.session.getUserId(h3); + const userId = await aclManager.getUserIdACL(h3, ["notifications:delete"]); if (!userId) throw createError({ statusCode: 403 }); const notificationId = getRouterParam(h3, "id"); diff --git a/server/api/v1/notifications/[id]/index.get.ts b/server/api/v1/notifications/[id]/index.get.ts index 0d9b2c2..75129aa 100644 --- a/server/api/v1/notifications/[id]/index.get.ts +++ b/server/api/v1/notifications/[id]/index.get.ts @@ -1,7 +1,8 @@ +import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; export default defineEventHandler(async (h3) => { - const userId = await h3.context.session.getUserId(h3); + const userId = await aclManager.getUserIdACL(h3, ["notifications:read"]); if (!userId) throw createError({ statusCode: 403 }); const notificationId = getRouterParam(h3, "id"); diff --git a/server/api/v1/notifications/[id]/read.post.ts b/server/api/v1/notifications/[id]/read.post.ts index ef180c4..8f93844 100644 --- a/server/api/v1/notifications/[id]/read.post.ts +++ b/server/api/v1/notifications/[id]/read.post.ts @@ -1,7 +1,8 @@ +import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; export default defineEventHandler(async (h3) => { - const userId = await h3.context.session.getUserId(h3); + const userId = await aclManager.getUserIdACL(h3, ["notifications:mark"]); if (!userId) throw createError({ statusCode: 403 }); const notificationId = getRouterParam(h3, "id"); diff --git a/server/api/v1/notifications/index.get.ts b/server/api/v1/notifications/index.get.ts index 65a6cba..9c3502f 100644 --- a/server/api/v1/notifications/index.get.ts +++ b/server/api/v1/notifications/index.get.ts @@ -1,7 +1,8 @@ +import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; export default defineEventHandler(async (h3) => { - const userId = await h3.context.session.getUserId(h3); + const userId = await aclManager.getUserIdACL(h3, ["notifications:read"]); if (!userId) throw createError({ statusCode: 403 }); const notifications = await prisma.notification.findMany({ diff --git a/server/api/v1/notifications/readall.post.ts b/server/api/v1/notifications/readall.post.ts index 9f29bba..7b11927 100644 --- a/server/api/v1/notifications/readall.post.ts +++ b/server/api/v1/notifications/readall.post.ts @@ -1,7 +1,8 @@ +import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; export default defineEventHandler(async (h3) => { - const userId = await h3.context.session.getUserId(h3); + const userId = await aclManager.getUserIdACL(h3, ["notifications:mark"]); if (!userId) throw createError({ statusCode: 403 }); await prisma.notification.updateMany({ diff --git a/server/api/v1/notifications/ws.get.ts b/server/api/v1/notifications/ws.get.ts index 1309128..ebd6574 100644 --- a/server/api/v1/notifications/ws.get.ts +++ b/server/api/v1/notifications/ws.get.ts @@ -1,6 +1,7 @@ import notificationSystem from "~/server/internal/notifications"; import session from "~/server/internal/session"; import { parse as parseCookies } from "cookie-es"; +import aclManager from "~/server/internal/acls"; // TODO add web socket sessions for horizontal scaling // Peer ID to user ID @@ -8,16 +9,10 @@ const socketSessions: { [key: string]: string } = {}; export default defineWebSocketHandler({ async open(peer) { - const cookies = peer.request?.headers?.get("Cookie"); - if (!cookies) { - peer.send("unauthenticated"); - return; - } - - const parsedCookies = parseCookies(cookies); - const token = parsedCookies[session.getDropTokenCookie()]; - - const userId = await session.getUserIdRaw(token); + const userId = await aclManager.getUserIdACL( + { headers: peer.request?.headers ?? new Headers() }, + ["notifications:listen"] + ); if (!userId) { peer.send("unauthenticated"); return; diff --git a/server/api/v1/object/[id]/index.delete.ts b/server/api/v1/object/[id]/index.delete.ts index 2bd2e35..60802f1 100644 --- a/server/api/v1/object/[id]/index.delete.ts +++ b/server/api/v1/object/[id]/index.delete.ts @@ -1,8 +1,10 @@ +import aclManager from "~/server/internal/acls"; + export default defineEventHandler(async (h3) => { const id = getRouterParam(h3, "id"); if (!id) throw createError({ statusCode: 400, statusMessage: "Invalid ID" }); - const userId = await h3.context.session.getUserId(h3); + const userId = await aclManager.getUserIdACL(h3, ["object:delete"]); const result = await h3.context.objects.deleteWithPermission(id, userId); return { success: result }; diff --git a/server/api/v1/object/[id]/index.get.ts b/server/api/v1/object/[id]/index.get.ts index afdf692..3e48981 100644 --- a/server/api/v1/object/[id]/index.get.ts +++ b/server/api/v1/object/[id]/index.get.ts @@ -1,8 +1,10 @@ +import aclManager from "~/server/internal/acls"; + export default defineEventHandler(async (h3) => { const id = getRouterParam(h3, "id"); if (!id) throw createError({ statusCode: 400, statusMessage: "Invalid ID" }); - const userId = await h3.context.session.getUserId(h3); + const userId = await aclManager.getUserIdACL(h3, ["object:read"]); const object = await h3.context.objects.fetchWithPermissions(id, userId); if (!object) diff --git a/server/api/v1/object/[id]/index.post.ts b/server/api/v1/object/[id]/index.post.ts index 6fcbb1c..a27e18d 100644 --- a/server/api/v1/object/[id]/index.post.ts +++ b/server/api/v1/object/[id]/index.post.ts @@ -1,3 +1,5 @@ +import aclManager from "~/server/internal/acls"; + export default defineEventHandler(async (h3) => { const id = getRouterParam(h3, "id"); if (!id) throw createError({ statusCode: 400, statusMessage: "Invalid ID" }); @@ -9,7 +11,7 @@ export default defineEventHandler(async (h3) => { statusMessage: "Invalid upload", }); - const userId = await h3.context.session.getUserId(h3); + const userId = await aclManager.getUserIdACL(h3, ["object:update"]); const buffer = Buffer.from(body); const result = await h3.context.objects.writeWithPermissions( diff --git a/server/api/v1/store/developers.ts b/server/api/v1/store/developers.ts index a47f846..79e3124 100644 --- a/server/api/v1/store/developers.ts +++ b/server/api/v1/store/developers.ts @@ -1,7 +1,8 @@ +import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; export default defineEventHandler(async (h3) => { - const userId = await h3.context.session.getUserId(h3); + const userId = await aclManager.getUserACL(h3, ["store:read"]); if (!userId) throw createError({ statusCode: 403 }); const developers = await prisma.developer.findMany({ diff --git a/server/api/v1/store/publishers.ts b/server/api/v1/store/publishers.ts index 8587336..9fef6ff 100644 --- a/server/api/v1/store/publishers.ts +++ b/server/api/v1/store/publishers.ts @@ -1,7 +1,8 @@ +import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; export default defineEventHandler(async (h3) => { - const userId = await h3.context.session.getUserId(h3); + const userId = await aclManager.getUserACL(h3, ["store:read"]); if (!userId) throw createError({ statusCode: 403 }); const publishers = await prisma.publisher.findMany({ diff --git a/server/api/v1/store/recent.get.ts b/server/api/v1/store/recent.get.ts index 16d3fac..be137f0 100644 --- a/server/api/v1/store/recent.get.ts +++ b/server/api/v1/store/recent.get.ts @@ -1,7 +1,8 @@ +import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; export default defineEventHandler(async (h3) => { - const userId = await h3.context.session.getUserId(h3); + const userId = await aclManager.getUserACL(h3, ["store:read"]); if (!userId) throw createError({ statusCode: 403 }); const games = await prisma.game.findMany({ diff --git a/server/api/v1/store/released.get.ts b/server/api/v1/store/released.get.ts index 494d5fe..37c729c 100644 --- a/server/api/v1/store/released.get.ts +++ b/server/api/v1/store/released.get.ts @@ -1,7 +1,8 @@ +import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; export default defineEventHandler(async (h3) => { - const userId = await h3.context.session.getUserId(h3); + const userId = await aclManager.getUserACL(h3, ["store:read"]); if (!userId) throw createError({ statusCode: 403 }); const games = await prisma.game.findMany({ diff --git a/server/api/v1/store/updated.get.ts b/server/api/v1/store/updated.get.ts index a186216..520033e 100644 --- a/server/api/v1/store/updated.get.ts +++ b/server/api/v1/store/updated.get.ts @@ -1,7 +1,8 @@ +import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; export default defineEventHandler(async (h3) => { - const userId = await h3.context.session.getUserId(h3); + const userId = await aclManager.getUserACL(h3, ["store:read"]); if (!userId) throw createError({ statusCode: 403 }); const versions = await prisma.gameVersion.findMany({ diff --git a/server/api/v1/task/index.get.ts b/server/api/v1/task/index.get.ts index dc85634..ccb2b3a 100644 --- a/server/api/v1/task/index.get.ts +++ b/server/api/v1/task/index.get.ts @@ -1,46 +1,39 @@ import session from "~/server/internal/session"; import taskHandler, { TaskMessage } from "~/server/internal/tasks"; import { parse as parseCookies } from "cookie-es"; +import { MinimumRequestObject } from "~/server/h3"; // TODO add web socket sessions for horizontal scaling // ID to admin -const adminSocketSessions: { [key: string]: boolean } = {}; +const socketHeaders: { [key: string]: MinimumRequestObject } = {}; export default defineWebSocketHandler({ async open(peer) { - const cookies = peer.request?.headers?.get("Cookie"); - if (!cookies) { + const request = peer.request; + if (!request) { peer.send("unauthenticated"); return; } - const parsedCookies = parseCookies(cookies); - const token = parsedCookies[session.getDropTokenCookie()]; - - const userId = await session.getUserIdRaw(token); - if (!userId) { - peer.send("unauthenticated"); - return; - } - - const admin = session.getAdminUser(token); - adminSocketSessions[peer.id] = admin !== undefined; + socketHeaders[peer.id] = { + headers: request.headers ?? new Headers(), + }; peer.send(`connect`); }, message(peer, message) { if (!peer.id) return; - if (adminSocketSessions[peer.id] === undefined) return; + if (socketHeaders[peer.id] === undefined) return; const text = message.text(); if (text.startsWith("connect/")) { const id = text.substring("connect/".length); - taskHandler.connect(peer.id, id, peer, adminSocketSessions[peer.id]); + taskHandler.connect(peer.id, id, peer, socketHeaders[peer.id]); return; } }, close(peer, details) { if (!peer.id) return; - if (adminSocketSessions[peer.id] === undefined) return; - delete adminSocketSessions[peer.id]; + if (socketHeaders[peer.id] === undefined) return; + delete socketHeaders[peer.id]; taskHandler.disconnectAll(peer.id); }, diff --git a/server/api/v1/user/index.get.ts b/server/api/v1/user/index.get.ts index b611c1c..fb8a254 100644 --- a/server/api/v1/user/index.get.ts +++ b/server/api/v1/user/index.get.ts @@ -1,4 +1,6 @@ +import aclManager from "~/server/internal/acls"; + export default defineEventHandler(async (h3) => { - const user = await h3.context.session.getUser(h3); + const user = await aclManager.getUserACL(h3, ["read"]); return user ?? null; // Need to specifically return null }); diff --git a/server/h3.d.ts b/server/h3.d.ts index 7b7c1ba..e2fddb4 100644 --- a/server/h3.d.ts +++ b/server/h3.d.ts @@ -6,9 +6,9 @@ import { SessionHandler } from "./internal/session"; export * from "h3"; declare module "h3" { interface H3EventContext { - session: SessionHandler; - metadataHandler: MetadataHandler; ca: CertificateAuthority; - objects: ObjectBackend + objects: ObjectBackend; } } + +export type MinimumRequestObject = { headers: Headers }; diff --git a/server/internal/acls/index.ts b/server/internal/acls/index.ts new file mode 100644 index 0000000..874fa54 --- /dev/null +++ b/server/internal/acls/index.ts @@ -0,0 +1,152 @@ +import { APITokenMode, User } from "@prisma/client"; +import { H3Context, H3Event } from "h3"; +import prisma from "../db/database"; +import sessionHandler from "../session"; +import { MinimumRequestObject } from "~/server/h3"; + +const userACLs = [ + "read", + + "store:read", + + "object:read", + "object:update", + "object:delete", + + "notifications:read", + "notifications:mark", + "notifications:listen", + "notifications:delete", + + "collections:new", + "collections:read", + "collections:delete", + "collections:add", + "collections:remove", + "library:add", + "library:remove", + + "news:read", +] as const; +const userACLPrefix = "user:"; + +type UserACL = Array<(typeof userACLs)[number]>; + +const systemACLs = [ + "auth:simple:invitation:read", + "auth:simple:invitation:new", + "auth:simple:invitation:delete", + + "library:read", + "game:read", + "game:update", + "game:delete", + "game:version:update", + "game:version:delete", + "game:image:new", + "game:image:delete", + + "import:version:read", + "import:version:new", + + "import:game:read", + "import:game:new", + + "user:read", +] as const; +const systemACLPrefix = "system:"; + +type SystemACL = Array<(typeof systemACLs)[number]>; + +class ACLManager { + private getAuthorizationToken(request: MinimumRequestObject) { + const [type, token] = + request.headers.get("Authorization")?.split(" ") ?? []; + if (!type || !token) return undefined; + if (type != "Bearer") return undefined; + return token; + } + + async getUserIdACL(request: MinimumRequestObject | undefined, acls: UserACL) { + if (!request) + throw new Error("Native web requests not available - weird deployment?"); + // Sessions automatically have all ACLs + const userId = await sessionHandler.getUserId(request); + if (userId) return userId; + + const authorizationToken = this.getAuthorizationToken(request); + if (!authorizationToken) return undefined; + const token = await prisma.aPIToken.findUnique({ + where: { token: authorizationToken }, + }); + if (!token) return undefined; + if (token.mode != APITokenMode.User || !token.userId) return undefined; // If it's a system token + + for (const acl of acls) { + const tokenACLIndex = token.acls.findIndex((e) => e == acl); + if (tokenACLIndex != -1) return token.userId; + } + + return undefined; + } + + async getUserACL(request: MinimumRequestObject | undefined, acls: UserACL) { + if (!request) + throw new Error("Native web requests not available - weird deployment?"); + const userId = await this.getUserIdACL(request, acls); + if (!userId) return undefined; + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (user) return user; + return undefined; + } + + async allowSystemACL( + request: MinimumRequestObject | undefined, + acls: SystemACL + ) { + if (!request) + throw new Error("Native web requests not available - weird deployment?"); + const userId = await sessionHandler.getUserId(request); + if (userId) { + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) return false; + if (user.admin) return true; + return false; + } + + const authorizationToken = this.getAuthorizationToken(request); + if (!authorizationToken) return false; + const token = await prisma.aPIToken.findUnique({ + where: { token: authorizationToken }, + }); + if (!token) return false; + if (token.mode != APITokenMode.System) return false; + for (const acl of acls) { + const tokenACLIndex = token.acls.findIndex((e) => e == acl); + if (tokenACLIndex != -1) return true; + } + + return false; + } + + async hasACL(request: MinimumRequestObject | undefined, acls: string[]) { + for (const acl of acls) { + if (acl.startsWith(userACLPrefix)) { + const rawACL = acl.substring(userACLPrefix.length); + const userId = await this.getUserIdACL(request, [rawACL as any]); + if (!userId) return false; + } + + if (acl.startsWith(systemACLPrefix)) { + const rawACL = acl.substring(systemACLPrefix.length); + const allowed = await this.allowSystemACL(request, [rawACL as any]); + if (!allowed) return false; + } + } + + return true; + } +} + +export const aclManager = new ACLManager(); +export default aclManager; diff --git a/server/internal/applibrary/README.md b/server/internal/applibrary/README.md deleted file mode 100644 index e33b8ad..0000000 --- a/server/internal/applibrary/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Library Format - -Drop uses a filesystem-based library format, as it targets homelabs and not enterprise-grade solutions. The format works as follows: - -## /{game name} - -The game name is only used for initial matching, and doesn't affect actual metadata. Metadata is linked to the game's database entry, which is linked to it's filesystem name (they, however, can be completely different). - -## /{game name}/{version name} - -The version name can be anything. Versions have to manually imported within the web UI. There, you can change the order of the updates and mark them as deltas. Delta updates apply files over the previous versions. \ No newline at end of file diff --git a/server/internal/applibrary/index.ts b/server/internal/applibrary/index.ts deleted file mode 100644 index 308c90d..0000000 --- a/server/internal/applibrary/index.ts +++ /dev/null @@ -1,309 +0,0 @@ -/** - * The Library Manager keeps track of games in Drop's library and their various states. - * It uses path relative to the library, so it can moved without issue - * - * It also provides the endpoints with information about unmatched games - */ - -import fs from "fs"; -import path from "path"; -import prisma from "../db/database"; -import { GameVersion, Platform } 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/droplet"; - -class AppLibraryManager { - private basePath: string; - - constructor() { - this.basePath = process.env.LIBRARY ?? "./.data/library"; - fs.mkdirSync(this.basePath, { recursive: true }); - } - - fetchLibraryPath() { - return this.basePath; - } - - async fetchAllUnimportedGames() { - const dirs = fs.readdirSync(this.basePath).filter((e) => { - const fullDir = path.join(this.basePath, e); - return fs.lstatSync(fullDir).isDirectory(); - }); - - const validGames = await prisma.game.findMany({ - where: { - libraryBasePath: { in: dirs }, - }, - select: { - libraryBasePath: true, - }, - }); - const validGameDirs = validGames.map((e) => e.libraryBasePath); - - const unregisteredGames = dirs.filter((e) => !validGameDirs.includes(e)); - - return unregisteredGames; - } - - 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) - ); - - return unimportedVersions; - } - - async fetchGamesWithStatus() { - const games = await prisma.game.findMany({ - select: { - id: true, - versions: true, - mName: true, - mShortDescription: true, - metadataSource: true, - mDevelopers: true, - mPublishers: true, - mIconId: true, - libraryBasePath: true, - }, - orderBy: { - mName: "asc", - }, - }); - - return await Promise.all( - games.map(async (e) => ({ - game: e, - status: { - noVersions: e.versions.length == 0, - unimportedVersions: await this.fetchUnimportedGameVersions( - e.libraryBasePath, - e.versions - ), - }, - })) - ); - } - - 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 }, - }); - if (!game) return undefined; - const targetDir = path.join( - this.basePath, - game.libraryBasePath, - versionName - ); - if (!fs.existsSync(targetDir)) return undefined; - - const fileExts: { [key: string]: string[] } = { - Linux: [ - // Ext for Unity games - ".x86_64", - // Shell scripts - ".sh", - // No extension is common for Linux binaries - "", - ], - Windows: [ - // Pretty much the only one - ".exe", - ], - }; - - const options: Array<{ - filename: string; - platform: string; - match: number; - }> = []; - - const files = recursivelyReaddir(targetDir, 2); - for (const file of files) { - const filename = path.basename(file); - const dotLocation = file.lastIndexOf("."); - const ext = dotLocation == -1 ? "" : file.slice(dotLocation); - for (const [platform, checkExts] of Object.entries(fileExts)) { - 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, - match: fuzzyValue, - }); - } - } - } - - const sortedOptions = options.sort((a, b) => b.match - a.match); - - return sortedOptions; - } - - // 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; - - const hasGame = - (await prisma.game.count({ where: { libraryBasePath: targetPath } })) > 0; - if (hasGame) return false; - - return true; - } - - async importVersion( - gameId: string, - versionName: string, - metadata: { - platform: string; - onlySetup: boolean; - - setup: string; - setupArgs: string; - launch: string; - launchArgs: string; - delta: boolean; - - umuId: string; - } - ) { - const taskId = `import:${gameId}:${versionName}`; - - const platform = parsePlatform(metadata.platform); - if (!platform) return undefined; - - const game = await prisma.game.findUnique({ - where: { id: gameId }, - select: { mName: true, libraryBasePath: true }, - }); - if (!game) return undefined; - - const baseDir = path.join(this.basePath, game.libraryBasePath, versionName); - if (!fs.existsSync(baseDir)) return undefined; - - taskHandler.create({ - id: taskId, - name: `Importing version ${versionName} for ${game.mName}`, - requireAdmin: true, - 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); - } - ); - }); - - log("Created manifest successfully!"); - - const currentIndex = await prisma.gameVersion.count({ - where: { gameId: gameId }, - }); - - // Then, create the database object - if (metadata.onlySetup) { - await prisma.gameVersion.create({ - data: { - gameId: gameId, - versionName: versionName, - dropletManifest: manifest, - versionIndex: currentIndex, - delta: metadata.delta, - umuIdOverride: metadata.umuId, - platform: platform, - - onlySetup: true, - setupCommand: metadata.setup, - setupArgs: metadata.setupArgs.split(" "), - }, - }); - } else { - await prisma.gameVersion.create({ - data: { - gameId: gameId, - versionName: versionName, - dropletManifest: manifest, - versionIndex: currentIndex, - delta: metadata.delta, - umuIdOverride: metadata.umuId, - platform: platform, - - onlySetup: false, - setupCommand: metadata.setup, - setupArgs: metadata.setupArgs.split(" "), - launchCommand: metadata.launch, - launchArgs: metadata.launchArgs.split(" "), - }, - }); - } - - log("Successfully created version!"); - - progress(100); - }, - }); - - return taskId; - } -} - -export const appLibraryManager = new AppLibraryManager(); -export default appLibraryManager; diff --git a/server/internal/library/index.ts b/server/internal/library/index.ts index 2ed0a4e..b33f998 100644 --- a/server/internal/library/index.ts +++ b/server/internal/library/index.ts @@ -230,7 +230,7 @@ class LibraryManager { taskHandler.create({ id: taskId, name: `Importing version ${versionName} for ${game.mName}`, - requireAdmin: true, + acls: ["system:import:version:read"], 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 diff --git a/server/internal/objects/index.ts b/server/internal/objects/index.ts index 1a6b450..5d97c45 100644 --- a/server/internal/objects/index.ts +++ b/server/internal/objects/index.ts @@ -76,7 +76,7 @@ export abstract class ObjectBackend { } if (source instanceof Buffer) { const mime = - getMimeTypeBuffer(source)?.mime ?? "application/octet-stream"; + getMimeTypeBuffer(new Uint8Array(source).buffer)?.mime ?? "application/octet-stream"; return { source: source, mime }; } diff --git a/server/internal/session/index.ts b/server/internal/session/index.ts index ec9cf96..3049b22 100644 --- a/server/internal/session/index.ts +++ b/server/internal/session/index.ts @@ -4,6 +4,8 @@ import { SessionProvider } from "./types"; import prisma from "../db/database"; import { v4 as uuidv4 } from "uuid"; import moment from "moment"; +import { parse as parseCookies } from "cookie-es"; +import { MinimumRequestObject } from "~/server/h3"; /* This implementation may need work. @@ -25,8 +27,12 @@ export class SessionHandler { this.sessionProvider = createMemorySessionProvider(); } - private getSessionToken(h3: H3Event) { - const cookie = getCookie(h3, dropTokenCookie); + private getSessionToken(request: MinimumRequestObject | undefined) { + if(!request) throw new Error("Native web request not available"); + const cookieHeader = request.headers.get("Cookie"); + if (!cookieHeader) return undefined; + const cookies = parseCookies(cookieHeader); + const cookie = cookies[dropTokenCookie]; return cookie; } @@ -47,8 +53,8 @@ export class SessionHandler { return dropTokenCookie; } - async getSession(h3: H3Event) { - const token = this.getSessionToken(h3); + async getSession(request: MinimumRequestObject) { + const token = this.getSessionToken(request); if (!token) return undefined; const data = await this.sessionProvider.getSession<{ [userSessionKey]: T }>( token @@ -68,14 +74,14 @@ export class SessionHandler { return result; } - async clearSession(h3: H3Event) { - const token = this.getSessionToken(h3); + async clearSession(request: MinimumRequestObject) { + const token = this.getSessionToken(request); if (!token) return false; await this.sessionProvider.clearSession(token); return true; } - async getUserId(h3: H3Event) { + async getUserId(h3: MinimumRequestObject) { const token = this.getSessionToken(h3); if (!token) return undefined; @@ -91,17 +97,6 @@ export class SessionHandler { return session[userIdKey]; } - async getUser(obj: H3Event | string) { - const userId = - typeof obj === "string" - ? await this.getUserIdRaw(obj) - : await this.getUserId(obj); - if (!userId) return undefined; - - const user = await prisma.user.findFirst({ where: { id: userId } }); - return user; - } - async setUserId(h3: H3Event, userId: string, extend = false) { const token = this.getSessionToken(h3) ?? (await this.createSession(h3, extend)); @@ -112,13 +107,7 @@ export class SessionHandler { userId ); } - - async getAdminUser(h3: H3Event | string) { - const user = await this.getUser(h3); - if (!user) return undefined; - if (!user.admin) return undefined; - return user; - } } -export default new SessionHandler(); +export const sessionHandler = new SessionHandler(); +export default sessionHandler; diff --git a/server/internal/tasks/index.ts b/server/internal/tasks/index.ts index 8868b6e..a70cf50 100644 --- a/server/internal/tasks/index.ts +++ b/server/internal/tasks/index.ts @@ -1,4 +1,6 @@ import droplet from "@drop/droplet"; +import { MinimumRequestObject } from "~/server/h3"; +import aclManager from "../acls"; /** * The TaskHandler setups up two-way connections to web clients and manages the state for them @@ -13,7 +15,7 @@ type TaskRegistryEntry = { error: { title: string; description: string } | undefined; clients: { [key: string]: boolean }; name: string; - requireAdmin: boolean; + acls: string[]; }; class TaskHandler { @@ -84,7 +86,7 @@ class TaskHandler { error: undefined, log: [], clients: {}, - requireAdmin: task.requireAdmin ?? false, + acls: task.acls, }; updateAllClients(true); @@ -113,7 +115,12 @@ class TaskHandler { }); } - connect(id: string, taskId: string, peer: PeerImpl, isAdmin = false) { + async connect( + id: string, + taskId: string, + peer: PeerImpl, + request: MinimumRequestObject + ) { const task = this.taskRegistry[taskId]; if (!task) { peer.send( @@ -122,8 +129,9 @@ class TaskHandler { return; } - if (task.requireAdmin && !isAdmin) { - console.warn("user is not an admin, so cannot view this task"); + const allowed = await aclManager.hasACL(request, task.acls); + if (!allowed) { + console.warn("user does not have necessary ACLs"); peer.send( `error/${taskId}/Unknown task/Drop couldn't find the task you're looking for.` ); @@ -186,7 +194,7 @@ export interface Task { id: string; name: string; run: (context: TaskRunContext) => Promise; - requireAdmin?: boolean; + acls: string[]; } export type TaskMessage = { diff --git a/server/plugins/redirect.ts b/server/plugins/redirect.ts index fd1031c..9ef78e3 100644 --- a/server/plugins/redirect.ts +++ b/server/plugins/redirect.ts @@ -1,4 +1,5 @@ import { H3Error } from "h3"; +import sessionHandler from "../internal/session"; export default defineNitroPlugin((nitro) => { nitro.hooks.hook("error", async (error, { event }) => { @@ -13,9 +14,8 @@ export default defineNitroPlugin((nitro) => { switch (error.statusCode) { case 401: case 403: - const userId = await event.context.session.getUserId(event); + const userId = await sessionHandler.getUserId(event); if (userId) break; - console.log("user is signed out, redirecting"); return sendRedirect( event, `/signin?redirect=${encodeURIComponent(event.path)}` diff --git a/server/plugins/session.ts b/server/plugins/session.ts deleted file mode 100644 index e8aea3c..0000000 --- a/server/plugins/session.ts +++ /dev/null @@ -1,7 +0,0 @@ -import session from "../internal/session"; - -export default defineNitroPlugin((nitro) => { - nitro.hooks.hook('request', (h3) => { - h3.context.session = session; - }) -}); \ No newline at end of file diff --git a/server/routes/signout.get.ts b/server/routes/signout.get.ts index 36b5a6f..9706df9 100644 --- a/server/routes/signout.get.ts +++ b/server/routes/signout.get.ts @@ -1,5 +1,7 @@ +import sessionHandler from "../internal/session"; + export default defineEventHandler(async (h3) => { - await h3.context.session.clearSession(h3); + await sessionHandler.clearSession(h3); return sendRedirect(h3, "/signin"); });