mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-13 16:22:39 +10:00
Merge remote-tracking branch 'origin/develop' into more-fixes
This commit is contained in:
@ -6,6 +6,7 @@
|
|||||||
# Drop
|
# Drop
|
||||||
|
|
||||||
[](https://droposs.org)
|
[](https://droposs.org)
|
||||||
|
[](https://forum.droposs.org)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://discord.gg/ACq4qZp4a9)
|
[](https://discord.gg/ACq4qZp4a9)
|
||||||
[](https://opencollective.com/drop-oss)
|
[](https://opencollective.com/drop-oss)
|
||||||
|
|||||||
@ -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 →
|
Sign in with external provider →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const route = useRoute();
|
||||||
|
</script>
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 →
|
Open with Metadata →
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|||||||
@ -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 →
|
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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Notification" ADD COLUMN "acls" TEXT[];
|
||||||
@ -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[]
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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`;
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 {};
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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}`;
|
||||||
|
|||||||
@ -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, "/");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user