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

View File

@ -6,6 +6,7 @@
# Drop # Drop
[![Website](https://img.shields.io/badge/website-000000?style=for-the-badge&logo=About.me&logoColor=white)](https://droposs.org) [![Website](https://img.shields.io/badge/website-000000?style=for-the-badge&logo=About.me&logoColor=white)](https://droposs.org)
[![Static Badge](https://img.shields.io/badge/FORUM-blue?style=for-the-badge)](https://forum.droposs.org)
[![GitHub License](https://img.shields.io/badge/AGPL--3.0-red?style=for-the-badge)](LICENSE) [![GitHub License](https://img.shields.io/badge/AGPL--3.0-red?style=for-the-badge)](LICENSE)
[![Discord](https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/ACq4qZp4a9) [![Discord](https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/ACq4qZp4a9)
[![Open Collective](https://img.shields.io/badge/OpenCollective-1F87FF?style=for-the-badge&logo=OpenCollective&logoColor=white)](https://opencollective.com/drop-oss) [![Open Collective](https://img.shields.io/badge/OpenCollective-1F87FF?style=for-the-badge&logo=OpenCollective&logoColor=white)](https://opencollective.com/drop-oss)

View File

@ -1,10 +1,14 @@
<template> <template>
<div class="flex"> <div class="flex">
<a <a
href="/auth/oidc" :href="`/auth/oidc?redirect=${route.query.redirect ?? '/'}`"
class="transition rounded-md grow inline-flex items-center justify-center bg-white/10 px-3.5 py-2.5 text-sm font-semibold text-white shadow-xs hover:bg-white/20" class="transition rounded-md grow inline-flex items-center justify-center bg-white/10 px-3.5 py-2.5 text-sm font-semibold text-white shadow-xs hover:bg-white/20"
> >
Sign in with external provider &rarr; Sign in with external provider &rarr;
</a> </a>
</div> </div>
</template> </template>
<script setup lang="ts">
const route = useRoute();
</script>

View File

@ -1,82 +1,141 @@
<!-- eslint-disable vue/no-v-html -->
<template> <template>
<div> <div>
<!-- import games button --> <div
<NuxtLink v-if="game && unimportedVersions !== undefined"
v-if="unimportedVersions !== undefined" class="grow flex flex-col gap-y-8"
:href="
unimportedVersions.length > 0 ? `/admin/library/${game.id}/import` : ''
"
type="button"
:class="[
unimportedVersions.length > 0
? 'bg-blue-600 hover:bg-blue-700'
: 'bg-blue-800/50',
'inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1 text-sm font-semibold font-display text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600',
]"
> >
{{ <div
unimportedVersions.length > 0 class="grow w-full h-full lg:pr-[30vw] px-6 py-4 flex flex-col"
? "Import version" ></div>
: "No versions to import" <div
}} class="lg:overflow-y-auto lg:border-l lg:border-zinc-800 lg:fixed lg:inset-y-0 lg:z-50 lg:w-[30vw] flex flex-col lg:right-0 gap-y-8 px-6 py-4"
</NuxtLink>
<!-- version priority -->
<div>
<div class="border-b border-zinc-800 pb-3">
<div class="flex flex-wrap items-center justify-between sm:flex-nowrap">
<h3
class="text-base font-semibold font-display leading-6 text-zinc-100"
>
Version priority
</h3>
</div>
</div>
<div class="mt-4 text-center w-full text-sm text-zinc-600">lowest</div>
<draggable
:list="game.versions"
handle=".handle"
class="mt-2 space-y-4"
@update="() => updateVersionOrder()"
> >
<template #item="{ element: item }: { element: GameVersion }"> <!-- toolbar -->
<div <div class="inline-flex justify-end items-stretch gap-x-4">
class="w-full inline-flex items-center px-4 py-2 bg-zinc-800 rounded justify-between" <!-- open in library button -->
<NuxtLink
:href="`/admin/metadata/games/${game.id}`"
type="button"
class="inline-flex w-fit items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
> >
<div class="text-zinc-100 font-semibold"> Open in Metadata
{{ item.versionName }} <ArrowTopRightOnSquareIcon
class="-mr-0.5 h-7 w-7 p-1"
aria-hidden="true"
/>
</NuxtLink>
<!-- open in store button -->
<NuxtLink
:href="`/store/${game.id}`"
type="button"
class="inline-flex w-fit items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Open in Store
<ArrowTopRightOnSquareIcon
class="-mr-0.5 h-7 w-7 p-1"
aria-hidden="true"
/>
</NuxtLink>
</div>
<!-- version manager -->
<div>
<!-- version priority -->
<div>
<div class="border-b border-zinc-800 pb-3">
<div
class="flex flex-wrap items-center justify-between sm:flex-nowrap"
>
<h3
class="text-base font-semibold font-display leading-6 text-zinc-100"
>
Version priority
<!-- import games button -->
<NuxtLink
v-if="unimportedVersions !== undefined"
:href="
unimportedVersions.length > 0
? `/admin/library/${game.id}/import`
: ''
"
type="button"
:class="[
unimportedVersions.length > 0
? 'bg-blue-600 hover:bg-blue-700'
: 'bg-blue-800/50',
'inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1 text-sm font-semibold font-display text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600',
]"
>
{{
unimportedVersions.length > 0
? "Import version"
: "No versions to import"
}}
</NuxtLink>
</h3>
</div>
</div> </div>
<div class="text-zinc-400">
{{ item.delta ? "Upgrade mode" : "" }} <div class="mt-4 text-center w-full text-sm text-zinc-600">
lowest
</div> </div>
<div class="inline-flex items-center gap-x-2"> <draggable
<component :list="game.versions"
:is="PLATFORM_ICONS[item.platform]" handle=".handle"
class="size-6 text-blue-600" class="mt-2 space-y-4"
/> @update="() => updateVersionOrder()"
<Bars3Icon class="cursor-move w-6 h-6 text-zinc-400 handle" /> >
<button @click="() => deleteVersion(item.versionName)"> <template #item="{ element: item }: { element: GameVersion }">
<TrashIcon class="w-5 h-5 text-red-600" /> <div
</button> class="w-full inline-flex items-center px-4 py-2 bg-zinc-800 rounded justify-between"
>
<div class="text-zinc-100 font-semibold">
{{ item.versionName }}
</div>
<div class="text-zinc-400">
{{ item.delta ? "Upgrade mode" : "" }}
</div>
<div class="inline-flex items-center gap-x-2">
<component
:is="PLATFORM_ICONS[item.platform]"
class="size-6 text-blue-600"
/>
<Bars3Icon
class="cursor-move w-6 h-6 text-zinc-400 handle"
/>
<button @click="() => deleteVersion(item.versionName)">
<TrashIcon class="w-5 h-5 text-red-600" />
</button>
</div>
</div>
</template>
</draggable>
<div
v-if="game.versions.length == 0"
class="text-center font-bold text-zinc-400 my-3"
>
no versions added
</div>
<div class="mt-2 text-center w-full text-sm text-zinc-600">
highest
</div> </div>
</div> </div>
</template> </div>
</draggable>
<div
v-if="game.versions.length == 0"
class="text-center font-bold text-zinc-400 my-3"
>
no versions added
</div> </div>
<div class="mt-2 text-center w-full text-sm text-zinc-600">highest</div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Bars3Icon, TrashIcon } from "@heroicons/vue/16/solid";
import type { GameVersion } from "~/prisma/client"; import type { GameVersion } from "~/prisma/client";
import {
ArrowTopRightOnSquareIcon,
Bars3Icon,
TrashIcon,
} from "@heroicons/vue/24/solid";
definePageMeta({ definePageMeta({
layout: "admin", layout: "admin",

View File

@ -94,7 +94,7 @@
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
:href="`/admin/metadata/games/${game.id}`" :href="`/admin/metadata/games/${game.id}`"
class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700u focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
> >
Open with Metadata &rarr; Open with Metadata &rarr;
</NuxtLink> </NuxtLink>

View File

@ -184,7 +184,7 @@
type="button" type="button"
class="inline-flex w-fit items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" class="inline-flex w-fit items-center gap-x-2 rounded-md bg-zinc-800 px-3 py-1 text-sm font-semibold font-display text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
> >
Open in Library &rarr; Open in Library
<ArrowTopRightOnSquareIcon <ArrowTopRightOnSquareIcon
class="-mr-0.5 h-7 w-7 p-1" class="-mr-0.5 h-7 w-7 p-1"
aria-hidden="true" aria-hidden="true"

View File

@ -21,8 +21,8 @@
class="transition group aspect-[3/2] flex flex-col justify-center items-center rounded-lg bg-zinc-950 hover:bg-zinc-950/50 shadow" class="transition group aspect-[3/2] flex flex-col justify-center items-center rounded-lg bg-zinc-950 hover:bg-zinc-950/50 shadow"
> >
<span <span
class="transition-all text-4xl font-bold text-zinc-300 group-hover:text-zinc-100 uppercase tracking-widest" class="transition-all text-4xl font-bold text-zinc-400 group-hover:text-zinc-100 uppercase tracking-widest"
>GAMES</span >Games</span
> >
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
@ -30,7 +30,7 @@
class="transition group aspect-[3/2] flex flex-col justify-center items-center rounded-lg bg-zinc-950 hover:bg-zinc-950/50 shadow" class="transition group aspect-[3/2] flex flex-col justify-center items-center rounded-lg bg-zinc-950 hover:bg-zinc-950/50 shadow"
> >
<span <span
class="transition-all text-4xl font-bold text-zinc-300 group-hover:text-zinc-100 uppercase tracking-widest" class="transition-all text-4xl font-bold text-zinc-400 group-hover:text-zinc-100 uppercase tracking-widest"
>Companies</span >Companies</span
> >
</NuxtLink> </NuxtLink>

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Notification" ADD COLUMN "acls" TEXT[];

View File

@ -16,17 +16,6 @@ model Client {
platform Platform platform Platform
lastConnected DateTime lastConnected DateTime
peerAPI ClientPeerAPIConfiguration?
lastAccessedSaves SaveSlot[] lastAccessedSaves SaveSlot[]
tokens APIToken[] tokens APIToken[]
} }
model ClientPeerAPIConfiguration {
id String @id @default(uuid())
clientId String @unique
client Client @relation(fields: [clientId], references: [id])
endpoints String[]
}

View File

@ -27,7 +27,8 @@ model Notification {
nonce String? nonce String?
userId String userId String
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
acls String[]
created DateTime @default(now()) created DateTime @default(now())
title String title String

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 clientHandler from "~/server/internal/clients/handler";
import { parsePlatform } from "~/server/internal/utils/parseplatform"; import { parsePlatform } from "~/server/internal/utils/parseplatform";
@ -6,6 +13,8 @@ export default defineEventHandler(async (h3) => {
const name = body.name; const name = body.name;
const platformRaw = body.platform; const platformRaw = body.platform;
const capabilities: Partial<CapabilityConfiguration> =
body.capabilities ?? {};
if (!name || !platformRaw) if (!name || !platformRaw)
throw createError({ throw createError({
@ -20,7 +29,46 @@ export default defineEventHandler(async (h3) => {
statusMessage: "Invalid or unsupported platform", 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`; return `/client/${clientId}/callback`;
}); });

View File

@ -55,7 +55,7 @@ export default defineClientEventHandler(
title: `"${client.name}" can now access ${capability}`, title: `"${client.name}" can now access ${capability}`,
description: `A device called "${client.name}" now has access to your ${capability}.`, description: `A device called "${client.name}" now has access to your ${capability}.`,
actions: ["Review|/account/devices"], actions: ["Review|/account/devices"],
requiredPerms: ["clients:read"], acls: ["user:clients:read"],
}); });
return {}; return {};

View File

@ -5,17 +5,19 @@ export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["notifications:read"]); const userId = await aclManager.getUserIdACL(h3, ["notifications:read"]);
if (!userId) throw createError({ statusCode: 403 }); if (!userId) throw createError({ statusCode: 403 });
const userIds = [userId]; const acls = await aclManager.fetchAllACLs(h3);
const hasSystemPerms = await aclManager.allowSystemACL(h3, [ if (!acls)
"notifications:mark", throw createError({
]); statusCode: 500,
if (hasSystemPerms) { statusMessage: "Got userId but no ACLs - what?",
userIds.push("system"); });
}
const notifications = await prisma.notification.findMany({ const notifications = await prisma.notification.findMany({
where: { where: {
userId: { in: userIds }, userId,
acls: {
hasSome: acls,
},
}, },
orderBy: { orderBy: {
created: "desc", // Newest first created: "desc", // Newest first

View File

@ -5,17 +5,19 @@ export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["notifications:mark"]); const userId = await aclManager.getUserIdACL(h3, ["notifications:mark"]);
if (!userId) throw createError({ statusCode: 403 }); if (!userId) throw createError({ statusCode: 403 });
const userIds = [userId]; const acls = await aclManager.fetchAllACLs(h3);
const hasSystemPerms = await aclManager.allowSystemACL(h3, [ if (!acls)
"notifications:mark", throw createError({
]); statusCode: 500,
if (hasSystemPerms) { statusMessage: "Got userId but no ACLs - what?",
userIds.push("system"); });
}
await prisma.notification.updateMany({ await prisma.notification.updateMany({
where: { where: {
userId: { in: userIds }, userId,
acls: {
hasSome: acls,
},
}, },
data: { data: {
read: true, read: true,

View File

@ -14,22 +14,17 @@ export default defineWebSocketHandler({
return; return;
} }
const userIds = [userId]; const acls = await aclManager.fetchAllACLs(h3);
if (!acls) {
const hasSystemPerms = await aclManager.allowSystemACL(h3, [ peer.send("unauthenticated");
"notifications:listen", return;
]);
if (hasSystemPerms) {
userIds.push("system");
} }
socketSessions.set(peer.id, userId); socketSessions.set(peer.id, userId);
for (const listenUserId of userIds) { notificationSystem.listen(userId, acls, peer.id, (notification) => {
notificationSystem.listen(listenUserId, peer.id, (notification) => { peer.send(JSON.stringify(notification));
peer.send(JSON.stringify(notification)); });
});
}
}, },
async close(peer, _details) { async close(peer, _details) {
const userId = socketSessions.get(peer.id); const userId = socketSessions.get(peer.id);

View File

@ -70,7 +70,9 @@ const systemACLPrefix = "system:";
export type SystemACL = Array<(typeof systemACLs)[number]>; 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 { class ACLManager {
private getAuthorizationToken(request: MinimumRequestObject) { private getAuthorizationToken(request: MinimumRequestObject) {
@ -175,6 +177,38 @@ class ACLManager {
return true; 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(); export const aclManager = new ACLManager();

View File

@ -1,6 +1,4 @@
import type { EnumDictionary } from "../utils/types"; import type { EnumDictionary } from "../utils/types";
import https from "https";
import { useCertificateAuthority } from "~/server/plugins/ca";
import prisma from "../db/database"; import prisma from "../db/database";
import { ClientCapabilities } from "~/prisma/client"; import { ClientCapabilities } from "~/prisma/client";
@ -17,7 +15,7 @@ export enum InternalClientCapability {
export const validCapabilities = Object.values(InternalClientCapability); export const validCapabilities = Object.values(InternalClientCapability);
export type CapabilityConfiguration = { export type CapabilityConfiguration = {
[InternalClientCapability.PeerAPI]: { endpoints: string[] }; [InternalClientCapability.PeerAPI]: object;
[InternalClientCapability.UserStatus]: object; [InternalClientCapability.UserStatus]: object;
[InternalClientCapability.CloudSaves]: object; [InternalClientCapability.CloudSaves]: object;
}; };
@ -27,6 +25,7 @@ class CapabilityManager {
InternalClientCapability, InternalClientCapability,
(configuration: object) => Promise<boolean> (configuration: object) => Promise<boolean>
> = { > = {
/*
[InternalClientCapability.PeerAPI]: async (rawConfiguration) => { [InternalClientCapability.PeerAPI]: async (rawConfiguration) => {
const configuration = const configuration =
rawConfiguration as CapabilityConfiguration[InternalClientCapability.PeerAPI]; rawConfiguration as CapabilityConfiguration[InternalClientCapability.PeerAPI];
@ -71,12 +70,13 @@ class CapabilityManager {
valid = true; valid = true;
break; break;
} catch { } catch {
/* empty */
} }
} }
return valid; return valid;
}, },
*/
[InternalClientCapability.PeerAPI]: async () => true,
[InternalClientCapability.UserStatus]: async () => true, // No requirements for user status [InternalClientCapability.UserStatus]: async () => true, // No requirements for user status
[InternalClientCapability.CloudSaves]: async () => true, // No requirements for cloud saves [InternalClientCapability.CloudSaves]: async () => true, // No requirements for cloud saves
}; };
@ -92,7 +92,7 @@ class CapabilityManager {
async upsertClientCapability( async upsertClientCapability(
capability: InternalClientCapability, capability: InternalClientCapability,
rawCapability: object, rawCapabilityConfiguration: object,
clientId: string, clientId: string,
) { ) {
const upsertFunctions: EnumDictionary< const upsertFunctions: EnumDictionary<
@ -100,8 +100,7 @@ class CapabilityManager {
() => Promise<void> | void () => Promise<void> | void
> = { > = {
[InternalClientCapability.PeerAPI]: async function () { [InternalClientCapability.PeerAPI]: async function () {
const configuration = // const configuration =rawCapability as CapabilityConfiguration[InternalClientCapability.PeerAPI];
rawCapability as CapabilityConfiguration[InternalClientCapability.PeerAPI];
const currentClient = await prisma.client.findUnique({ const currentClient = await prisma.client.findUnique({
where: { id: clientId }, where: { id: clientId },
@ -110,6 +109,7 @@ class CapabilityManager {
}, },
}); });
if (!currentClient) throw new Error("Invalid client ID"); if (!currentClient) throw new Error("Invalid client ID");
/*
if (currentClient.capabilities.includes(ClientCapabilities.PeerAPI)) { if (currentClient.capabilities.includes(ClientCapabilities.PeerAPI)) {
await prisma.clientPeerAPIConfiguration.update({ await prisma.clientPeerAPIConfiguration.update({
where: { clientId }, where: { clientId },
@ -126,6 +126,7 @@ class CapabilityManager {
endpoints: configuration.endpoints, endpoints: configuration.endpoints,
}, },
}); });
*/
await prisma.client.update({ await prisma.client.update({
where: { id: clientId }, where: { id: clientId },

View File

@ -2,10 +2,13 @@ import { randomUUID } from "node:crypto";
import prisma from "../db/database"; import prisma from "../db/database";
import type { Platform } from "~/prisma/client"; import type { Platform } from "~/prisma/client";
import { useCertificateAuthority } from "~/server/plugins/ca"; import { useCertificateAuthority } from "~/server/plugins/ca";
import type { CapabilityConfiguration, InternalClientCapability } from "./capabilities";
import capabilityManager from "./capabilities";
export interface ClientMetadata { export interface ClientMetadata {
name: string; name: string;
platform: Platform; platform: Platform;
capabilities: Partial<CapabilityConfiguration>;
} }
export class ClientHandler { export class ClientHandler {
@ -75,7 +78,7 @@ export class ClientHandler {
if (!metadata) throw new Error("Invalid client ID"); if (!metadata) throw new Error("Invalid client ID");
if (!metadata.userId) throw new Error("Un-authorized client ID"); if (!metadata.userId) throw new Error("Un-authorized client ID");
return await prisma.client.create({ const client = await prisma.client.create({
data: { data: {
id: id, id: id,
userId: metadata.userId, userId: metadata.userId,
@ -87,6 +90,20 @@ export class ClientHandler {
lastConnected: new Date(), 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) { async removeClient(id: string) {

View File

@ -306,7 +306,7 @@ class LibraryManager {
title: `'${game.mName}' ('${versionName}') finished importing.`, title: `'${game.mName}' ('${versionName}') finished importing.`,
description: `Drop finished importing version ${versionName} for ${game.mName}.`, description: `Drop finished importing version ${versionName} for ${game.mName}.`,
actions: [`View|/admin/library/${gameId}`], actions: [`View|/admin/library/${gameId}`],
requiredPerms: ["import:game:new"], acls: ["system:import:version:read"],
}); });
progress(100); progress(100);

View File

@ -8,28 +8,35 @@ Design goals:
import type { Notification } from "~/prisma/client"; import type { Notification } from "~/prisma/client";
import prisma from "../db/database"; import prisma from "../db/database";
import type { GlobalACL } from "../acls";
// type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>; // type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
// TODO: document notification action format // TODO: document notification action format
export type NotificationCreateArgs = Pick< export type NotificationCreateArgs = Pick<
Notification, Notification,
"title" | "description" | "actions" | "nonce" | "requiredPerms" "title" | "description" | "actions" | "nonce"
>; > & { acls: Array<GlobalACL> };
class NotificationSystem { class NotificationSystem {
// userId to acl to listenerId
private listeners = new Map< private listeners = new Map<
string, string,
Map<string, (notification: Notification) => void> Map<
string,
{ callback: (notification: Notification) => void; acls: GlobalACL[] }
>
>(); >();
listen( listen(
userId: string, userId: string,
acls: Array<GlobalACL>,
id: string, id: string,
callback: (notification: Notification) => void, callback: (notification: Notification) => void,
) { ) {
this.listeners.set(userId, new Map()); if (!this.listeners.has(userId)) this.listeners.set(userId, new Map());
this.listeners.get(userId)?.set(id, callback); // eslint-disable-next-line @typescript-eslint/no-extra-non-null-assertion
this.listeners.get(userId)!!.set(id, { callback, acls });
this.catchupListener(userId, id); this.catchupListener(userId, id);
} }
@ -39,23 +46,27 @@ class NotificationSystem {
} }
private async catchupListener(userId: string, id: string) { private async catchupListener(userId: string, id: string) {
const callback = this.listeners.get(userId)?.get(id); const listener = this.listeners.get(userId)?.get(id);
if (!callback) if (!listener)
throw new Error("Failed to catch-up listener: callback does not exist"); throw new Error("Failed to catch-up listener: callback does not exist");
const notifications = await prisma.notification.findMany({ const notifications = await prisma.notification.findMany({
where: { userId: userId }, where: { userId: userId, acls: { hasSome: listener.acls } },
orderBy: { orderBy: {
created: "asc", // Oldest first, because they arrive in reverse order created: "asc", // Oldest first, because they arrive in reverse order
}, },
}); });
for (const notification of notifications) { for (const notification of notifications) {
await callback(notification); await listener.callback(notification);
} }
} }
private async pushNotification(userId: string, notification: Notification) { private async pushNotification(userId: string, notification: Notification) {
for (const listener of this.listeners.get(userId) ?? []) { for (const [_, listener] of this.listeners.get(userId) ?? []) {
await listener[1](notification); 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) { async systemPush(notificationCreateArgs: NotificationCreateArgs) {
await this.push("system", notificationCreateArgs); return await this.pushAll(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);
} }
} }

View File

@ -1,5 +1,6 @@
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import prisma from "../db/database"; import prisma from "../db/database";
import type { User } from "~/prisma/client";
import { AuthMec } from "~/prisma/client"; import { AuthMec } from "~/prisma/client";
import objectHandler from "../objects"; import objectHandler from "../objects";
import type { Readable } from "stream"; import type { Readable } from "stream";
@ -12,10 +13,15 @@ interface OIDCWellKnown {
scopes_supported: string[]; scopes_supported: string[];
} }
interface OIDCAuthSessionOptions {
redirect: string | undefined;
}
interface OIDCAuthSession { interface OIDCAuthSession {
redirectUrl: string; redirectUrl: string;
callbackUrl: string; callbackUrl: string;
state: string; state: string;
options: OIDCAuthSessionOptions;
} }
interface OIDCUserInfo { interface OIDCUserInfo {
@ -132,7 +138,7 @@ export class OIDCManager {
}; };
} }
generateAuthSession(): OIDCAuthSession { generateAuthSession(options?: OIDCAuthSessionOptions): OIDCAuthSession {
const stateKey = randomUUID(); const stateKey = randomUUID();
const normalisedUrl = new URL( const normalisedUrl = new URL(
@ -148,12 +154,16 @@ export class OIDCManager {
redirectUrl: finalUrl, redirectUrl: finalUrl,
callbackUrl: redirectUrl, callbackUrl: redirectUrl,
state: stateKey, state: stateKey,
options: options ?? { redirect: undefined },
}; };
this.signinStateTable[stateKey] = session; this.signinStateTable[stateKey] = session;
return 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]; const session = this.signinStateTable[state];
if (!session) return "Invalid state parameter"; if (!session) return "Invalid state parameter";
@ -191,7 +201,9 @@ export class OIDCManager {
const user = await this.fetchOrCreateUser(userinfo); const user = await this.fetchOrCreateUser(userinfo);
return user; if (typeof user === "string") return user;
return { user, options: session.options };
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return `Request to identity provider failed: ${e}`; return `Request to identity provider failed: ${e}`;

View File

@ -29,15 +29,19 @@ export default defineEventHandler(async (h3) => {
statusMessage: "No state in query params.", 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({ throw createError({
statusCode: 403, 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, "/"); return sendRedirect(h3, "/");
}); });

View File

@ -9,10 +9,16 @@ defineRouteMeta({
}); });
export default defineEventHandler((h3) => { 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 manager = enabledAuthManagers.OpenID;
const { redirectUrl } = manager.generateAuthSession(); const { redirectUrl } = manager.generateAuthSession({ redirect });
return sendRedirect(h3, redirectUrl); return sendRedirect(h3, redirectUrl);
}); });