Merge remote-tracking branch 'origin/develop' into more-fixes

This commit is contained in:
Huskydog9988
2025-05-15 13:38:46 -04:00
22 changed files with 331 additions and 161 deletions
+49 -1
View File
@@ -1,3 +1,10 @@
import type {
CapabilityConfiguration,
InternalClientCapability,
} from "~/server/internal/clients/capabilities";
import capabilityManager, {
validCapabilities,
} from "~/server/internal/clients/capabilities";
import clientHandler from "~/server/internal/clients/handler";
import { parsePlatform } from "~/server/internal/utils/parseplatform";
@@ -6,6 +13,8 @@ export default defineEventHandler(async (h3) => {
const name = body.name;
const platformRaw = body.platform;
const capabilities: Partial<CapabilityConfiguration> =
body.capabilities ?? {};
if (!name || !platformRaw)
throw createError({
@@ -20,7 +29,46 @@ export default defineEventHandler(async (h3) => {
statusMessage: "Invalid or unsupported platform",
});
const clientId = await clientHandler.initiate({ name, platform });
if (!capabilities || typeof capabilities !== "object")
throw createError({
statusCode: 400,
statusMessage: "Capabilities must be an array",
});
const capabilityIterable = Object.entries(capabilities) as Array<
[InternalClientCapability, object]
>;
if (
capabilityIterable.length > 0 &&
capabilityIterable
.map(([capability]) => validCapabilities.find((v) => capability == v))
.filter((e) => e).length == 0
)
throw createError({
statusCode: 400,
statusMessage: "Invalid capabilities.",
});
if (
capabilityIterable.length > 0 &&
capabilityIterable.filter(
([capability, configuration]) =>
!capabilityManager.validateCapabilityConfiguration(
capability,
configuration,
),
).length > 0
)
throw createError({
statusCode: 400,
statusMessage: "Invalid capability configuration.",
});
const clientId = await clientHandler.initiate({
name,
platform,
capabilities,
});
return `/client/${clientId}/callback`;
});
@@ -55,7 +55,7 @@ export default defineClientEventHandler(
title: `"${client.name}" can now access ${capability}`,
description: `A device called "${client.name}" now has access to your ${capability}.`,
actions: ["Review|/account/devices"],
requiredPerms: ["clients:read"],
acls: ["user:clients:read"],
});
return {};
+10 -8
View File
@@ -5,17 +5,19 @@ export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["notifications:read"]);
if (!userId) throw createError({ statusCode: 403 });
const userIds = [userId];
const hasSystemPerms = await aclManager.allowSystemACL(h3, [
"notifications:mark",
]);
if (hasSystemPerms) {
userIds.push("system");
}
const acls = await aclManager.fetchAllACLs(h3);
if (!acls)
throw createError({
statusCode: 500,
statusMessage: "Got userId but no ACLs - what?",
});
const notifications = await prisma.notification.findMany({
where: {
userId: { in: userIds },
userId,
acls: {
hasSome: acls,
},
},
orderBy: {
created: "desc", // Newest first
+10 -8
View File
@@ -5,17 +5,19 @@ export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["notifications:mark"]);
if (!userId) throw createError({ statusCode: 403 });
const userIds = [userId];
const hasSystemPerms = await aclManager.allowSystemACL(h3, [
"notifications:mark",
]);
if (hasSystemPerms) {
userIds.push("system");
}
const acls = await aclManager.fetchAllACLs(h3);
if (!acls)
throw createError({
statusCode: 500,
statusMessage: "Got userId but no ACLs - what?",
});
await prisma.notification.updateMany({
where: {
userId: { in: userIds },
userId,
acls: {
hasSome: acls,
},
},
data: {
read: true,
+7 -12
View File
@@ -14,22 +14,17 @@ export default defineWebSocketHandler({
return;
}
const userIds = [userId];
const hasSystemPerms = await aclManager.allowSystemACL(h3, [
"notifications:listen",
]);
if (hasSystemPerms) {
userIds.push("system");
const acls = await aclManager.fetchAllACLs(h3);
if (!acls) {
peer.send("unauthenticated");
return;
}
socketSessions.set(peer.id, userId);
for (const listenUserId of userIds) {
notificationSystem.listen(listenUserId, peer.id, (notification) => {
peer.send(JSON.stringify(notification));
});
}
notificationSystem.listen(userId, acls, peer.id, (notification) => {
peer.send(JSON.stringify(notification));
});
},
async close(peer, _details) {
const userId = socketSessions.get(peer.id);
+35 -1
View File
@@ -70,7 +70,9 @@ const systemACLPrefix = "system:";
export type SystemACL = Array<(typeof systemACLs)[number]>;
export type ValidACLItems = Array<SystemACL[number] | UserACL[number]>;
export type GlobalACL =
| `${typeof systemACLPrefix}${(typeof systemACLs)[number]}`
| `${typeof userACLPrefix}${(typeof userACLs)[number]}`;
class ACLManager {
private getAuthorizationToken(request: MinimumRequestObject) {
@@ -175,6 +177,38 @@ class ACLManager {
return true;
}
async fetchAllACLs(
request: MinimumRequestObject,
): Promise<GlobalACL[] | undefined> {
const userSession = await sessionHandler.getSession(request);
if (!userSession) {
const authorizationToken = this.getAuthorizationToken(request);
if (!authorizationToken) return undefined;
const token = await prisma.aPIToken.findUnique({
where: { token: authorizationToken },
});
if (!token) return undefined;
return token.acls as GlobalACL[];
}
const user = await prisma.user.findUnique({
where: { id: userSession.userId },
select: {
admin: true,
},
});
if (!user)
throw new Error("User session without user - did something break?");
const acls = userACLs.map((e) => `${userACLPrefix}${e}`);
if (user.admin) {
acls.push(...systemACLs.map((e) => `${systemACLPrefix}${e}`));
}
return acls as GlobalACL[];
}
}
export const aclManager = new ACLManager();
+8 -7
View File
@@ -1,6 +1,4 @@
import type { EnumDictionary } from "../utils/types";
import https from "https";
import { useCertificateAuthority } from "~/server/plugins/ca";
import prisma from "../db/database";
import { ClientCapabilities } from "~/prisma/client";
@@ -17,7 +15,7 @@ export enum InternalClientCapability {
export const validCapabilities = Object.values(InternalClientCapability);
export type CapabilityConfiguration = {
[InternalClientCapability.PeerAPI]: { endpoints: string[] };
[InternalClientCapability.PeerAPI]: object;
[InternalClientCapability.UserStatus]: object;
[InternalClientCapability.CloudSaves]: object;
};
@@ -27,6 +25,7 @@ class CapabilityManager {
InternalClientCapability,
(configuration: object) => Promise<boolean>
> = {
/*
[InternalClientCapability.PeerAPI]: async (rawConfiguration) => {
const configuration =
rawConfiguration as CapabilityConfiguration[InternalClientCapability.PeerAPI];
@@ -71,12 +70,13 @@ class CapabilityManager {
valid = true;
break;
} catch {
/* empty */
}
}
return valid;
},
*/
[InternalClientCapability.PeerAPI]: async () => true,
[InternalClientCapability.UserStatus]: async () => true, // No requirements for user status
[InternalClientCapability.CloudSaves]: async () => true, // No requirements for cloud saves
};
@@ -92,7 +92,7 @@ class CapabilityManager {
async upsertClientCapability(
capability: InternalClientCapability,
rawCapability: object,
rawCapabilityConfiguration: object,
clientId: string,
) {
const upsertFunctions: EnumDictionary<
@@ -100,8 +100,7 @@ class CapabilityManager {
() => Promise<void> | void
> = {
[InternalClientCapability.PeerAPI]: async function () {
const configuration =
rawCapability as CapabilityConfiguration[InternalClientCapability.PeerAPI];
// const configuration =rawCapability as CapabilityConfiguration[InternalClientCapability.PeerAPI];
const currentClient = await prisma.client.findUnique({
where: { id: clientId },
@@ -110,6 +109,7 @@ class CapabilityManager {
},
});
if (!currentClient) throw new Error("Invalid client ID");
/*
if (currentClient.capabilities.includes(ClientCapabilities.PeerAPI)) {
await prisma.clientPeerAPIConfiguration.update({
where: { clientId },
@@ -126,6 +126,7 @@ class CapabilityManager {
endpoints: configuration.endpoints,
},
});
*/
await prisma.client.update({
where: { id: clientId },
+18 -1
View File
@@ -2,10 +2,13 @@ import { randomUUID } from "node:crypto";
import prisma from "../db/database";
import type { Platform } from "~/prisma/client";
import { useCertificateAuthority } from "~/server/plugins/ca";
import type { CapabilityConfiguration, InternalClientCapability } from "./capabilities";
import capabilityManager from "./capabilities";
export interface ClientMetadata {
name: string;
platform: Platform;
capabilities: Partial<CapabilityConfiguration>;
}
export class ClientHandler {
@@ -75,7 +78,7 @@ export class ClientHandler {
if (!metadata) throw new Error("Invalid client ID");
if (!metadata.userId) throw new Error("Un-authorized client ID");
return await prisma.client.create({
const client = await prisma.client.create({
data: {
id: id,
userId: metadata.userId,
@@ -87,6 +90,20 @@ export class ClientHandler {
lastConnected: new Date(),
},
});
for (const [capability, configuration] of Object.entries(
metadata.data.capabilities,
)) {
await capabilityManager.upsertClientCapability(
capability as InternalClientCapability,
configuration,
client.id,
);
}
this.temporaryClientTable.delete(id);
return client;
}
async removeClient(id: string) {
+1 -1
View File
@@ -306,7 +306,7 @@ class LibraryManager {
title: `'${game.mName}' ('${versionName}') finished importing.`,
description: `Drop finished importing version ${versionName} for ${game.mName}.`,
actions: [`View|/admin/library/${gameId}`],
requiredPerms: ["import:game:new"],
acls: ["system:import:version:read"],
});
progress(100);
+23 -30
View File
@@ -8,28 +8,35 @@ Design goals:
import type { Notification } from "~/prisma/client";
import prisma from "../db/database";
import type { GlobalACL } from "../acls";
// type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
// TODO: document notification action format
export type NotificationCreateArgs = Pick<
Notification,
"title" | "description" | "actions" | "nonce" | "requiredPerms"
>;
"title" | "description" | "actions" | "nonce"
> & { acls: Array<GlobalACL> };
class NotificationSystem {
// userId to acl to listenerId
private listeners = new Map<
string,
Map<string, (notification: Notification) => void>
Map<
string,
{ callback: (notification: Notification) => void; acls: GlobalACL[] }
>
>();
listen(
userId: string,
acls: Array<GlobalACL>,
id: string,
callback: (notification: Notification) => void,
) {
this.listeners.set(userId, new Map());
this.listeners.get(userId)?.set(id, callback);
if (!this.listeners.has(userId)) this.listeners.set(userId, new Map());
// eslint-disable-next-line @typescript-eslint/no-extra-non-null-assertion
this.listeners.get(userId)!!.set(id, { callback, acls });
this.catchupListener(userId, id);
}
@@ -39,23 +46,27 @@ class NotificationSystem {
}
private async catchupListener(userId: string, id: string) {
const callback = this.listeners.get(userId)?.get(id);
if (!callback)
const listener = this.listeners.get(userId)?.get(id);
if (!listener)
throw new Error("Failed to catch-up listener: callback does not exist");
const notifications = await prisma.notification.findMany({
where: { userId: userId },
where: { userId: userId, acls: { hasSome: listener.acls } },
orderBy: {
created: "asc", // Oldest first, because they arrive in reverse order
},
});
for (const notification of notifications) {
await callback(notification);
await listener.callback(notification);
}
}
private async pushNotification(userId: string, notification: Notification) {
for (const listener of this.listeners.get(userId) ?? []) {
await listener[1](notification);
for (const [_, listener] of this.listeners.get(userId) ?? []) {
const hasSome =
notification.acls.findIndex(
(e) => listener.acls.findIndex((v) => v === e) != -1,
) != -1;
if (hasSome) await listener.callback(notification);
}
}
@@ -100,25 +111,7 @@ class NotificationSystem {
}
async systemPush(notificationCreateArgs: NotificationCreateArgs) {
await this.push("system", notificationCreateArgs);
}
async pushAllAdmins(notificationCreateArgs: NotificationCreateArgs) {
const users = await prisma.user.findMany({
where: {
admin: true,
},
select: {
id: true,
},
});
const res: Promise<void>[] = [];
for (const user of users) {
res.push(this.push(user.id, notificationCreateArgs));
}
// wait for all notifications to pass
await Promise.all(res);
return await this.pushAll(notificationCreateArgs);
}
}
+15 -3
View File
@@ -1,5 +1,6 @@
import { randomUUID } from "crypto";
import prisma from "../db/database";
import type { User } from "~/prisma/client";
import { AuthMec } from "~/prisma/client";
import objectHandler from "../objects";
import type { Readable } from "stream";
@@ -12,10 +13,15 @@ interface OIDCWellKnown {
scopes_supported: string[];
}
interface OIDCAuthSessionOptions {
redirect: string | undefined;
}
interface OIDCAuthSession {
redirectUrl: string;
callbackUrl: string;
state: string;
options: OIDCAuthSessionOptions;
}
interface OIDCUserInfo {
@@ -132,7 +138,7 @@ export class OIDCManager {
};
}
generateAuthSession(): OIDCAuthSession {
generateAuthSession(options?: OIDCAuthSessionOptions): OIDCAuthSession {
const stateKey = randomUUID();
const normalisedUrl = new URL(
@@ -148,12 +154,16 @@ export class OIDCManager {
redirectUrl: finalUrl,
callbackUrl: redirectUrl,
state: stateKey,
options: options ?? { redirect: undefined },
};
this.signinStateTable[stateKey] = session;
return session;
}
async authorize(code: string, state: string) {
async authorize(
code: string,
state: string,
): Promise<{ user: User; options: OIDCAuthSessionOptions } | string> {
const session = this.signinStateTable[state];
if (!session) return "Invalid state parameter";
@@ -191,7 +201,9 @@ export class OIDCManager {
const user = await this.fetchOrCreateUser(userinfo);
return user;
if (typeof user === "string") return user;
return { user, options: session.options };
} catch (e) {
console.error(e);
return `Request to identity provider failed: ${e}`;
+8 -4
View File
@@ -29,15 +29,19 @@ export default defineEventHandler(async (h3) => {
statusMessage: "No state in query params.",
});
const user = await manager.authorize(code, state);
const result = await manager.authorize(code, state);
if (typeof user === "string")
if (typeof result === "string")
throw createError({
statusCode: 403,
statusMessage: `Failed to sign in: "${user}". Please try again.`,
statusMessage: `Failed to sign in: "${result}". Please try again.`,
});
await sessionHandler.signin(h3, user.id, true);
await sessionHandler.signin(h3, result.user.id, true);
if (result.options.redirect) {
return sendRedirect(h3, result.options.redirect);
}
return sendRedirect(h3, "/");
});
+8 -2
View File
@@ -9,10 +9,16 @@ defineRouteMeta({
});
export default defineEventHandler((h3) => {
if (!enabledAuthManagers.OpenID) return sendRedirect(h3, "/auth/signin");
const redirect = getQuery(h3).redirect?.toString();
if (!enabledAuthManagers.OpenID)
return sendRedirect(
h3,
`/auth/signin${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""}`,
);
const manager = enabledAuthManagers.OpenID;
const { redirectUrl } = manager.generateAuthSession();
const { redirectUrl } = manager.generateAuthSession({ redirect });
return sendRedirect(h3, redirectUrl);
});