From e7837af0e71a071a9f2ba81d176bb8dce764fa80 Mon Sep 17 00:00:00 2001 From: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com> Date: Sun, 2 Feb 2025 10:05:13 +1030 Subject: [PATCH 01/22] feat(news): added ability to delete news articles --- components/DeleteNewsModal.vue | 72 ++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 components/DeleteNewsModal.vue diff --git a/components/DeleteNewsModal.vue b/components/DeleteNewsModal.vue new file mode 100644 index 00000000..475c8bd5 --- /dev/null +++ b/components/DeleteNewsModal.vue @@ -0,0 +1,72 @@ + + + From 866c4d354e461a93185b9e300ae4537e2fa007ac Mon Sep 17 00:00:00 2001 From: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com> Date: Sun, 2 Feb 2025 10:05:55 +1030 Subject: [PATCH 02/22] feat(news): Created ability to create news articles --- components/NewsArticleCreate.vue | 346 +++++++++++++++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 components/NewsArticleCreate.vue diff --git a/components/NewsArticleCreate.vue b/components/NewsArticleCreate.vue new file mode 100644 index 00000000..77590623 --- /dev/null +++ b/components/NewsArticleCreate.vue @@ -0,0 +1,346 @@ + + + + + From 28bf070ce233816e67e69e0501608f122b73eb81 Mon Sep 17 00:00:00 2001 From: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com> Date: Sun, 2 Feb 2025 10:06:38 +1030 Subject: [PATCH 03/22] feat(news): Added ability to search and filter news articles --- components/NewsDirectory.vue | 193 +++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 components/NewsDirectory.vue diff --git a/components/NewsDirectory.vue b/components/NewsDirectory.vue new file mode 100644 index 00000000..2bff2ad3 --- /dev/null +++ b/components/NewsDirectory.vue @@ -0,0 +1,193 @@ + + + + + From 5d8f9d38133e136a04ba3bb6b1b26ae04067fe99 Mon Sep 17 00:00:00 2001 From: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com> Date: Sun, 2 Feb 2025 10:07:24 +1030 Subject: [PATCH 04/22] Create useNews.ts --- composables/useNews.ts | 50 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 composables/useNews.ts diff --git a/composables/useNews.ts b/composables/useNews.ts new file mode 100644 index 00000000..f5f20936 --- /dev/null +++ b/composables/useNews.ts @@ -0,0 +1,50 @@ +export const useNews = () => { + const getAll = async (options?: { + limit?: number; + skip?: number; + orderBy?: 'asc' | 'desc'; + tags?: string[]; + search?: string; + }) => { + const query = new URLSearchParams(); + + if (options?.limit) query.set('limit', options.limit.toString()); + if (options?.skip) query.set('skip', options.skip.toString()); + if (options?.orderBy) query.set('order', options.orderBy); + if (options?.tags?.length) query.set('tags', options.tags.join(',')); + if (options?.search) query.set('search', options.search); + + return await useFetch(`/api/v1/news?${query.toString()}`); + }; + + const getById = async (id: string) => { + return await useFetch(`/api/v1/news/${id}`); + }; + + const create = async (article: { + title: string; + excerpt: string; + content: string; + image?: string; + tags: string[]; + authorId: string; + }) => { + return await $fetch('/api/v1/news', { + method: 'POST', + body: article + }); + }; + + const remove = async (id: string) => { + return await $fetch(`/api/v1/news/${id}`, { + method: 'DELETE' + }); + }; + + return { + getAll, + getById, + create, + remove + }; +}; From d8e964e06b232e710416dffc343aa0759878442b Mon Sep 17 00:00:00 2001 From: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com> Date: Sun, 2 Feb 2025 10:08:34 +1030 Subject: [PATCH 05/22] feat(news): Added backend for news From f78b29b7fd381f28377382caf16c1316f6347ecb Mon Sep 17 00:00:00 2001 From: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com> Date: Sun, 2 Feb 2025 10:09:25 +1030 Subject: [PATCH 06/22] feat(news) Added news page/sidebar --- pages/news.vue | 156 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 pages/news.vue diff --git a/pages/news.vue b/pages/news.vue new file mode 100644 index 00000000..8f00aa73 --- /dev/null +++ b/pages/news.vue @@ -0,0 +1,156 @@ + + + + + + From 6c7866ad143d9d07597b9a93c0c3d59da9635b49 Mon Sep 17 00:00:00 2001 From: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com> Date: Sun, 2 Feb 2025 10:10:16 +1030 Subject: [PATCH 07/22] feat(news): Created article overview page --- pages/news/index.vue | 137 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 pages/news/index.vue diff --git a/pages/news/index.vue b/pages/news/index.vue new file mode 100644 index 00000000..9ae7fc9f --- /dev/null +++ b/pages/news/index.vue @@ -0,0 +1,137 @@ + + + + + From 3a5507553279b3add7bcd3aff91443679fe9eac4 Mon Sep 17 00:00:00 2001 From: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com> Date: Sun, 2 Feb 2025 10:15:09 +1030 Subject: [PATCH 08/22] feat(news): Created article full screen view --- pages/news/article/[id]/index.vue | 150 ++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 pages/news/article/[id]/index.vue diff --git a/pages/news/article/[id]/index.vue b/pages/news/article/[id]/index.vue new file mode 100644 index 00000000..112beb1c --- /dev/null +++ b/pages/news/article/[id]/index.vue @@ -0,0 +1,150 @@ + + + + + + + From 1ed15902a37777f8471bd8ece7c14cde09abe736 Mon Sep 17 00:00:00 2001 From: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com> Date: Sun, 2 Feb 2025 10:16:28 +1030 Subject: [PATCH 09/22] feat(news): Updated user for authoring articles --- prisma/schema/user.prisma | 1 + 1 file changed, 1 insertion(+) diff --git a/prisma/schema/user.prisma b/prisma/schema/user.prisma index 0e46c0d4..539af45a 100644 --- a/prisma/schema/user.prisma +++ b/prisma/schema/user.prisma @@ -11,6 +11,7 @@ model User { clients Client[] notifications Notification[] collections Collection[] + news News[] } model Notification { From 623ab7d786ebd7dd1538910b422c574a1f96b613 Mon Sep 17 00:00:00 2001 From: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com> Date: Sun, 2 Feb 2025 10:17:21 +1030 Subject: [PATCH 10/22] feat(DB): Updated DB for news articles to be stored in the DB --- prisma/schema/news.prisma | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 prisma/schema/news.prisma diff --git a/prisma/schema/news.prisma b/prisma/schema/news.prisma new file mode 100644 index 00000000..dd2bde6b --- /dev/null +++ b/prisma/schema/news.prisma @@ -0,0 +1,13 @@ +model News { + id String @id @default(uuid()) + title String + content String @db.Text + excerpt String + tags String[] + image String? + publishedAt DateTime @default(now()) + author User @relation(fields: [authorId], references: [id]) + authorId String + + @@map("news") +} From 88453f1ec44b62316f4086735d1df08ce0e9b6bf Mon Sep 17 00:00:00 2001 From: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com> Date: Sun, 2 Feb 2025 10:18:27 +1030 Subject: [PATCH 11/22] feat(backend): Added backend communction between API & Frontend --- server/internal/news/index.ts | 114 ++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 server/internal/news/index.ts diff --git a/server/internal/news/index.ts b/server/internal/news/index.ts new file mode 100644 index 00000000..e1f91191 --- /dev/null +++ b/server/internal/news/index.ts @@ -0,0 +1,114 @@ +import prisma from "../db/database"; + +class NewsManager { + async create(data: { + title: string; + content: string; + excerpt: string; + tags: string[]; + authorId: string; + image?: string; + }) { + return await prisma.news.create({ + data: { + title: data.title, + content: data.content, + excerpt: data.excerpt, + tags: data.tags, + image: data.image, + author: { + connect: { + id: data.authorId, + }, + }, + publishedAt: new Date(), + }, + }); + } + + async getAll(options?: { + take?: number; + skip?: number; + orderBy?: 'asc' | 'desc'; + tags?: string[]; + search?: string; + }) { + const where = { + AND: [ + options?.tags?.length ? { + tags: { + hasSome: options.tags, + }, + } : {}, + options?.search ? { + OR: [ + { + title: { + contains: options.search, + mode: 'insensitive' as const, + }, + }, + { + content: { + contains: options.search, + mode: 'insensitive' as const, + }, + }, + ], + } : {}, + ], + }; + + return await prisma.news.findMany({ + where, + take: options?.take, + skip: options?.skip, + orderBy: { + publishedAt: options?.orderBy || 'desc', + }, + include: { + author: { + select: { + id: true, + displayName: true, + }, + }, + }, + }); + } + + async getById(id: string) { + return await prisma.news.findUnique({ + where: { id }, + include: { + author: { + select: { + id: true, + displayName: true, + }, + }, + }, + }); + } + + async update(id: string, data: { + title?: string; + content?: string; + excerpt?: string; + tags?: string[]; + image?: string; + }) { + return await prisma.news.update({ + where: { id }, + data, + }); + } + + async delete(id: string) { + return await prisma.news.delete({ + where: { id }, + }); + } +} + +export default new NewsManager(); From 86053815f059f5e76e6bd7d7d46337fe7c5cd0b4 Mon Sep 17 00:00:00 2001 From: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com> Date: Sun, 2 Feb 2025 10:19:31 +1030 Subject: [PATCH 12/22] feat(api): Added API for creating articles --- server/api/v1/news/index.post.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 server/api/v1/news/index.post.ts diff --git a/server/api/v1/news/index.post.ts b/server/api/v1/news/index.post.ts new file mode 100644 index 00000000..61c4d926 --- /dev/null +++ b/server/api/v1/news/index.post.ts @@ -0,0 +1,24 @@ +import { defineEventHandler, createError, readBody } from "h3"; +import newsManager from "~/server/internal/news"; + +export default defineEventHandler(async (event) => { + const body = await readBody(event); + + if (!body.authorId) { + throw createError({ + statusCode: 400, + message: 'Author ID is required' + }); + } + + const article = await newsManager.create({ + title: body.title, + content: body.content, + excerpt: body.excerpt, + tags: body.tags, + image: body.image, + authorId: body.authorId, + }); + + return article; +}); From 2ef8f2f93c51c2174bd77589fdab6b3a1d36c3e5 Mon Sep 17 00:00:00 2001 From: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com> Date: Sun, 2 Feb 2025 10:20:26 +1030 Subject: [PATCH 13/22] feat(api): Added API for fetching news articles --- server/api/v1/news/index.get.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 server/api/v1/news/index.get.ts diff --git a/server/api/v1/news/index.get.ts b/server/api/v1/news/index.get.ts new file mode 100644 index 00000000..3bc4108e --- /dev/null +++ b/server/api/v1/news/index.get.ts @@ -0,0 +1,17 @@ +import { defineEventHandler, getQuery } from "h3"; +import newsManager from "~/server/internal/news"; + +export default defineEventHandler(async (event) => { + const query = getQuery(event); + + const options = { + take: query.limit ? parseInt(query.limit as string) : undefined, + skip: query.skip ? parseInt(query.skip as string) : undefined, + orderBy: query.order as 'asc' | 'desc', + tags: query.tags ? (query.tags as string).split(',') : undefined, + search: query.search as string, + }; + + const news = await newsManager.getAll(options); + return news; +}); From 1286248207b65f39b5fe1200f6631c3d1f459619 Mon Sep 17 00:00:00 2001 From: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com> Date: Sun, 2 Feb 2025 10:21:10 +1030 Subject: [PATCH 14/22] feat(api): Added API for retriving information about a spesific news article --- server/api/v1/news/[id].get.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 server/api/v1/news/[id].get.ts diff --git a/server/api/v1/news/[id].get.ts b/server/api/v1/news/[id].get.ts new file mode 100644 index 00000000..07c62dfe --- /dev/null +++ b/server/api/v1/news/[id].get.ts @@ -0,0 +1,22 @@ +import { defineEventHandler, createError } from "h3"; +import newsManager from "~/server/internal/news"; + +export default defineEventHandler(async (event) => { + const id = event.context.params?.id; + if (!id) { + throw createError({ + statusCode: 400, + message: "Missing news ID", + }); + } + + const news = await newsManager.getById(id); + if (!news) { + throw createError({ + statusCode: 404, + message: "News article not found", + }); + } + + return news; +}); From 9344d94e4ccd21d2e1015346d1aed3fa112b588d Mon Sep 17 00:00:00 2001 From: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com> Date: Sun, 2 Feb 2025 10:21:43 +1030 Subject: [PATCH 15/22] feat(api): Added API for deleting news articles --- server/api/v1/news/[id].delete.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 server/api/v1/news/[id].delete.ts diff --git a/server/api/v1/news/[id].delete.ts b/server/api/v1/news/[id].delete.ts new file mode 100644 index 00000000..09949df9 --- /dev/null +++ b/server/api/v1/news/[id].delete.ts @@ -0,0 +1,23 @@ +import { defineEventHandler, createError } from "h3"; +import newsManager from "~/server/internal/news"; + +export default defineEventHandler(async (event) => { + const userId = await event.context.session.getUserId(event); + if (!userId) { + throw createError({ + statusCode: 401, + message: "Unauthorized", + }); + } + + const id = event.context.params?.id; + if (!id) { + throw createError({ + statusCode: 400, + message: "Missing news ID", + }); + } + + await newsManager.delete(id); + return { success: true }; +}); From 256fbd6afa5446c7841989c95ad2e444c8191abf Mon Sep 17 00:00:00 2001 From: Aden Lindsay <140392385+AdenMGB@users.noreply.github.com> Date: Mon, 3 Feb 2025 16:50:10 +1030 Subject: [PATCH 16/22] fix(backend): Add forgotton migration for news storage --- .../20250128102738_add_news/migration.sql | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 prisma/migrations/20250128102738_add_news/migration.sql diff --git a/prisma/migrations/20250128102738_add_news/migration.sql b/prisma/migrations/20250128102738_add_news/migration.sql new file mode 100644 index 00000000..68b2aca1 --- /dev/null +++ b/prisma/migrations/20250128102738_add_news/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "news" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "excerpt" TEXT NOT NULL, + "tags" TEXT[], + "image" TEXT, + "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "authorId" TEXT NOT NULL, + + CONSTRAINT "news_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "news" ADD CONSTRAINT "news_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; From 090d2e6586affbff9e1080bfb599dcb120edd3e2 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Tue, 4 Feb 2025 13:15:34 +1100 Subject: [PATCH 17/22] 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 533eb483..637b4e1e 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 328ae414..d62553b6 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 00000000..30237279 --- /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 00000000..846ce199 --- /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 436373c4..64677b8a 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 b18cf4aa..179ada52 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 539af45a..30173fec 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 065c6a40..381dea30 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 0b7efb4c..e0b6881a 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 60a700c6..015557e3 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 6e84ec25..3b6252c1 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 b94b304a..8453b6fc 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 4cab69c9..0f98b3ef 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 7ff78c14..8878c805 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 83ec5df0..578bf96e 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 ded9eae1..4540dfd2 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 a80da87c..cf85c465 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 bbb2fc31..3771179d 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 9137adaa..b1b3c3d0 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 d7712036..15992334 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 90c93445..adf5109a 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 98251a4f..8d60a05b 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 1dfe99a7..3460b20e 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 d14e0f53..b8429b34 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 9cb0d05e..00000000 --- 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 d526c1f7..192a7c25 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 b241860d..8b1df155 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 54e9f14e..996bf2b3 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 fd3a6176..ea8e4b31 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 bcc151e4..46eb0b34 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 17c34765..575ae3c0 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 2356e432..d6a2394f 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 d275036a..e00b3d63 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 04609e5c..9bb18540 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 d72dd74a..77f3c393 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 c4af2fc6..4c8b8fde 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 25057794..22a357d5 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 76a3f51f..2d4c4202 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 4841b133..1d3daadd 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 94d4b6f0..d7c1ed8c 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 c89a147d..a8f63ab3 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 0d9b2c21..75129aaf 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 ef180c40..8f938448 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 65a6cba1..9c3502f4 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 9f29bbaf..7b119277 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 13091285..ebd65745 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 2bd2e352..60802f1b 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 afdf6927..3e48981b 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 6fcbb1cd..a27e18dc 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 a47f846a..79e3124c 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 85873366..9fef6ff6 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 16d3fac8..be137f0b 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 494d5fe2..37c729c2 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 a1862163..520033e3 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 dc856346..ccb2b3ad 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 b611c1c0..fb8a2544 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 7b7c1baf..e2fddb45 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 00000000..874fa542 --- /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 e33b8add..00000000 --- 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 308c90d6..00000000 --- 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 2ed0a4e9..b33f9980 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 1a6b4508..5d97c451 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 ec9cf96c..3049b221 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 8868b6ea..a70cf501 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 fd1031c2..9ef78e30 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 e8aea3cc..00000000 --- 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 36b5a6f8..9706df9c 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"); }); From 0877638fc450b32d0a9b27c93dfe3a652306cbe2 Mon Sep 17 00:00:00 2001 From: DecDuck Date: Fri, 7 Feb 2025 17:26:23 +1100 Subject: [PATCH 18/22] feat(acls): refactor & acl descriptions --- nuxt.config.ts | 2 +- .../api/v1/admin/game/image/index.delete.ts | 3 +- server/api/v1/admin/import/game/index.post.ts | 5 +- server/api/v1/admin/import/game/search.get.ts | 4 +- server/api/v1/admin/user/token/token.get.ts | 9 + server/api/v1/auth/signup/simple.post.ts | 3 +- server/api/v1/client/auth/handshake.post.ts | 7 +- server/api/v1/client/object/[id]/index.get.ts | 3 +- server/api/v1/object/[id]/index.delete.ts | 3 +- server/api/v1/object/[id]/index.get.ts | 3 +- server/api/v1/object/[id]/index.post.ts | 3 +- server/internal/acls/descriptions.ts | 58 ++++++ server/internal/acls/index.ts | 4 +- server/internal/clients/ca.ts | 5 +- server/internal/clients/event-handler.ts | 5 +- server/internal/metadata/index.ts | 3 +- server/internal/objects/fsBackend.ts | 2 +- server/internal/objects/index.ts | 189 +----------------- server/internal/objects/objectHandler.ts | 187 +++++++++++++++++ server/internal/objects/transactional.ts | 2 +- server/plugins/ca.ts | 9 - server/plugins/metadata.ts | 25 --- server/plugins/objects.ts | 10 - 23 files changed, 291 insertions(+), 253 deletions(-) create mode 100644 server/api/v1/admin/user/token/token.get.ts create mode 100644 server/internal/acls/descriptions.ts create mode 100644 server/internal/objects/objectHandler.ts delete mode 100644 server/plugins/metadata.ts delete mode 100644 server/plugins/objects.ts diff --git a/nuxt.config.ts b/nuxt.config.ts index 2852dbbe..f241a58b 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -32,7 +32,7 @@ export default defineNuxtConfig({ }, }, - extends: ['./drop-base'], + extends: ["./drop-base"], // Module config from here down modules: ["@nuxt/content", "vue3-carousel-nuxt"], diff --git a/server/api/v1/admin/game/image/index.delete.ts b/server/api/v1/admin/game/image/index.delete.ts index 3b6252c1..feb338e3 100644 --- a/server/api/v1/admin/game/image/index.delete.ts +++ b/server/api/v1/admin/game/image/index.delete.ts @@ -1,5 +1,6 @@ import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; +import objectHandler from "~/server/internal/objects"; export default defineEventHandler(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, [ @@ -36,7 +37,7 @@ export default defineEventHandler(async (h3) => { throw createError({ statusCode: 400, statusMessage: "Image not found" }); game.mImageLibrary.splice(imageIndex, 1); - await h3.context.objects.delete(imageId); + await objectHandler.delete(imageId); if (game.mBannerId === imageId) { game.mBannerId = game.mImageLibrary[0]; diff --git a/server/api/v1/admin/import/game/index.post.ts b/server/api/v1/admin/import/game/index.post.ts index 15992334..504ea5f9 100644 --- a/server/api/v1/admin/import/game/index.post.ts +++ b/server/api/v1/admin/import/game/index.post.ts @@ -1,5 +1,6 @@ import aclManager from "~/server/internal/acls"; import libraryManager from "~/server/internal/library"; +import metadataHandler from "~/server/internal/metadata"; import { GameMetadataSearchResult, GameMetadataSource, @@ -30,8 +31,8 @@ export default defineEventHandler(async (h3) => { }); if (!metadata || !metadata.id || !metadata.sourceId) { - return await h3.context.metadataHandler.createGameWithoutMetadata(path); + return await metadataHandler.createGameWithoutMetadata(path); } else { - return await h3.context.metadataHandler.createGame(metadata, path); + return await metadataHandler.createGame(metadata, path); } }); diff --git a/server/api/v1/admin/import/game/search.get.ts b/server/api/v1/admin/import/game/search.get.ts index adf5109a..723de480 100644 --- a/server/api/v1/admin/import/game/search.get.ts +++ b/server/api/v1/admin/import/game/search.get.ts @@ -1,5 +1,5 @@ import aclManager from "~/server/internal/acls"; -import libraryManager from "~/server/internal/library"; +import metadataHandler from "~/server/internal/metadata"; export default defineEventHandler(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, [ @@ -12,7 +12,7 @@ export default defineEventHandler(async (h3) => { if (!search) throw createError({ statusCode: 400, statusMessage: "Invalid search" }); - const results = await h3.context.metadataHandler.search(search); + const results = await metadataHandler.search(search); if (results.length == 0) throw createError({ diff --git a/server/api/v1/admin/user/token/token.get.ts b/server/api/v1/admin/user/token/token.get.ts new file mode 100644 index 00000000..d118809f --- /dev/null +++ b/server/api/v1/admin/user/token/token.get.ts @@ -0,0 +1,9 @@ +import aclManager from "~/server/internal/acls"; +import { userACLDescriptions } from "~/server/internal/acls/descriptions"; + +export default defineEventHandler(async (h3) => { + const userId = await aclManager.getUserIdACL(h3, []); // No ACLs only allows session authentication + if (!userId) throw createError({ statusCode: 403 }); + + return userACLDescriptions; +}); diff --git a/server/api/v1/auth/signup/simple.post.ts b/server/api/v1/auth/signup/simple.post.ts index e4e44181..f6e27faa 100644 --- a/server/api/v1/auth/signup/simple.post.ts +++ b/server/api/v1/auth/signup/simple.post.ts @@ -3,6 +3,7 @@ import prisma from "~/server/internal/db/database"; import { createHash } from "~/server/internal/security/simple"; import { v4 as uuidv4 } from "uuid"; import * as jdenticon from "jdenticon"; +import objectHandler from "~/server/internal/objects"; // Only really a simple test, in case people mistype their emails const mailRegex = /^\S+@\S+\.\S+$/; @@ -88,7 +89,7 @@ export default defineEventHandler(async (h3) => { const userId = uuidv4(); const profilePictureId = uuidv4(); - await h3.context.objects.createFromSource( + await objectHandler.createFromSource( profilePictureId, async () => jdenticon.toPng(username, 256), {}, diff --git a/server/api/v1/client/auth/handshake.post.ts b/server/api/v1/client/auth/handshake.post.ts index 9d69b51d..62785f50 100644 --- a/server/api/v1/client/auth/handshake.post.ts +++ b/server/api/v1/client/auth/handshake.post.ts @@ -1,4 +1,5 @@ import clientHandler from "~/server/internal/clients/handler"; +import { useCertificateAuthority } from "~/server/plugins/ca"; export default defineEventHandler(async (h3) => { const body = await readBody(h3); @@ -27,14 +28,14 @@ export default defineEventHandler(async (h3) => { statusMessage: "Invalid token", }); - const ca = h3.context.ca; - const bundle = await ca.generateClientCertificate( + const certificateAuthority = useCertificateAuthority(); + const bundle = await certificateAuthority.generateClientCertificate( clientId, metadata.data.name ); const client = await clientHandler.finialiseClient(clientId); - await ca.storeClientCertificate(clientId, bundle); + await certificateAuthority.storeClientCertificate(clientId, bundle); return { private: bundle.priv, diff --git a/server/api/v1/client/object/[id]/index.get.ts b/server/api/v1/client/object/[id]/index.get.ts index 5793b69c..962d30d3 100644 --- a/server/api/v1/client/object/[id]/index.get.ts +++ b/server/api/v1/client/object/[id]/index.get.ts @@ -1,4 +1,5 @@ import { defineClientEventHandler } from "~/server/internal/clients/event-handler"; +import objectHandler from "~/server/internal/objects"; export default defineClientEventHandler(async (h3, utils) => { const id = getRouterParam(h3, "id"); @@ -6,7 +7,7 @@ export default defineClientEventHandler(async (h3, utils) => { const user = await utils.fetchUser(); - const object = await h3.context.objects.fetchWithPermissions(id, user.id); + const object = await objectHandler.fetchWithPermissions(id, user.id); if (!object) throw createError({ statusCode: 404, statusMessage: "Object not found" }); diff --git a/server/api/v1/object/[id]/index.delete.ts b/server/api/v1/object/[id]/index.delete.ts index 60802f1b..ba8f568c 100644 --- a/server/api/v1/object/[id]/index.delete.ts +++ b/server/api/v1/object/[id]/index.delete.ts @@ -1,4 +1,5 @@ import aclManager from "~/server/internal/acls"; +import objectHandler from "~/server/internal/objects"; export default defineEventHandler(async (h3) => { const id = getRouterParam(h3, "id"); @@ -6,6 +7,6 @@ export default defineEventHandler(async (h3) => { const userId = await aclManager.getUserIdACL(h3, ["object:delete"]); - const result = await h3.context.objects.deleteWithPermission(id, userId); + const result = await objectHandler.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 3e48981b..a67115c5 100644 --- a/server/api/v1/object/[id]/index.get.ts +++ b/server/api/v1/object/[id]/index.get.ts @@ -1,4 +1,5 @@ import aclManager from "~/server/internal/acls"; +import objectHandler from "~/server/internal/objects"; export default defineEventHandler(async (h3) => { const id = getRouterParam(h3, "id"); @@ -6,7 +7,7 @@ export default defineEventHandler(async (h3) => { const userId = await aclManager.getUserIdACL(h3, ["object:read"]); - const object = await h3.context.objects.fetchWithPermissions(id, userId); + const object = await objectHandler.fetchWithPermissions(id, userId); if (!object) throw createError({ statusCode: 404, statusMessage: "Object not found" }); diff --git a/server/api/v1/object/[id]/index.post.ts b/server/api/v1/object/[id]/index.post.ts index a27e18dc..7b08d2fb 100644 --- a/server/api/v1/object/[id]/index.post.ts +++ b/server/api/v1/object/[id]/index.post.ts @@ -1,4 +1,5 @@ import aclManager from "~/server/internal/acls"; +import objectHandler from "~/server/internal/objects"; export default defineEventHandler(async (h3) => { const id = getRouterParam(h3, "id"); @@ -14,7 +15,7 @@ export default defineEventHandler(async (h3) => { const userId = await aclManager.getUserIdACL(h3, ["object:update"]); const buffer = Buffer.from(body); - const result = await h3.context.objects.writeWithPermissions( + const result = await objectHandler.writeWithPermissions( id, async () => buffer, userId diff --git a/server/internal/acls/descriptions.ts b/server/internal/acls/descriptions.ts new file mode 100644 index 00000000..7b3bad58 --- /dev/null +++ b/server/internal/acls/descriptions.ts @@ -0,0 +1,58 @@ +import { systemACLs, userACLs } from "."; + +type ObjectFromList, V = string> = { + [K in T extends ReadonlyArray ? U : never]: V; +}; + +export const userACLDescriptions: ObjectFromList = { + read: "Fetch user information like username, display name, email, etc...", + + "store:read": + "Fetch and search the store for games, developers and publishers.", + + "object:read": + "Read object objects like game images, profile pictures, and downloads.", + "object:update": + "Update objects like game images, profile pictures, and downloads.", + "object:delete": + "Delete objects like game images, profile pictures, and downloads.", + + "notifications:read": "Fetch this account's notifications.", + "notifications:mark": "Mark notifications as read for this account.", + "notifications:listen": "Connect to a websocket to recieve notifications.", + "notifications:delete": "Delete this account's notifications.", + + "collections:new": "Create collections for this account.", + "collections:read": "Fetch all collections (including library).", + "collections:delete": "Delete a collection for this account.", + "collections:add": "Add a game to any collection (excluding library).", + "collections:remove": + "Remove a game from any collection (excluding library).", + "library:add": "Add a game to your library.", + "library:remove": "Remove a game from your library.", +}; + +export const systemACLDescriptions: ObjectFromList = { + "auth:simple:invitation:read": "Fetch simple auth invitations.", + "auth:simple:invitation:new": "Create new simple auth invitations.", + "auth:simple:invitation:delete": "Delete a simple auth invitation.", + + "library:read": "Fetch a list of all games on this instance.", + + "game:read": "Fetch a given game on this instance.", + "game:update": "Update a game on this instance.", + "game:delete": "Delete a game on this instance.", + "game:version:update": "Update the version order on a game.", + "game:version:delete": "Delete a version for a game.", + "game:image:new": "Upload an image for a game.", + "game:image:delete": "Delete an image for a game.", + + "import:version:read": + "Fetch versions to be imported, and information about versions to be imported.", + "import:version:new": "Import a game version.", + "import:game:read": + "Fetch games to be imported, and search the metadata for games.", + "import:game:new": "Import a game.", + + "user:read": "Fetch any user's information.", +}; diff --git a/server/internal/acls/index.ts b/server/internal/acls/index.ts index 874fa542..65b4664f 100644 --- a/server/internal/acls/index.ts +++ b/server/internal/acls/index.ts @@ -4,7 +4,7 @@ import prisma from "../db/database"; import sessionHandler from "../session"; import { MinimumRequestObject } from "~/server/h3"; -const userACLs = [ +export const userACLs = [ "read", "store:read", @@ -32,7 +32,7 @@ const userACLPrefix = "user:"; type UserACL = Array<(typeof userACLs)[number]>; -const systemACLs = [ +export const systemACLs = [ "auth:simple:invitation:read", "auth:simple:invitation:new", "auth:simple:invitation:delete", diff --git a/server/internal/clients/ca.ts b/server/internal/clients/ca.ts index 7665ec40..065b1850 100644 --- a/server/internal/clients/ca.ts +++ b/server/internal/clients/ca.ts @@ -1,6 +1,7 @@ import path from "path"; +import fs from "fs"; import droplet from "@drop/droplet"; -import { CertificateStore } from "./ca-store"; +import { CertificateStore, fsCertificateStore } from "./ca-store"; export type CertificateBundle = { priv: string; @@ -72,4 +73,4 @@ export class CertificateAuthority { async blacklistClient(clientId: string) { await this.certificateStore.blacklistCertificate(clientId); } -} +} \ No newline at end of file diff --git a/server/internal/clients/event-handler.ts b/server/internal/clients/event-handler.ts index 66249fbf..aea667d4 100644 --- a/server/internal/clients/event-handler.ts +++ b/server/internal/clients/event-handler.ts @@ -2,6 +2,7 @@ import { Client, User } from "@prisma/client"; import { EventHandlerRequest, H3Event } from "h3"; import droplet from "@drop/droplet"; import prisma from "../db/database"; +import { useCertificateAuthority } from "~/server/plugins/ca"; export type EventHandlerFunction = ( h3: H3Event, @@ -47,8 +48,8 @@ export function defineClientEventHandler(handler: EventHandlerFunction) { }); } - const ca = h3.context.ca; - const certBundle = await ca.fetchClientCertificate(clientId); + const certificateAuthority = useCertificateAuthority(); + const certBundle = await certificateAuthority.fetchClientCertificate(clientId); // This does the blacklist check already if (!certBundle) throw createError({ diff --git a/server/internal/metadata/index.ts b/server/internal/metadata/index.ts index 5588f8c8..3746525c 100644 --- a/server/internal/metadata/index.ts +++ b/server/internal/metadata/index.ts @@ -232,4 +232,5 @@ export class MetadataHandler { } } -export default new MetadataHandler(); +export const metadataHandler = new MetadataHandler(); +export default metadataHandler; diff --git a/server/internal/objects/fsBackend.ts b/server/internal/objects/fsBackend.ts index 9ce0d31e..3d7f000e 100644 --- a/server/internal/objects/fsBackend.ts +++ b/server/internal/objects/fsBackend.ts @@ -1,4 +1,4 @@ -import { Object, ObjectBackend, ObjectMetadata, ObjectReference, Source } from "."; +import { Object, ObjectBackend, ObjectMetadata, ObjectReference, Source } from "./objectHandler"; import sanitize from "sanitize-filename"; diff --git a/server/internal/objects/index.ts b/server/internal/objects/index.ts index 5d97c451..e18afa23 100644 --- a/server/internal/objects/index.ts +++ b/server/internal/objects/index.ts @@ -1,186 +1,3 @@ -/** - * Objects are basically files, like images or downloads, that have a set of metadata and permissions attached - * They're served by the API from the /api/v1/object/${objectId} endpoint. - * - * It supports streams and buffers, depending on the use case. Buffers will likely only be used internally if - * the data needs to be manipulated somehow. - * - * Objects are designed to be created once, and link to a single ID. For example, each user gets a single object - * that's tied to their profile picture. If they want to update their profile picture, they overwrite that object. - * - * Permissions are a list of strings. Each permission string is in the id:permission format. Eg - * anonymous:read - * myUserId:read - * anotherUserId:write - */ - -import { parse as getMimeTypeBuffer } from "file-type-mime"; -import { Readable } from "stream"; -import { getMimeType as getMimeTypeStream } from "stream-mime-type"; -import { v4 as uuidv4 } from "uuid"; - -export type ObjectReference = string; -export type ObjectMetadata = { - mime: string; - permissions: string[]; - userMetadata: { [key: string]: string }; -}; - -export enum ObjectPermission { - Read = "read", - Write = "write", - Delete = "delete", -} -export const ObjectPermissionPriority: Array = [ - ObjectPermission.Read, - ObjectPermission.Write, - ObjectPermission.Delete, -]; - -export type Object = { mime: string; data: Source }; - -export type Source = Readable | Buffer; - -export abstract class ObjectBackend { - // Interface functions, not designed to be called directly. - // They don't check permissions to provide any utilities - abstract fetch(id: ObjectReference): Promise; - abstract write(id: ObjectReference, source: Source): Promise; - abstract create( - id: string, - source: Source, - metadata: ObjectMetadata - ): Promise; - abstract delete(id: ObjectReference): Promise; - abstract fetchMetadata( - id: ObjectReference - ): Promise; - abstract writeMetadata( - id: ObjectReference, - metadata: ObjectMetadata - ): Promise; - - async createFromSource( - id: string, - sourceFetcher: () => Promise, - metadata: { [key: string]: string }, - permissions: Array - ) { - async function fetchMimeType(source: Source) { - if (source instanceof ReadableStream) { - source = Readable.from(source); - } - if (source instanceof Readable) { - const { stream, mime } = await getMimeTypeStream(source); - return { source: Readable.from(stream), mime: mime }; - } - if (source instanceof Buffer) { - const mime = - getMimeTypeBuffer(new Uint8Array(source).buffer)?.mime ?? "application/octet-stream"; - return { source: source, mime }; - } - - return { source: undefined, mime: undefined }; - } - const { source, mime } = await fetchMimeType(await sourceFetcher()); - if (!mime) - throw new Error("Unable to calculate MIME type - is the source empty?"); - - await this.create(id, source, { - permissions, - userMetadata: metadata, - mime, - }); - } - - async fetchWithPermissions(id: ObjectReference, userId?: string) { - const metadata = await this.fetchMetadata(id); - if (!metadata) return; - - // We only need one permission, so find instead of filter is faster - const myPermissions = metadata.permissions.find((e) => { - if (userId !== undefined && e.startsWith(userId)) return true; - if (userId !== undefined && e.startsWith("internal")) return true; - if (e.startsWith("anonymous")) return true; - return false; - }); - - if (!myPermissions) { - // We do not have access to this object - return; - } - - // Because any permission can be read or up, we automatically know we can read this object - // So just straight return the object - const source = await this.fetch(id); - if (!source) return undefined; - const object: Object = { - data: source, - mime: metadata.mime, - }; - return object; - } - - // If we need to fetch a remote resource, it doesn't make sense - // to immediately fetch the object, *then* check permissions. - // Instead the caller can pass a simple anonymous funciton, like - // () => $fetch('/my-image'); - // And if we actually have permission to write, it fetches it then. - async writeWithPermissions( - id: ObjectReference, - sourceFetcher: () => Promise, - userId?: string - ) { - const metadata = await this.fetchMetadata(id); - if (!metadata) return false; - - const myPermissions = metadata.permissions - .filter((e) => { - if (userId !== undefined && e.startsWith(userId)) return true; - if (userId !== undefined && e.startsWith("internal")) return true; - if (e.startsWith("anonymous")) return true; - return false; - }) - // Strip IDs from permissions - .map((e) => e.split(":").at(1)) - // Map to priority according to array - .map((e) => ObjectPermissionPriority.findIndex((c) => c === e)); - - const requiredPermissionIndex = 1; - const hasPermission = - myPermissions.find((e) => e >= requiredPermissionIndex) != undefined; - - if (!hasPermission) return false; - - const source = await sourceFetcher(); - const result = await this.write(id, source); - - return result; - } - - async deleteWithPermission(id: ObjectReference, userId?: string) { - const metadata = await this.fetchMetadata(id); - if (!metadata) return false; - - const myPermissions = metadata.permissions - .filter((e) => { - if (userId !== undefined && e.startsWith(userId)) return true; - if (userId !== undefined && e.startsWith("internal")) return true; - if (e.startsWith("anonymous")) return true; - return false; - }) - // Strip IDs from permissions - .map((e) => e.split(":").at(1)) - // Map to priority according to array - .map((e) => ObjectPermissionPriority.findIndex((c) => c === e)); - - const requiredPermissionIndex = 2; - const hasPermission = - myPermissions.find((e) => e >= requiredPermissionIndex) != undefined; - - if (!hasPermission) return false; - - const result = await this.delete(id); - return result; - } -} +import { FsObjectBackend } from "./fsBackend"; +export const objectHandler = new FsObjectBackend(); +export default objectHandler \ No newline at end of file diff --git a/server/internal/objects/objectHandler.ts b/server/internal/objects/objectHandler.ts new file mode 100644 index 00000000..8ea48034 --- /dev/null +++ b/server/internal/objects/objectHandler.ts @@ -0,0 +1,187 @@ +/** + * Objects are basically files, like images or downloads, that have a set of metadata and permissions attached + * They're served by the API from the /api/v1/object/${objectId} endpoint. + * + * It supports streams and buffers, depending on the use case. Buffers will likely only be used internally if + * the data needs to be manipulated somehow. + * + * Objects are designed to be created once, and link to a single ID. For example, each user gets a single object + * that's tied to their profile picture. If they want to update their profile picture, they overwrite that object. + * + * Permissions are a list of strings. Each permission string is in the id:permission format. Eg + * anonymous:read + * myUserId:read + * anotherUserId:write + */ + +import { parse as getMimeTypeBuffer } from "file-type-mime"; +import { Readable } from "stream"; +import { getMimeType as getMimeTypeStream } from "stream-mime-type"; +import { v4 as uuidv4 } from "uuid"; + +export type ObjectReference = string; +export type ObjectMetadata = { + mime: string; + permissions: string[]; + userMetadata: { [key: string]: string }; +}; + +export enum ObjectPermission { + Read = "read", + Write = "write", + Delete = "delete", +} +export const ObjectPermissionPriority: Array = [ + ObjectPermission.Read, + ObjectPermission.Write, + ObjectPermission.Delete, +]; + +export type Object = { mime: string; data: Source }; + +export type Source = Readable | Buffer; + +export abstract class ObjectBackend { + // Interface functions, not designed to be called directly. + // They don't check permissions to provide any utilities + abstract fetch(id: ObjectReference): Promise; + abstract write(id: ObjectReference, source: Source): Promise; + abstract create( + id: string, + source: Source, + metadata: ObjectMetadata + ): Promise; + abstract delete(id: ObjectReference): Promise; + abstract fetchMetadata( + id: ObjectReference + ): Promise; + abstract writeMetadata( + id: ObjectReference, + metadata: ObjectMetadata + ): Promise; + + async createFromSource( + id: string, + sourceFetcher: () => Promise, + metadata: { [key: string]: string }, + permissions: Array + ) { + async function fetchMimeType(source: Source) { + if (source instanceof ReadableStream) { + source = Readable.from(source); + } + if (source instanceof Readable) { + const { stream, mime } = await getMimeTypeStream(source); + return { source: Readable.from(stream), mime: mime }; + } + if (source instanceof Buffer) { + const mime = + getMimeTypeBuffer(new Uint8Array(source).buffer)?.mime ?? + "application/octet-stream"; + return { source: source, mime }; + } + + return { source: undefined, mime: undefined }; + } + const { source, mime } = await fetchMimeType(await sourceFetcher()); + if (!mime) + throw new Error("Unable to calculate MIME type - is the source empty?"); + + await this.create(id, source, { + permissions, + userMetadata: metadata, + mime, + }); + } + + async fetchWithPermissions(id: ObjectReference, userId?: string) { + const metadata = await this.fetchMetadata(id); + if (!metadata) return; + + // We only need one permission, so find instead of filter is faster + const myPermissions = metadata.permissions.find((e) => { + if (userId !== undefined && e.startsWith(userId)) return true; + if (userId !== undefined && e.startsWith("internal")) return true; + if (e.startsWith("anonymous")) return true; + return false; + }); + + if (!myPermissions) { + // We do not have access to this object + return; + } + + // Because any permission can be read or up, we automatically know we can read this object + // So just straight return the object + const source = await this.fetch(id); + if (!source) return undefined; + const object: Object = { + data: source, + mime: metadata.mime, + }; + return object; + } + + // If we need to fetch a remote resource, it doesn't make sense + // to immediately fetch the object, *then* check permissions. + // Instead the caller can pass a simple anonymous funciton, like + // () => $fetch('/my-image'); + // And if we actually have permission to write, it fetches it then. + async writeWithPermissions( + id: ObjectReference, + sourceFetcher: () => Promise, + userId?: string + ) { + const metadata = await this.fetchMetadata(id); + if (!metadata) return false; + + const myPermissions = metadata.permissions + .filter((e) => { + if (userId !== undefined && e.startsWith(userId)) return true; + if (userId !== undefined && e.startsWith("internal")) return true; + if (e.startsWith("anonymous")) return true; + return false; + }) + // Strip IDs from permissions + .map((e) => e.split(":").at(1)) + // Map to priority according to array + .map((e) => ObjectPermissionPriority.findIndex((c) => c === e)); + + const requiredPermissionIndex = 1; + const hasPermission = + myPermissions.find((e) => e >= requiredPermissionIndex) != undefined; + + if (!hasPermission) return false; + + const source = await sourceFetcher(); + const result = await this.write(id, source); + + return result; + } + + async deleteWithPermission(id: ObjectReference, userId?: string) { + const metadata = await this.fetchMetadata(id); + if (!metadata) return false; + + const myPermissions = metadata.permissions + .filter((e) => { + if (userId !== undefined && e.startsWith(userId)) return true; + if (userId !== undefined && e.startsWith("internal")) return true; + if (e.startsWith("anonymous")) return true; + return false; + }) + // Strip IDs from permissions + .map((e) => e.split(":").at(1)) + // Map to priority according to array + .map((e) => ObjectPermissionPriority.findIndex((c) => c === e)); + + const requiredPermissionIndex = 2; + const hasPermission = + myPermissions.find((e) => e >= requiredPermissionIndex) != undefined; + + if (!hasPermission) return false; + + const result = await this.delete(id); + return result; + } +} diff --git a/server/internal/objects/transactional.ts b/server/internal/objects/transactional.ts index 15fb41b9..922ff6ae 100644 --- a/server/internal/objects/transactional.ts +++ b/server/internal/objects/transactional.ts @@ -4,7 +4,7 @@ This is used as a utility in metadata handling, so we only fetch the objects if */ import { Readable } from "stream"; import { v4 as uuidv4 } from "uuid"; -import { objectHandler } from "~/server/plugins/objects"; +import objectHandler from "."; export type TransactionDataType = string | Readable | Buffer; type TransactionTable = { [key: string]: TransactionDataType }; // ID to data diff --git a/server/plugins/ca.ts b/server/plugins/ca.ts index 1b99d792..000764ae 100644 --- a/server/plugins/ca.ts +++ b/server/plugins/ca.ts @@ -15,13 +15,4 @@ export default defineNitroPlugin(async (nitro) => { const store = fsCertificateStore(basePath); ca = await CertificateAuthority.new(store); - - nitro.hooks.hook("request", (h3) => { - if (!ca) - throw createError({ - statusCode: 500, - statusMessage: "Certificate authority not initialised", - }); - h3.context.ca = ca; - }); }); diff --git a/server/plugins/metadata.ts b/server/plugins/metadata.ts deleted file mode 100644 index 8fbd8fa1..00000000 --- a/server/plugins/metadata.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { MetadataHandler, MetadataProvider } from "../internal/metadata"; -import { GiantBombProvider } from "../internal/metadata/giantbomb"; -import { ManualMetadataProvider } from "../internal/metadata/manual"; - -export const metadataHandler = new MetadataHandler(); - -const providerCreators: Array<() => MetadataProvider> = [ - () => new GiantBombProvider(), - () => new ManualMetadataProvider(), -]; - -export default defineNitroPlugin(async (nitro) => { - for (const creator of providerCreators) { - try { - const instance = creator(); - metadataHandler.addProvider(instance); - } catch (e) { - console.warn(e); - } - } - - nitro.hooks.hook("request", (h3) => { - h3.context.metadataHandler = metadataHandler; - }); -}); diff --git a/server/plugins/objects.ts b/server/plugins/objects.ts deleted file mode 100644 index 8e9ee40c..00000000 --- a/server/plugins/objects.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { FsObjectBackend } from "../internal/objects/fsBackend"; - -// To-do insert logic surrounding deciding what object backend to use -export const objectHandler = new FsObjectBackend(); - -export default defineNitroPlugin((nitro) => { - nitro.hooks.hook("request", (h3) => { - h3.context.objects = objectHandler; - }); -}); From b6189d12e70cbb8c499b9df052c5d4e96f48ea5a Mon Sep 17 00:00:00 2001 From: DecDuck Date: Sat, 8 Feb 2025 11:37:42 +1100 Subject: [PATCH 19/22] fix(droplet): add aarch64 optional packages --- package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 03d1cadd..dcf408de 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,9 @@ }, "optionalDependencies": { "@drop/droplet-linux-x64-gnu": "^0.7.0", - "@drop/droplet-win32-x64-msvc": "^0.7.0" + "@drop/droplet-win32-x64-msvc": "^0.7.0", + "@drop/droplet-darwin-arm64": "^0.7.0", + "@drop/droplet-linux-arm64-gnu": "^0.7.0" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } From 97792f0707dcdfdad351a82ad444af1da47b570a Mon Sep 17 00:00:00 2001 From: DecDuck Date: Sat, 8 Feb 2025 11:41:16 +1100 Subject: [PATCH 20/22] fix: home page now (temporarily) redirects to store --- pages/index.vue | 16 +++------------- yarn.lock | 10 ++++++++++ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/pages/index.vue b/pages/index.vue index 4388b2a8..458e24a6 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -1,15 +1,5 @@ diff --git a/yarn.lock b/yarn.lock index 365d4880..8569b10a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -288,6 +288,16 @@ resolved "https://registry.yarnpkg.com/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz#037817b574262134cabd68fc4ec1a454f168407b" integrity sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw== +"@drop/droplet-darwin-arm64@^0.7.0": + version "0.7.0" + resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet-darwin-arm64/-/@drop/droplet-darwin-arm64-0.7.0.tgz#34f7c6d168d6738d471c5f7287772e9b42fa7e33" + integrity sha1-NPfG0WjWc41HHF9yh3cum0L6fjM= + +"@drop/droplet-linux-arm64-gnu@^0.7.0": + version "0.7.0" + resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet-linux-arm64-gnu/-/@drop/droplet-linux-arm64-gnu-0.7.0.tgz#783b9d24879d9a4e3ae8c8693cc1de7868284a61" + integrity sha1-eDudJIedmk466MhpPMHeeGgoSmE= + "@drop/droplet-linux-x64-gnu@0.7.0", "@drop/droplet-linux-x64-gnu@^0.7.0": version "0.7.0" resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet-linux-x64-gnu/-/@drop/droplet-linux-x64-gnu-0.7.0.tgz#128e37707481cfcbbeb057142164f3e637f13f26" From 31aaec74af642d35d5d3c22bd23b5231d150dbee Mon Sep 17 00:00:00 2001 From: DecDuck Date: Fri, 14 Feb 2025 20:01:18 +1100 Subject: [PATCH 21/22] feat: migrate to tailwind v4 and fix user token API --- assets/core.scss | 13 +- assets/tailwindcss.css | 4 + components/AddLibraryButton.vue | 4 +- components/UserHeader/UserWidget.vue | 16 +- nuxt.config.ts | 12 +- package.json | 9 +- .../migration.sql | 8 + .../migration.sql | 14 ++ .../migration.sql | 20 ++ prisma/schema/auth.prisma | 6 +- prisma/schema/content.prisma | 4 +- prisma/schema/schema.prisma | 2 +- server/api/v1/user/token/[id]/index.delete.ts | 23 ++ .../token.get.ts => user/token/acls.get.ts} | 0 server/api/v1/user/token/index.get.ts | 15 ++ server/api/v1/user/token/index.post.ts | 41 ++++ server/internal/db/database.ts | 2 +- tailwind.config.js | 1 - yarn.lock | 211 +++++++++++++++--- 19 files changed, 348 insertions(+), 57 deletions(-) create mode 100644 assets/tailwindcss.css create mode 100644 prisma/migrations/20250208004345_add_api_token_name/migration.sql create mode 100644 prisma/migrations/20250208005625_add_id_to_token/migration.sql create mode 100644 prisma/migrations/20250211230021_ensure_non_null_launch_and_setup_commands/migration.sql create mode 100644 server/api/v1/user/token/[id]/index.delete.ts rename server/api/v1/{admin/user/token/token.get.ts => user/token/acls.get.ts} (100%) create mode 100644 server/api/v1/user/token/index.get.ts create mode 100644 server/api/v1/user/token/index.post.ts diff --git a/assets/core.scss b/assets/core.scss index c510b1db..eddfa750 100644 --- a/assets/core.scss +++ b/assets/core.scss @@ -1,7 +1,3 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - $motiva: ( ("MotivaSansThin.ttf", "ttf", 100, normal), ("MotivaSansLight.woff.ttf", "woff", 300, normal), @@ -72,3 +68,12 @@ $helvetica: ( .store-caoursel > .carousel__viewport { overflow: visible !important; } + + +button { + cursor: pointer !important; +} + +html { + background-color: oklch(.21 .006 285.885); +} \ No newline at end of file diff --git a/assets/tailwindcss.css b/assets/tailwindcss.css new file mode 100644 index 00000000..c64f75c3 --- /dev/null +++ b/assets/tailwindcss.css @@ -0,0 +1,4 @@ +@import "tailwindcss"; +@plugin "@tailwindcss/typography"; +@plugin "@tailwindcss/forms"; +@config "../tailwind.config.js"; \ No newline at end of file diff --git a/components/AddLibraryButton.vue b/components/AddLibraryButton.vue index 171720b9..19c7ceee 100644 --- a/components/AddLibraryButton.vue +++ b/components/AddLibraryButton.vue @@ -4,7 +4,7 @@ :loading="isLibraryLoading" @click="() => toggleLibrary()" :style="'none'" - class="transition w-48 inline-flex items-center justify-center gap-x-2 rounded-l-md bg-white/10 group-hover:bg-white/15 text-zinc-100 backdrop-blur px-5 py-3 active:scale-95" + class="transition w-48 h-fit gap-x-2 rounded-none rounded-l-md bg-white/10 hover:bg-white/20 text-zinc-100 backdrop-blur px-5 py-3 active:scale-95" > {{ inLibrary ? "In Library" : "Add to Library" }}