diff --git a/prisma/migrations/20241105221904_different_client_capabilities/migration.sql b/prisma/migrations/20241105221904_different_client_capabilities/migration.sql new file mode 100644 index 0000000..e63a1dc --- /dev/null +++ b/prisma/migrations/20241105221904_different_client_capabilities/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - The values [DownloadAggregation] on the enum `ClientCapabilities` will be removed. If these variants are still used in the database, this will fail. + +*/ +-- AlterEnum +BEGIN; +CREATE TYPE "ClientCapabilities_new" AS ENUM ('PeerAPI', 'UserStatus'); +ALTER TABLE "Client" ALTER COLUMN "capabilities" TYPE "ClientCapabilities_new"[] USING ("capabilities"::text::"ClientCapabilities_new"[]); +ALTER TYPE "ClientCapabilities" RENAME TO "ClientCapabilities_old"; +ALTER TYPE "ClientCapabilities_new" RENAME TO "ClientCapabilities"; +DROP TYPE "ClientCapabilities_old"; +COMMIT; diff --git a/prisma/migrations/20241105222110_trackable_names_for_capabilities/migration.sql b/prisma/migrations/20241105222110_trackable_names_for_capabilities/migration.sql new file mode 100644 index 0000000..1638c38 --- /dev/null +++ b/prisma/migrations/20241105222110_trackable_names_for_capabilities/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - The values [PeerAPI,UserStatus] on the enum `ClientCapabilities` will be removed. If these variants are still used in the database, this will fail. + +*/ +-- AlterEnum +BEGIN; +CREATE TYPE "ClientCapabilities_new" AS ENUM ('peerAPI', 'userStatus'); +ALTER TABLE "Client" ALTER COLUMN "capabilities" TYPE "ClientCapabilities_new"[] USING ("capabilities"::text::"ClientCapabilities_new"[]); +ALTER TYPE "ClientCapabilities" RENAME TO "ClientCapabilities_old"; +ALTER TYPE "ClientCapabilities_new" RENAME TO "ClientCapabilities"; +DROP TYPE "ClientCapabilities_old"; +COMMIT; diff --git a/prisma/migrations/20241105225732_peer_api_configuration/migration.sql b/prisma/migrations/20241105225732_peer_api_configuration/migration.sql new file mode 100644 index 0000000..ecafb93 --- /dev/null +++ b/prisma/migrations/20241105225732_peer_api_configuration/migration.sql @@ -0,0 +1,23 @@ +/* + Warnings: + + - You are about to drop the column `endpoint` on the `Client` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Client" DROP COLUMN "endpoint"; + +-- CreateTable +CREATE TABLE "ClientPeerAPIConfiguration" ( + "id" TEXT NOT NULL, + "clientId" TEXT NOT NULL, + "ipConfigurations" TEXT[], + + CONSTRAINT "ClientPeerAPIConfiguration_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ClientPeerAPIConfiguration_clientId_key" ON "ClientPeerAPIConfiguration"("clientId"); + +-- AddForeignKey +ALTER TABLE "ClientPeerAPIConfiguration" ADD CONSTRAINT "ClientPeerAPIConfiguration_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20241105230021_move_to_endpoint_configuration/migration.sql b/prisma/migrations/20241105230021_move_to_endpoint_configuration/migration.sql new file mode 100644 index 0000000..619e47c --- /dev/null +++ b/prisma/migrations/20241105230021_move_to_endpoint_configuration/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - You are about to drop the column `ipConfigurations` on the `ClientPeerAPIConfiguration` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "ClientPeerAPIConfiguration" DROP COLUMN "ipConfigurations", +ADD COLUMN "endpoints" TEXT[]; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9101b34..b0b04c9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -56,7 +56,8 @@ model Invitation { } enum ClientCapabilities { - DownloadAggregation + PeerAPI @map("peerAPI") // other clients can use the HTTP API to P2P with this client + UserStatus @map("userStatus") // this client can report this user's status (playing, online, etc etc) } enum Platform { @@ -70,12 +71,22 @@ model Client { userId String user User @relation(fields: [userId], references: [id]) - endpoint String capabilities ClientCapabilities[] name String platform Platform lastConnected DateTime + + peerAPI ClientPeerAPIConfiguration? +} + +model ClientPeerAPIConfiguration { + id String @id @default(uuid()) + + clientId String @unique + client Client @relation(fields: [clientId], references: [id]) + + endpoints String[] } enum MetadataSource { diff --git a/server/api/v1/client/capability/index.post.ts b/server/api/v1/client/capability/index.post.ts new file mode 100644 index 0000000..038f302 --- /dev/null +++ b/server/api/v1/client/capability/index.post.ts @@ -0,0 +1,49 @@ +import capabilityManager, { + InternalClientCapability, + validCapabilities, +} from "~/server/internal/clients/capabilities"; +import { defineClientEventHandler } from "~/server/internal/clients/event-handler"; + +export default defineClientEventHandler(async (h3, { clientId }) => { + 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 (!configuration || typeof configuration !== "object") + throw createError({ + statusCode: 400, + statusMessage: "configuration must be an object", + }); + + if (!(rawCapability in validCapabilities)) + throw createError({ + statusCode: 400, + statusMessage: "Invalid capability.", + }); + + const capability = rawCapability as InternalClientCapability; + + const isValid = await capabilityManager.validateCapabilityConfiguration( + capability, + configuration + ); + if (!isValid) + throw createError({ + statusCode: 400, + statusMessage: "Invalid capability configuration.", + }); + + await capabilityManager.upsertClientCapability( + capability, + configuration, + clientId + ); + + return {}; +}); diff --git a/server/internal/clients/capabilities.ts b/server/internal/clients/capabilities.ts new file mode 100644 index 0000000..5d1d09d --- /dev/null +++ b/server/internal/clients/capabilities.ts @@ -0,0 +1,137 @@ +import { EnumDictionary } from "../utils/types"; +import https from "https"; +import { useGlobalCertificateAuthority } from "~/server/plugins/ca"; +import prisma from "../db/database"; +import { ClientCapabilities } from "@prisma/client"; + + +// These values are technically mapped to the database, +// but Typescript/Prisma doesn't let me link them +// They are also what are required by clients in the API +// BREAKING CHANGE +export enum InternalClientCapability { + PeerAPI = "peerAPI", + UserStatus = "userStatus", +} + +export const validCapabilities = Object.values(InternalClientCapability); + +export type CapabilityConfiguration = { + [InternalClientCapability.PeerAPI]: { endpoints: string[] }; + [InternalClientCapability.UserStatus]: {}; +}; + +class CapabilityManager { + private validationFunctions: EnumDictionary< + InternalClientCapability, + (configuration: object) => Promise + > = { + [InternalClientCapability.PeerAPI]: async (rawConfiguration) => { + const configuration = + rawConfiguration as CapabilityConfiguration[InternalClientCapability.PeerAPI]; + + // Check if we can use the endpoints object + if (!configuration.endpoints) return false; + if (!Array.isArray(configuration.endpoints)) return false; + if (configuration.endpoints.length == 0) return false; + + // Check if valid URLs + if ( + configuration.endpoints.filter((endpoint) => { + try { + new URL(endpoint); + return true; + } catch { + return false; + } + }) + ) + return false; + + const ca = useGlobalCertificateAuthority(); + const serverCertificate = await ca.fetchClientCertificate("server"); + if (!serverCertificate) + throw new Error( + "CA not initialised properly - server mTLS certificate not present" + ); + const httpsAgent = new https.Agent({ + key: serverCertificate.priv, + cert: serverCertificate.cert, + }); + + // Loop through endpoints and make sure at least one is accessible by the Drop server + let valid = false; + for (const endpoint of configuration.endpoints) { + const healthcheckEndpoint = new URL("/", endpoint); + try { + await $fetch(healthcheckEndpoint.href, { + agent: httpsAgent, + }); + valid = true; + break; + } catch {} + } + + return valid; + }, + [InternalClientCapability.UserStatus]: async () => true, // No requirements for user status + }; + + async validateCapabilityConfiguration( + capability: InternalClientCapability, + configuration: object + ) { + const validationFunction = this.validationFunctions[capability]; + return validationFunction(configuration); + } + + async upsertClientCapability( + capability: InternalClientCapability, + rawCapability: object, + clientId: string + ) { + switch (capability) { + case InternalClientCapability.PeerAPI: + const configuration = + rawCapability as CapabilityConfiguration[InternalClientCapability.PeerAPI]; + + const currentClient = await prisma.client.findUnique({ + where: { id: clientId }, + select: { + capabilities: true, + }, + }); + if (!currentClient) throw new Error("Invalid client ID"); + if (currentClient.capabilities.includes(ClientCapabilities.PeerAPI)) { + await prisma.clientPeerAPIConfiguration.update({ + where: { clientId }, + data: { + endpoints: configuration.endpoints, + }, + }); + return; + } + + await prisma.clientPeerAPIConfiguration.create({ + data: { + clientId: clientId, + endpoints: configuration.endpoints, + }, + }); + + await prisma.client.update({ + where: { id: clientId }, + data: { + capabilities: { + push: ClientCapabilities.PeerAPI, + }, + }, + }); + return; + } + throw new Error("Cannot upsert client capability for: " + capability); + } +} + +const capabilityManager = new CapabilityManager(); +export default capabilityManager;