mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-13 08:12:40 +10:00
Merge remote-tracking branch 'origin/develop' into more-fixes
This commit is contained in:
@ -6,6 +6,7 @@
|
||||
# Drop
|
||||
|
||||
[](https://droposs.org)
|
||||
[](https://forum.droposs.org)
|
||||
[](LICENSE)
|
||||
[](https://discord.gg/ACq4qZp4a9)
|
||||
[](https://opencollective.com/drop-oss)
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
<template>
|
||||
<div class="flex">
|
||||
<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"
|
||||
>
|
||||
Sign in with external provider →
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
</script>
|
||||
|
||||
@ -1,82 +1,141 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div>
|
||||
<!-- 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',
|
||||
]"
|
||||
<div
|
||||
v-if="game && unimportedVersions !== undefined"
|
||||
class="grow flex flex-col gap-y-8"
|
||||
>
|
||||
{{
|
||||
unimportedVersions.length > 0
|
||||
? "Import version"
|
||||
: "No versions to import"
|
||||
}}
|
||||
</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()"
|
||||
<div
|
||||
class="grow w-full h-full lg:pr-[30vw] px-6 py-4 flex flex-col"
|
||||
></div>
|
||||
<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"
|
||||
>
|
||||
<template #item="{ element: item }: { element: GameVersion }">
|
||||
<div
|
||||
class="w-full inline-flex items-center px-4 py-2 bg-zinc-800 rounded justify-between"
|
||||
<!-- toolbar -->
|
||||
<div class="inline-flex justify-end items-stretch gap-x-4">
|
||||
<!-- 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">
|
||||
{{ item.versionName }}
|
||||
Open in Metadata
|
||||
<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 class="text-zinc-400">
|
||||
{{ item.delta ? "Upgrade mode" : "" }}
|
||||
|
||||
<div class="mt-4 text-center w-full text-sm text-zinc-600">
|
||||
lowest
|
||||
</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>
|
||||
<draggable
|
||||
:list="game.versions"
|
||||
handle=".handle"
|
||||
class="mt-2 space-y-4"
|
||||
@update="() => updateVersionOrder()"
|
||||
>
|
||||
<template #item="{ element: item }: { element: GameVersion }">
|
||||
<div
|
||||
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>
|
||||
</template>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Bars3Icon, TrashIcon } from "@heroicons/vue/16/solid";
|
||||
import type { GameVersion } from "~/prisma/client";
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
Bars3Icon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/vue/24/solid";
|
||||
|
||||
definePageMeta({
|
||||
layout: "admin",
|
||||
|
||||
@ -94,7 +94,7 @@
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
: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 →
|
||||
</NuxtLink>
|
||||
|
||||
@ -184,7 +184,7 @@
|
||||
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 Library →
|
||||
Open in Library
|
||||
<ArrowTopRightOnSquareIcon
|
||||
class="-mr-0.5 h-7 w-7 p-1"
|
||||
aria-hidden="true"
|
||||
|
||||
@ -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"
|
||||
>
|
||||
<span
|
||||
class="transition-all text-4xl font-bold text-zinc-300 group-hover:text-zinc-100 uppercase tracking-widest"
|
||||
>GAMES</span
|
||||
class="transition-all text-4xl font-bold text-zinc-400 group-hover:text-zinc-100 uppercase tracking-widest"
|
||||
>Games</span
|
||||
>
|
||||
</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"
|
||||
>
|
||||
<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
|
||||
>
|
||||
</NuxtLink>
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Notification" ADD COLUMN "acls" TEXT[];
|
||||
@ -16,17 +16,6 @@ model Client {
|
||||
platform Platform
|
||||
lastConnected DateTime
|
||||
|
||||
peerAPI ClientPeerAPIConfiguration?
|
||||
|
||||
lastAccessedSaves SaveSlot[]
|
||||
tokens APIToken[]
|
||||
}
|
||||
|
||||
model ClientPeerAPIConfiguration {
|
||||
id String @id @default(uuid())
|
||||
|
||||
clientId String @unique
|
||||
client Client @relation(fields: [clientId], references: [id])
|
||||
|
||||
endpoints String[]
|
||||
}
|
||||
|
||||
@ -27,7 +27,8 @@ model Notification {
|
||||
nonce String?
|
||||
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
acls String[]
|
||||
|
||||
created DateTime @default(now())
|
||||
title String
|
||||
|
||||
@ -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 {};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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}`;
|
||||
|
||||
@ -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, "/");
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user