diff --git a/components/AccountSidebar.vue b/components/AccountSidebar.vue index 9595945..66fe676 100644 --- a/components/AccountSidebar.vue +++ b/components/AccountSidebar.vue @@ -41,14 +41,10 @@ diff --git a/prisma/migrations/20250405062945_make_last_accessed_optional_on_save_slots/migration.sql b/prisma/migrations/20250405062945_make_last_accessed_optional_on_save_slots/migration.sql new file mode 100644 index 0000000..fef07e3 --- /dev/null +++ b/prisma/migrations/20250405062945_make_last_accessed_optional_on_save_slots/migration.sql @@ -0,0 +1,8 @@ +-- DropForeignKey +ALTER TABLE "SaveSlot" DROP CONSTRAINT "SaveSlot_lastUsedClientId_fkey"; + +-- AlterTable +ALTER TABLE "SaveSlot" ALTER COLUMN "lastUsedClientId" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "SaveSlot" ADD CONSTRAINT "SaveSlot_lastUsedClientId_fkey" FOREIGN KEY ("lastUsedClientId") REFERENCES "Client"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema/content.prisma b/prisma/schema/content.prisma index 166f4ab..bf33f1b 100644 --- a/prisma/schema/content.prisma +++ b/prisma/schema/content.prisma @@ -76,8 +76,8 @@ model SaveSlot { createdAt DateTime @default(now()) playtime Float @default(0) // hours - lastUsedClientId String - lastUsedClient Client @relation(fields: [lastUsedClientId], references: [id]) + lastUsedClientId String? + lastUsedClient Client? @relation(fields: [lastUsedClientId], references: [id]) history String[] // list of objects historyChecksums String[] // list of hashes diff --git a/server/api/v1/admin/news/index.get.ts b/server/api/v1/admin/news/index.get.ts index 392f4b7..df11a96 100644 --- a/server/api/v1/admin/news/index.get.ts +++ b/server/api/v1/admin/news/index.get.ts @@ -27,7 +27,8 @@ export default defineEventHandler(async (h3) => { take: parseInt(query.limit as string), skip: parseInt(query.skip as string), orderBy: orderBy, - ...(tags && { tags: tags.map((e) => e.toString()) }), + ...(tags && { tags: tags + .map((e) => e.toString()) }), search: query.search as string, }; diff --git a/server/api/v1/client/capability/index.post.ts b/server/api/v1/client/capability/index.post.ts index 8ddaddf..fcb585a 100644 --- a/server/api/v1/client/capability/index.post.ts +++ b/server/api/v1/client/capability/index.post.ts @@ -3,47 +3,60 @@ import capabilityManager, { validCapabilities, } from "~/server/internal/clients/capabilities"; import { defineClientEventHandler } from "~/server/internal/clients/event-handler"; +import notificationSystem from "~/server/internal/notifications"; -export default defineClientEventHandler(async (h3, { clientId }) => { - const body = await readBody(h3); - const rawCapability = body.capability; - const configuration = body.configuration; +export default defineClientEventHandler( + async (h3, { clientId, fetchClient, fetchUser }) => { + const body = await readBody(h3); + const rawCapability = body.capability; + const configuration = body.configuration; - if (!rawCapability || typeof rawCapability !== "string") - throw createError({ - statusCode: 400, - statusMessage: "capability must be a string", + if (!rawCapability || typeof rawCapability !== "string") + throw createError({ + statusCode: 400, + statusMessage: "capability must be a string", + }); + + if (!configuration || typeof configuration !== "object") + throw createError({ + statusCode: 400, + statusMessage: "configuration must be an object", + }); + + const capability = rawCapability as InternalClientCapability; + + if (!validCapabilities.includes(capability)) + throw createError({ + statusCode: 400, + statusMessage: "Invalid capability.", + }); + + const isValid = await capabilityManager.validateCapabilityConfiguration( + capability, + configuration + ); + if (!isValid) + throw createError({ + statusCode: 400, + statusMessage: "Invalid capability configuration.", + }); + + await capabilityManager.upsertClientCapability( + capability, + configuration, + clientId + ); + + const client = await fetchClient(); + const user = await fetchUser(); + + await notificationSystem.push(user.id, { + nonce: `capability-${clientId}-${capability}`, + title: `"${client.name}" can now access ${capability}`, + description: `A device called "${client.name}" now has access to your ${capability}.`, + actions: ["Review|/account/devices"], }); - if (!configuration || typeof configuration !== "object") - throw createError({ - statusCode: 400, - statusMessage: "configuration must be an object", - }); - - const capability = rawCapability as InternalClientCapability; - - if (!validCapabilities.includes(capability)) - throw createError({ - statusCode: 400, - statusMessage: "Invalid capability.", - }); - - const isValid = await capabilityManager.validateCapabilityConfiguration( - capability, - configuration - ); - if (!isValid) - throw createError({ - statusCode: 400, - statusMessage: "Invalid capability configuration.", - }); - - await capabilityManager.upsertClientCapability( - capability, - configuration, - clientId - ); - - return {}; -}); + return {}; + } +); diff --git a/server/api/v1/user/client/[id]/index.delete.ts b/server/api/v1/user/client/[id]/index.delete.ts new file mode 100644 index 0000000..5cc4d0f --- /dev/null +++ b/server/api/v1/user/client/[id]/index.delete.ts @@ -0,0 +1,17 @@ +import aclManager from "~/server/internal/acls"; +import clientHandler from "~/server/internal/clients/handler"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const userId = await aclManager.getUserIdACL(h3, ["clients:revoke"]); + if (!userId) throw createError({ statusCode: 403 }); + + const clientId = getRouterParam(h3, "id"); + if (!clientId) + throw createError({ + statusCode: 400, + statusMessage: "Client ID missing in route params", + }); + + await clientHandler.removeClient(clientId); +}); diff --git a/server/api/v1/user/client/index.get.ts b/server/api/v1/user/client/index.get.ts new file mode 100644 index 0000000..17b5f1f --- /dev/null +++ b/server/api/v1/user/client/index.get.ts @@ -0,0 +1,15 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const userId = await aclManager.getUserIdACL(h3, ["clients:read"]); + if (!userId) throw createError({ statusCode: 403 }); + + const clients = await prisma.client.findMany({ + where: { + userId, + }, + }); + + return clients; +}); diff --git a/server/internal/acls/descriptions.ts b/server/internal/acls/descriptions.ts index 2d3f560..faff2bc 100644 --- a/server/internal/acls/descriptions.ts +++ b/server/internal/acls/descriptions.ts @@ -31,6 +31,9 @@ export const userACLDescriptions: ObjectFromList = { "library:add": "Add a game to your library.", "library:remove": "Remove a game from your library.", + "clients:read": "Read the clients connected to this account", + "clients:revoke": "", + "news:read": "Read the server's news articles.", }; diff --git a/server/internal/acls/index.ts b/server/internal/acls/index.ts index b75f07a..ad7ac79 100644 --- a/server/internal/acls/index.ts +++ b/server/internal/acls/index.ts @@ -26,6 +26,9 @@ export const userACLs = [ "library:add", "library:remove", + "clients:read", + "clients:revoke", + "news:read", ] as const; const userACLPrefix = "user:"; diff --git a/server/internal/clients/ca.ts b/server/internal/clients/ca.ts index a9618cf..aeca81b 100644 --- a/server/internal/clients/ca.ts +++ b/server/internal/clients/ca.ts @@ -64,13 +64,13 @@ export class CertificateAuthority { async fetchClientCertificate(clientId: string) { const isBlacklist = await this.certificateStore.checkBlacklistCertificate( - clientId + `client:${clientId}` ); if (isBlacklist) return undefined; return await this.certificateStore.fetch(`client:${clientId}`); } async blacklistClient(clientId: string) { - await this.certificateStore.blacklistCertificate(clientId); + await this.certificateStore.blacklistCertificate(`client:${clientId}`); } -} \ No newline at end of file +} diff --git a/server/internal/clients/event-handler.ts b/server/internal/clients/event-handler.ts index 4be0310..9b2d848 100644 --- a/server/internal/clients/event-handler.ts +++ b/server/internal/clients/event-handler.ts @@ -3,6 +3,7 @@ import { EventHandlerRequest, H3Event } from "h3"; import droplet from "@drop-oss/droplet"; import prisma from "../db/database"; import { useCertificateAuthority } from "~/server/plugins/ca"; +import moment from "moment"; export type EventHandlerFunction = ( h3: H3Event, @@ -122,7 +123,7 @@ export function defineClientEventHandler(handler: EventHandlerFunction) { fetchUser, }; - prisma.client.update({ + await prisma.client.update({ where: { id: clientId }, data: { lastConnected: new Date() }, }); diff --git a/server/internal/clients/handler.ts b/server/internal/clients/handler.ts index 543f8b4..f2176ea 100644 --- a/server/internal/clients/handler.ts +++ b/server/internal/clients/handler.ts @@ -2,6 +2,7 @@ import { v4 as uuidv4 } from "uuid"; import { CertificateBundle } from "./ca"; import prisma from "../db/database"; import { Platform } from "@prisma/client"; +import { useCertificateAuthority } from "~/server/plugins/ca"; export interface ClientMetadata { name: string; @@ -82,6 +83,17 @@ export class ClientHandler { }, }); } + + async removeClient(id: string) { + const ca = useCertificateAuthority(); + await ca.blacklistClient(id); + + await prisma.client.delete({ + where: { + id, + }, + }); + } } export const clientHandler = new ClientHandler();