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();