mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-10 20:42:06 +10:00
Merge branch 'Huskydog9988-more-ui-work' into develop
This commit is contained in:
12
app.vue
12
app.vue
@ -8,3 +8,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
await updateUser();
|
await updateUser();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* You can customise the default animation here. */
|
||||||
|
|
||||||
|
::view-transition-old(root) {
|
||||||
|
animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-new(root) {
|
||||||
|
animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
54
build/fix-prisma.js
Normal file
54
build/fix-prisma.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
// thanks https://github.com/prisma/prisma/issues/26565#issuecomment-2777915354
|
||||||
|
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
async function replaceInFiles(dir) {
|
||||||
|
const files = await fs.readdir(dir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const fullPath = path.join(dir, file.name);
|
||||||
|
// Skip directories
|
||||||
|
if (!file.isDirectory()) {
|
||||||
|
if (
|
||||||
|
file.name.endsWith(".js") ||
|
||||||
|
file.name.endsWith(".ts") ||
|
||||||
|
file.name.endsWith(".mjs")
|
||||||
|
) {
|
||||||
|
let content = await fs.readFile(fullPath, "utf8");
|
||||||
|
if (content.includes(".prisma")) {
|
||||||
|
const isWindows = content.includes("\r\n");
|
||||||
|
const lineEnding = isWindows ? "\r\n" : "\n";
|
||||||
|
|
||||||
|
content = content
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.replace(/\.prisma/g, "_prisma"))
|
||||||
|
.join(lineEnding);
|
||||||
|
|
||||||
|
await fs.writeFile(fullPath, content, "utf8");
|
||||||
|
console.log(`Modified: ${fullPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const oldPath = path.join(__dirname, "../node_modules/.prisma");
|
||||||
|
const newPath = path.join(__dirname, "../node_modules/_prisma");
|
||||||
|
try {
|
||||||
|
await fs.rename(oldPath, newPath);
|
||||||
|
console.log("Directory renamed from .prisma to _prisma");
|
||||||
|
} catch (err) {
|
||||||
|
console.log("Directory .prisma does not exist or has already been renamed");
|
||||||
|
}
|
||||||
|
|
||||||
|
await replaceInFiles(path.join(__dirname, "../node_modules/@prisma/client"));
|
||||||
|
console.log("Done! --- prisma!!!, replaced .prisma with _prisma");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => console.error(err));
|
||||||
@ -2,6 +2,7 @@
|
|||||||
<div class="flex flex-row flex-wrap gap-2 justify-center">
|
<div class="flex flex-row flex-wrap gap-2 justify-center">
|
||||||
<button
|
<button
|
||||||
v-for="(_, i) in amount"
|
v-for="(_, i) in amount"
|
||||||
|
:key="i"
|
||||||
@click="() => slideTo(i)"
|
@click="() => slideTo(i)"
|
||||||
:class="[
|
:class="[
|
||||||
carousel.currentSlide == i ? 'bg-blue-600 w-6' : 'bg-zinc-700 w-3',
|
carousel.currentSlide == i ? 'bg-blue-600 w-6' : 'bg-zinc-700 w-3',
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="currentComponent">
|
<div ref="currentComponent">
|
||||||
<ClientOnly>
|
<ClientOnly fallback-tag="span">
|
||||||
<VueCarousel :itemsToShow="singlePage" :itemsToScroll="singlePage">
|
<VueCarousel :itemsToShow="singlePage" :itemsToScroll="singlePage">
|
||||||
<VueSlide
|
<VueSlide
|
||||||
class="justify-start"
|
class="justify-start"
|
||||||
@ -14,6 +14,18 @@
|
|||||||
<VueNavigation />
|
<VueNavigation />
|
||||||
</template>
|
</template>
|
||||||
</VueCarousel>
|
</VueCarousel>
|
||||||
|
<template #fallback>
|
||||||
|
<div
|
||||||
|
class="flex flex-nowrap flex-row overflow-hidden whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<SkeletonCard
|
||||||
|
v-for="index in 10"
|
||||||
|
:key="index"
|
||||||
|
:loading="true"
|
||||||
|
class="mr-3 flex-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -37,13 +49,22 @@ const games: Ref<Array<SerializeObject<Game> | undefined>> = computed(() =>
|
|||||||
.map((_, i) => props.items[i])
|
.map((_, i) => props.items[i])
|
||||||
);
|
);
|
||||||
|
|
||||||
const singlePage = ref(1);
|
const singlePage = ref(2);
|
||||||
const sizeOfCard = 192 + 10;
|
const sizeOfCard = 192 + 10;
|
||||||
|
|
||||||
onMounted(() => {
|
const handleResize = () => {
|
||||||
singlePage.value =
|
singlePage.value =
|
||||||
(props.width ??
|
(props.width ??
|
||||||
currentComponent.value?.parentElement?.clientWidth ??
|
currentComponent.value?.parentElement?.clientWidth ??
|
||||||
window.innerWidth) / sizeOfCard;
|
window.innerWidth) / sizeOfCard;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
handleResize();
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -3,45 +3,59 @@
|
|||||||
v-if="game"
|
v-if="game"
|
||||||
:href="props.href ?? `/store/${game.id}`"
|
:href="props.href ?? `/store/${game.id}`"
|
||||||
class="group relative w-48 h-64 rounded-lg overflow-hidden transition-all duration-300 text-left hover:scale-[1.02] hover:shadow-lg hover:-translate-y-0.5"
|
class="group relative w-48 h-64 rounded-lg overflow-hidden transition-all duration-300 text-left hover:scale-[1.02] hover:shadow-lg hover:-translate-y-0.5"
|
||||||
|
@click.native="active = game.id"
|
||||||
>
|
>
|
||||||
<div class="absolute inset-0 transition-all duration-300 group-hover:scale-110">
|
<div
|
||||||
<img
|
class="absolute inset-0 transition-all duration-300 group-hover:scale-110"
|
||||||
:src="useObject(game.mCoverId)"
|
>
|
||||||
class="w-full h-full object-cover brightness-[90%]"
|
<img
|
||||||
|
:src="useObject(game.mCoverId)"
|
||||||
|
class="w-full h-full object-cover brightness-[90%]"
|
||||||
|
:class="{ active: active === game.id }"
|
||||||
|
:alt="game.mName"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-gradient-to-t from-zinc-950/80 via-zinc-950/20 to-transparent"
|
||||||
/>
|
/>
|
||||||
<div class="absolute inset-0 bg-gradient-to-t from-zinc-950/80 via-zinc-950/20 to-transparent" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute bottom-0 left-0 w-full p-3">
|
<div class="absolute bottom-0 left-0 w-full p-3">
|
||||||
<h1 class="text-zinc-100 text-sm font-bold font-display group-hover:text-white transition-colors">
|
<h1
|
||||||
|
class="text-zinc-100 text-sm font-bold font-display group-hover:text-white transition-colors"
|
||||||
|
>
|
||||||
{{ game.mName }}
|
{{ game.mName }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-zinc-400 text-xs line-clamp-2 group-hover:text-zinc-300 transition-colors">
|
<p
|
||||||
|
class="text-zinc-400 text-xs line-clamp-2 group-hover:text-zinc-300 transition-colors"
|
||||||
|
>
|
||||||
{{ game.mShortDescription }}
|
{{ game.mShortDescription }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div
|
<SkeletonCard v-else message="no game" />>
|
||||||
v-else
|
|
||||||
class="rounded-lg w-48 h-64 bg-zinc-800/50 flex items-center justify-center transition-all duration-300 hover:bg-zinc-800"
|
|
||||||
>
|
|
||||||
<p class="text-zinc-700 text-sm font-semibold font-display uppercase">
|
|
||||||
no game
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SerializeObject } from "nitropack";
|
import type { SerializeObject } from "nitropack";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
game: SerializeObject<{
|
game:
|
||||||
id: string;
|
| SerializeObject<{
|
||||||
mCoverId: string;
|
id: string;
|
||||||
mName: string;
|
mCoverId: string;
|
||||||
mShortDescription: string;
|
mName: string;
|
||||||
}> | undefined;
|
mShortDescription: string;
|
||||||
|
}>
|
||||||
|
| undefined;
|
||||||
href?: string;
|
href?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const active = useState();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
img.active {
|
||||||
|
view-transition-name: selected-game;
|
||||||
|
contain: layout;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
v-for="[name, link] in notification.actions.map((e) =>
|
v-for="[name, link] in notification.actions.map((e) =>
|
||||||
e.split('|')
|
e.split('|')
|
||||||
)"
|
)"
|
||||||
|
:key="name"
|
||||||
type="button"
|
type="button"
|
||||||
:href="link"
|
:href="link"
|
||||||
class="rounded-md text-sm font-medium text-blue-600 hover:text-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
class="rounded-md text-sm font-medium text-blue-600 hover:text-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||||
|
|||||||
19
components/SkeletonCard.vue
Normal file
19
components/SkeletonCard.vue
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'rounded-lg w-48 h-64 bg-zinc-800/50 flex items-center justify-center transition-all duration-300 hover:bg-zinc-800',
|
||||||
|
props.loading && 'animate-pulse',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<p class="text-zinc-700 text-sm font-semibold font-display uppercase">
|
||||||
|
{{ props.message }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
message?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
@ -1,103 +1,132 @@
|
|||||||
<template>
|
<template>
|
||||||
<footer class="bg-zinc-950" aria-labelledby="footer-heading">
|
<footer class="bg-zinc-950" aria-labelledby="footer-heading">
|
||||||
<h2 id="footer-heading" class="sr-only">Footer</h2>
|
<h2 id="footer-heading" class="sr-only">Footer</h2>
|
||||||
<div class="mx-auto max-w-7xl px-6 py-16 sm:py-24 lg:px-8 ">
|
<div class="mx-auto max-w-7xl px-6 py-16 sm:py-24 lg:px-8">
|
||||||
<div class="xl:grid xl:grid-cols-3 xl:gap-8">
|
<div class="xl:grid xl:grid-cols-3 xl:gap-8">
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
<Wordmark class="h-10" />
|
<Wordmark class="h-10" />
|
||||||
<p class="text-sm leading-6 text-zinc-300">An open-source game distribution platform built for
|
<p class="text-sm leading-6 text-zinc-300">
|
||||||
speed, flexibility and beauty.</p>
|
An open-source game distribution platform built for speed,
|
||||||
<div class="flex space-x-6">
|
flexibility and beauty.
|
||||||
<a v-for="item in navigation.social" :key="item.name" :href="item.href" target="_blank"
|
</p>
|
||||||
class="text-zinc-400 hover:text-zinc-400">
|
<div class="flex space-x-6">
|
||||||
<span class="sr-only">{{ item.name }}</span>
|
<NuxtLink
|
||||||
<component :is="item.icon" class="h-6 w-6" aria-hidden="true" />
|
v-for="item in navigation.social"
|
||||||
</a>
|
:key="item.name"
|
||||||
</div>
|
:to="item.href"
|
||||||
</div>
|
target="_blank"
|
||||||
<div class="mt-16 grid grid-cols-2 gap-8 xl:col-span-2 xl:mt-0">
|
class="text-zinc-400 hover:text-zinc-400"
|
||||||
<div class="md:grid md:grid-cols-2 md:gap-8">
|
>
|
||||||
<div>
|
<span class="sr-only">{{ item.name }}</span>
|
||||||
<h3 class="text-sm font-semibold leading-6 text-white">Games</h3>
|
<component :is="item.icon" class="h-6 w-6" aria-hidden="true" />
|
||||||
<ul role="list" class="mt-6 space-y-4">
|
</NuxtLink>
|
||||||
<li v-for="item in navigation.games" :key="item.name">
|
</div>
|
||||||
<a :href="item.href" class="text-sm leading-6 text-zinc-300 hover:text-white">{{
|
|
||||||
item.name }}</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="mt-10 md:mt-0">
|
|
||||||
<h3 class="text-sm font-semibold leading-6 text-white">Community</h3>
|
|
||||||
<ul role="list" class="mt-6 space-y-4">
|
|
||||||
<li v-for="item in navigation.community" :key="item.name">
|
|
||||||
<a :href="item.href" class="text-sm leading-6 text-zinc-300 hover:text-white">{{
|
|
||||||
item.name }}</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="md:grid md:grid-cols-2 md:gap-8">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-semibold leading-6 text-white">Documentation</h3>
|
|
||||||
<ul role="list" class="mt-6 space-y-4">
|
|
||||||
<li v-for="item in navigation.documentation" :key="item.name">
|
|
||||||
<a :href="item.href" class="text-sm leading-6 text-zinc-300 hover:text-white">{{
|
|
||||||
item.name }}</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="mt-10 md:mt-0">
|
|
||||||
<h3 class="text-sm font-semibold leading-6 text-white">About</h3>
|
|
||||||
<ul role="list" class="mt-6 space-y-4">
|
|
||||||
<li v-for="item in navigation.about" :key="item.name">
|
|
||||||
<a :href="item.href" class="text-sm leading-6 text-zinc-300 hover:text-white">{{
|
|
||||||
item.name }}</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
<div class="mt-16 grid grid-cols-2 gap-8 xl:col-span-2 xl:mt-0">
|
||||||
|
<div class="md:grid md:grid-cols-2 md:gap-8">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold leading-6 text-white">Games</h3>
|
||||||
|
<ul role="list" class="mt-6 space-y-4">
|
||||||
|
<li v-for="item in navigation.games" :key="item.name">
|
||||||
|
<NuxtLink
|
||||||
|
:to="item.href"
|
||||||
|
class="text-sm leading-6 text-zinc-300 hover:text-white"
|
||||||
|
>{{ item.name }}</NuxtLink
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="mt-10 md:mt-0">
|
||||||
|
<h3 class="text-sm font-semibold leading-6 text-white">
|
||||||
|
Community
|
||||||
|
</h3>
|
||||||
|
<ul role="list" class="mt-6 space-y-4">
|
||||||
|
<li v-for="item in navigation.community" :key="item.name">
|
||||||
|
<NuxtLink
|
||||||
|
:to="item.href"
|
||||||
|
class="text-sm leading-6 text-zinc-300 hover:text-white"
|
||||||
|
>{{ item.name }}</NuxtLink
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="md:grid md:grid-cols-2 md:gap-8">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold leading-6 text-white">
|
||||||
|
Documentation
|
||||||
|
</h3>
|
||||||
|
<ul role="list" class="mt-6 space-y-4">
|
||||||
|
<li v-for="item in navigation.documentation" :key="item.name">
|
||||||
|
<NuxtLink
|
||||||
|
:to="item.href"
|
||||||
|
class="text-sm leading-6 text-zinc-300 hover:text-white"
|
||||||
|
>{{ item.name }}</NuxtLink
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="mt-10 md:mt-0">
|
||||||
|
<h3 class="text-sm font-semibold leading-6 text-white">About</h3>
|
||||||
|
<ul role="list" class="mt-6 space-y-4">
|
||||||
|
<li v-for="item in navigation.about" :key="item.name">
|
||||||
|
<NuxtLink
|
||||||
|
:to="item.href"
|
||||||
|
class="text-sm leading-6 text-zinc-300 hover:text-white"
|
||||||
|
>{{ item.name }}</NuxtLink
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { IconsDiscordLogo, IconsGithubLogo } from '#components';
|
import { IconsDiscordLogo, IconsGithubLogo } from "#components";
|
||||||
|
|
||||||
const navigation = {
|
const navigation = {
|
||||||
games: [
|
games: [
|
||||||
{ name: 'Newly Added', href: '#' },
|
{ name: "Newly Added", href: "#" },
|
||||||
{ name: 'New Releases', href: '#' },
|
{ name: "New Releases", href: "#" },
|
||||||
{ name: 'Top Sellers', href: '#' },
|
{ name: "Top Sellers", href: "#" },
|
||||||
{ name: 'Find a Game', href: '#' },
|
{ name: "Find a Game", href: "#" },
|
||||||
],
|
],
|
||||||
community: [
|
community: [
|
||||||
{ name: 'Friends', href: '#' },
|
{ name: "Friends", href: "#" },
|
||||||
{ name: 'Groups', href: '#' },
|
{ name: "Groups", href: "#" },
|
||||||
{ name: 'Servers', href: '#' },
|
{ name: "Servers", href: "#" },
|
||||||
],
|
],
|
||||||
documentation: [
|
documentation: [
|
||||||
{ name: 'API', href: 'https://api.droposs.org/' },
|
{ name: "API", href: "https://api.droposs.org/" },
|
||||||
{ name: 'Server Docs', href: 'https://wiki.droposs.org/guides/quickstart.html' },
|
{
|
||||||
{ name: 'Client Docs', href: 'https://wiki.droposs.org/guides/client.html' },
|
name: "Server Docs",
|
||||||
],
|
href: "https://wiki.droposs.org/guides/quickstart.html",
|
||||||
about: [
|
},
|
||||||
{ name: 'About Drop', href: 'https://droposs.org/' },
|
{
|
||||||
{ name: 'Features', href: 'https://droposs.org/features' },
|
name: "Client Docs",
|
||||||
{ name: 'FAQ', href: 'https://droposs.org/faq' },
|
href: "https://wiki.droposs.org/guides/client.html",
|
||||||
],
|
},
|
||||||
social: [
|
],
|
||||||
{
|
about: [
|
||||||
name: 'GitHub',
|
{ name: "About Drop", href: "https://droposs.org/" },
|
||||||
href: 'https://github.com/Drop-OSS',
|
{ name: "Features", href: "https://droposs.org/features" },
|
||||||
icon: IconsGithubLogo,
|
{ name: "FAQ", href: "https://droposs.org/faq" },
|
||||||
},
|
],
|
||||||
{
|
social: [
|
||||||
name: "Discord",
|
{
|
||||||
href: "https://discord.gg/NHx46XKJWA",
|
name: "GitHub",
|
||||||
icon: IconsDiscordLogo
|
href: "https://github.com/Drop-OSS",
|
||||||
}
|
icon: IconsGithubLogo,
|
||||||
],
|
},
|
||||||
}
|
{
|
||||||
</script>
|
name: "Discord",
|
||||||
|
href: "https://discord.gg/NHx46XKJWA",
|
||||||
|
icon: IconsDiscordLogo,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="hidden lg:flex bg-zinc-950 flex-row px-12 xl:px-48 py-5">
|
<div class="hidden lg:flex bg-zinc-950 flex-row px-12 xl:px-48 py-5">
|
||||||
<div class="grow inline-flex items-center gap-x-20">
|
<div class="grow inline-flex items-center gap-x-20">
|
||||||
<NuxtLink to="/">
|
<NuxtLink to="/store">
|
||||||
<Wordmark class="h-8" />
|
<Wordmark class="h-8" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<nav class="inline-flex items-center">
|
<nav class="inline-flex items-center">
|
||||||
<ol class="inline-flex items-center gap-x-12">
|
<ol class="inline-flex items-center gap-x-12">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="(nav, navIdx) in navigation"
|
v-for="(nav, navIdx) in navigation"
|
||||||
|
:key="navIdx"
|
||||||
:href="nav.route"
|
:href="nav.route"
|
||||||
:class="[
|
:class="[
|
||||||
'transition hover:text-zinc-200 uppercase font-display font-semibold text-md',
|
'transition hover:text-zinc-200 uppercase font-display font-semibold text-md',
|
||||||
@ -141,6 +142,7 @@
|
|||||||
<ol class="flex flex-col gap-y-3">
|
<ol class="flex flex-col gap-y-3">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="(nav, navIdx) in navigation"
|
v-for="(nav, navIdx) in navigation"
|
||||||
|
:key="navIdx"
|
||||||
:href="nav.route"
|
:href="nav.route"
|
||||||
:class="[
|
:class="[
|
||||||
'transition hover:text-zinc-200 uppercase font-display font-semibold text-md',
|
'transition hover:text-zinc-200 uppercase font-display font-semibold text-md',
|
||||||
|
|||||||
@ -24,6 +24,7 @@
|
|||||||
<div class="flex flex-col gap-y-2 max-h-[300px] overflow-y-scroll">
|
<div class="flex flex-col gap-y-2 max-h-[300px] overflow-y-scroll">
|
||||||
<Notification
|
<Notification
|
||||||
v-for="notification in props.notifications"
|
v-for="notification in props.notifications"
|
||||||
|
:key="notification.id"
|
||||||
:notification="notification"
|
:notification="notification"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -41,6 +41,7 @@
|
|||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<MenuItem
|
<MenuItem
|
||||||
v-for="(nav, navIdx) in navigation"
|
v-for="(nav, navIdx) in navigation"
|
||||||
|
:key="navIdx"
|
||||||
v-slot="{ active, close }"
|
v-slot="{ active, close }"
|
||||||
hydrate-on-visible
|
hydrate-on-visible
|
||||||
>
|
>
|
||||||
|
|||||||
12
error.vue
12
error.vue
@ -63,11 +63,11 @@ if (import.meta.client) {
|
|||||||
</p>
|
</p>
|
||||||
<div class="mt-10">
|
<div class="mt-10">
|
||||||
<!-- full app reload to fix errors -->
|
<!-- full app reload to fix errors -->
|
||||||
<a
|
<NuxtLink
|
||||||
v-if="user && !showSignIn"
|
v-if="user && !showSignIn"
|
||||||
href="/"
|
to="/"
|
||||||
class="text-sm font-semibold leading-7 text-blue-600"
|
class="text-sm font-semibold leading-7 text-blue-600"
|
||||||
><span aria-hidden="true">←</span> Back to home</a
|
><span aria-hidden="true">←</span> Back to home</NuxtLink
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
@ -92,9 +92,9 @@ if (import.meta.client) {
|
|||||||
>
|
>
|
||||||
<circle cx="1" cy="1" r="1" />
|
<circle cx="1" cy="1" r="1" />
|
||||||
</svg>
|
</svg>
|
||||||
<a href="https://discord.gg/NHx46XKJWA" target="_blank"
|
<NuxtLink to="https://discord.gg/NHx46XKJWA" target="_blank">
|
||||||
>Support Discord</a
|
Support Discord
|
||||||
>
|
</NuxtLink>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@ -223,6 +223,16 @@ router.afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
|
htmlAttrs: {
|
||||||
|
lang: "en",
|
||||||
|
},
|
||||||
|
link: [
|
||||||
|
{
|
||||||
|
rel: "icon",
|
||||||
|
type: "image/png",
|
||||||
|
href: "/favicon.png",
|
||||||
|
},
|
||||||
|
],
|
||||||
titleTemplate(title) {
|
titleTemplate(title) {
|
||||||
return title ? `${title} | Admin | Drop` : `Admin Dashboard | Drop`;
|
return title ? `${title} | Admin | Drop` : `Admin Dashboard | Drop`;
|
||||||
},
|
},
|
||||||
|
|||||||
@ -16,6 +16,16 @@ const route = useRoute();
|
|||||||
const noWrapper = !!route.query.noWrapper;
|
const noWrapper = !!route.query.noWrapper;
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
|
htmlAttrs: {
|
||||||
|
lang: "en",
|
||||||
|
},
|
||||||
|
link: [
|
||||||
|
{
|
||||||
|
rel: "icon",
|
||||||
|
type: "image/png",
|
||||||
|
href: "/favicon.png",
|
||||||
|
},
|
||||||
|
],
|
||||||
titleTemplate(title) {
|
titleTemplate(title) {
|
||||||
if (title) return `${title} | Drop`;
|
if (title) return `${title} | Drop`;
|
||||||
return `Drop`;
|
return `Drop`;
|
||||||
|
|||||||
@ -6,11 +6,19 @@ export default defineNuxtConfig({
|
|||||||
// Nuxt-only config
|
// Nuxt-only config
|
||||||
telemetry: false,
|
telemetry: false,
|
||||||
compatibilityDate: "2024-04-03",
|
compatibilityDate: "2024-04-03",
|
||||||
devtools: { enabled: false },
|
devtools: {
|
||||||
|
enabled: true,
|
||||||
|
telemetry: false,
|
||||||
|
timeline: {
|
||||||
|
// seems to break things
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
css: ["~/assets/tailwindcss.css", "~/assets/core.scss"],
|
css: ["~/assets/tailwindcss.css", "~/assets/core.scss"],
|
||||||
|
|
||||||
experimental: {
|
experimental: {
|
||||||
buildCache: true,
|
buildCache: true,
|
||||||
|
viewTransition: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
vite: {
|
vite: {
|
||||||
@ -48,6 +56,8 @@ export default defineNuxtConfig({
|
|||||||
tsConfig: {
|
tsConfig: {
|
||||||
compilerOptions: {
|
compilerOptions: {
|
||||||
verbatimModuleSyntax: false,
|
verbatimModuleSyntax: false,
|
||||||
|
strictNullChecks: true,
|
||||||
|
exactOptionalPropertyTypes: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -78,6 +88,8 @@ export default defineNuxtConfig({
|
|||||||
"https://images.pcgamingwiki.com",
|
"https://images.pcgamingwiki.com",
|
||||||
"https://images.igdb.com",
|
"https://images.igdb.com",
|
||||||
],
|
],
|
||||||
|
|
||||||
|
"script-src": ["'nonce-{{nonce}}'"],
|
||||||
},
|
},
|
||||||
strictTransportSecurity: false,
|
strictTransportSecurity: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
"dev": "nuxt dev",
|
"dev": "nuxt dev",
|
||||||
"generate": "nuxt generate",
|
"generate": "nuxt generate",
|
||||||
"preview": "nuxt preview",
|
"preview": "nuxt preview",
|
||||||
"postinstall": "prisma generate && nuxt prepare",
|
"postinstall": "prisma generate && nuxt prepare && node build/fix-prisma.js",
|
||||||
"typecheck": "nuxt typecheck"
|
"typecheck": "nuxt typecheck"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -17,7 +17,6 @@
|
|||||||
"@heroicons/vue": "^2.1.5",
|
"@heroicons/vue": "^2.1.5",
|
||||||
"@nuxt/fonts": "^0.11.0",
|
"@nuxt/fonts": "^0.11.0",
|
||||||
"@nuxt/image": "^1.10.0",
|
"@nuxt/image": "^1.10.0",
|
||||||
"@nuxtjs/tailwindcss": "^6.12.2",
|
|
||||||
"@prisma/client": "^6.5.0",
|
"@prisma/client": "^6.5.0",
|
||||||
"@tailwindcss/vite": "^4.0.6",
|
"@tailwindcss/vite": "^4.0.6",
|
||||||
"argon2": "^0.41.1",
|
"argon2": "^0.41.1",
|
||||||
@ -31,12 +30,10 @@
|
|||||||
"lru-cache": "^11.1.0",
|
"lru-cache": "^11.1.0",
|
||||||
"luxon": "^3.6.1",
|
"luxon": "^3.6.1",
|
||||||
"micromark": "^4.0.1",
|
"micromark": "^4.0.1",
|
||||||
"nuxt": "3.15.4",
|
"nuxt": "^3.16.2",
|
||||||
"nuxt-security": "2.2.0",
|
"nuxt-security": "2.2.0",
|
||||||
"prisma": "^6.5.0",
|
"prisma": "^6.5.0",
|
||||||
"sanitize-filename": "^1.6.3",
|
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
"stream": "^0.0.3",
|
|
||||||
"stream-mime-type": "^2.0.0",
|
"stream-mime-type": "^2.0.0",
|
||||||
"turndown": "^7.2.0",
|
"turndown": "^7.2.0",
|
||||||
"vue": "latest",
|
"vue": "latest",
|
||||||
@ -48,7 +45,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/luxon": "^3.6.2",
|
"@types/luxon": "^3.6.2",
|
||||||
"@types/node": "^22.13.16",
|
"@types/node": "^22.13.16",
|
||||||
"@types/turndown": "^5.0.5",
|
"@types/turndown": "^5.0.5",
|
||||||
|
|||||||
@ -62,6 +62,7 @@
|
|||||||
<li
|
<li
|
||||||
class="inline-flex items-center gap-x-0.5"
|
class="inline-flex items-center gap-x-0.5"
|
||||||
v-for="capability in client.capabilities"
|
v-for="capability in client.capabilities"
|
||||||
|
:key="capability"
|
||||||
>
|
>
|
||||||
<CheckIcon class="size-4" /> {{ capability }}
|
<CheckIcon class="size-4" /> {{ capability }}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@ -50,29 +50,32 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-zinc-950/50 rounded-md p-2 text-zinc-100">
|
<div class="bg-zinc-950/50 rounded-md p-2 text-zinc-100">
|
||||||
<pre v-for="line in task.log">{{ line }}</pre>
|
<pre v-for="(line, idx) in task.log" :key="idx">{{ line }}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else role="status" class="w-full h-screen flex items-center justify-center">
|
<div
|
||||||
<svg
|
v-else
|
||||||
aria-hidden="true"
|
role="status"
|
||||||
class="size-8 text-transparent animate-spin fill-white"
|
class="w-full h-screen flex items-center justify-center"
|
||||||
viewBox="0 0 100 101"
|
>
|
||||||
fill="none"
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
aria-hidden="true"
|
||||||
>
|
class="size-8 text-transparent animate-spin fill-white"
|
||||||
<path
|
viewBox="0 0 100 101"
|
||||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
fill="none"
|
||||||
fill="currentColor"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
/>
|
>
|
||||||
<path
|
<path
|
||||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||||
fill="currentFill"
|
fill="currentColor"
|
||||||
/>
|
/>
|
||||||
</svg>
|
<path
|
||||||
<span class="sr-only">Loading...</span>
|
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||||
</div>
|
fill="currentFill"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Loading...</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@ -80,6 +80,7 @@
|
|||||||
<div v-if="authMech.settings">
|
<div v-if="authMech.settings">
|
||||||
<div
|
<div
|
||||||
v-for="[key, value] in Object.entries(authMech.settings)"
|
v-for="[key, value] in Object.entries(authMech.settings)"
|
||||||
|
:key="key"
|
||||||
class="flex justify-between gap-x-4 py-2"
|
class="flex justify-between gap-x-4 py-2"
|
||||||
>
|
>
|
||||||
<dt class="text-zinc-400">{{ key }}</dt>
|
<dt class="text-zinc-400">{{ key }}</dt>
|
||||||
|
|||||||
@ -81,10 +81,10 @@
|
|||||||
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-3"
|
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-3"
|
||||||
>
|
>
|
||||||
<!--
|
<!--
|
||||||
<a href="#" class="text-blue-600 hover:text-blue-500"
|
<NuxtLink to="#" class="text-blue-600 hover:text-blue-500"
|
||||||
>Edit<span class="sr-only"
|
>Edit<span class="sr-only"
|
||||||
>, {{ user.displayName }}</span
|
>, {{ user.displayName }}</span
|
||||||
></a
|
></NuxtLink
|
||||||
>
|
>
|
||||||
-->
|
-->
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -175,11 +175,11 @@
|
|||||||
<p v-if="false" class="mt-10 text-center text-sm text-zinc-400">
|
<p v-if="false" class="mt-10 text-center text-sm text-zinc-400">
|
||||||
What's Drop?
|
What's Drop?
|
||||||
{{ " " }}
|
{{ " " }}
|
||||||
<a
|
<NuxtLink
|
||||||
href="https://github.com/Drop-OSS/drop"
|
to="https://github.com/Drop-OSS/drop"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="font-semibold leading-6 text-blue-600 hover:text-blue-500"
|
class="font-semibold leading-6 text-blue-600 hover:text-blue-500"
|
||||||
>Check us out here →</a
|
>Check us out here →</NuxtLink
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -74,10 +74,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm leading-6">
|
<div class="text-sm leading-6">
|
||||||
<a
|
<NuxtLink
|
||||||
href="#"
|
to="#"
|
||||||
class="font-semibold text-blue-600 hover:text-blue-500"
|
class="font-semibold text-blue-600 hover:text-blue-500"
|
||||||
>Forgot password?</a
|
>Forgot password?</NuxtLink
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -24,6 +24,7 @@
|
|||||||
>
|
>
|
||||||
<GamePanel
|
<GamePanel
|
||||||
v-for="entry in collection?.entries"
|
v-for="entry in collection?.entries"
|
||||||
|
:key="entry.gameId"
|
||||||
:game="entry.game"
|
:game="entry.game"
|
||||||
:href="`/library/game/${entry.game.id}`"
|
:href="`/library/game/${entry.game.id}`"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -72,12 +72,11 @@
|
|||||||
|
|
||||||
<!-- game library grid -->
|
<!-- game library grid -->
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-zinc-100 text-xl font-bold font-display">
|
<h1 class="text-zinc-100 text-xl font-bold font-display">All Games</h1>
|
||||||
All Games
|
|
||||||
</h1>
|
|
||||||
<div class="mt-4 flex flex-row flex-wrap justify-left gap-4">
|
<div class="mt-4 flex flex-row flex-wrap justify-left gap-4">
|
||||||
<GamePanel
|
<GamePanel
|
||||||
v-for="game in games"
|
v-for="game in games"
|
||||||
|
:key="game.id"
|
||||||
:game="game"
|
:game="game"
|
||||||
:href="`/library/game/${game?.id}`"
|
:href="`/library/game/${game?.id}`"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -27,8 +27,9 @@
|
|||||||
class="col-start-1 lg:col-start-4 flex flex-col gap-y-6 items-center"
|
class="col-start-1 lg:col-start-4 flex flex-col gap-y-6 items-center"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
class="transition-all duration-300 hover:scale-105 hover:rotate-[-1deg] w-64 h-auto rounded"
|
class="transition-all duration-300 hover:scale-105 hover:rotate-[-1deg] w-64 h-auto rounded gameCover"
|
||||||
:src="useObject(game.mCoverId)"
|
:src="useObject(game.mCoverId)"
|
||||||
|
:alt="game.mName"
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center gap-x-2">
|
<div class="flex items-center gap-x-2">
|
||||||
<AddLibraryButton :gameId="game.id" />
|
<AddLibraryButton :gameId="game.id" />
|
||||||
@ -70,6 +71,7 @@
|
|||||||
>
|
>
|
||||||
<component
|
<component
|
||||||
v-for="platform in platforms"
|
v-for="platform in platforms"
|
||||||
|
:key="platform"
|
||||||
:is="PLATFORM_ICONS[platform]"
|
:is="PLATFORM_ICONS[platform]"
|
||||||
class="text-blue-600 w-6 h-6"
|
class="text-blue-600 w-6 h-6"
|
||||||
/>
|
/>
|
||||||
@ -90,7 +92,8 @@
|
|||||||
class="whitespace-nowrap flex flex-row items-center gap-x-1 px-3 py-4 text-sm text-zinc-400"
|
class="whitespace-nowrap flex flex-row items-center gap-x-1 px-3 py-4 text-sm text-zinc-400"
|
||||||
>
|
>
|
||||||
<StarIcon
|
<StarIcon
|
||||||
v-for="value in ratingArray"
|
v-for="(value, idx) in ratingArray"
|
||||||
|
:key="idx"
|
||||||
:class="[
|
:class="[
|
||||||
value ? 'text-yellow-600' : 'text-zinc-600',
|
value ? 'text-yellow-600' : 'text-zinc-600',
|
||||||
'w-4 h-4',
|
'w-4 h-4',
|
||||||
@ -219,3 +222,19 @@ useHead({
|
|||||||
title: game.mName,
|
title: game.mName,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
h1 {
|
||||||
|
view-transition-name: header;
|
||||||
|
}
|
||||||
|
img.gameCover {
|
||||||
|
view-transition-name: selected-game;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
::view-transition-old(header),
|
||||||
|
::view-transition-new(header) {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -0,0 +1,7 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ObjectHash" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"hash" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ObjectHash_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
@ -122,3 +122,8 @@ model Publisher {
|
|||||||
|
|
||||||
@@unique([metadataSource, metadataId, metadataOriginalQuery], name: "metadataKey")
|
@@unique([metadataSource, metadataId, metadataOriginalQuery], name: "metadataKey")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ObjectHash {
|
||||||
|
id String @id
|
||||||
|
hash String
|
||||||
|
}
|
||||||
|
|||||||
@ -3,9 +3,7 @@ import prisma from "~/server/internal/db/database";
|
|||||||
import objectHandler from "~/server/internal/objects";
|
import objectHandler from "~/server/internal/objects";
|
||||||
|
|
||||||
export default defineEventHandler(async (h3) => {
|
export default defineEventHandler(async (h3) => {
|
||||||
const allowed = await aclManager.allowSystemACL(h3, [
|
const allowed = await aclManager.allowSystemACL(h3, ["game:image:delete"]);
|
||||||
"game:image:delete",
|
|
||||||
]);
|
|
||||||
if (!allowed) throw createError({ statusCode: 403 });
|
if (!allowed) throw createError({ statusCode: 403 });
|
||||||
|
|
||||||
const body = await readBody(h3);
|
const body = await readBody(h3);
|
||||||
@ -37,8 +35,8 @@ export default defineEventHandler(async (h3) => {
|
|||||||
throw createError({ statusCode: 400, statusMessage: "Image not found" });
|
throw createError({ statusCode: 400, statusMessage: "Image not found" });
|
||||||
|
|
||||||
game.mImageLibrary.splice(imageIndex, 1);
|
game.mImageLibrary.splice(imageIndex, 1);
|
||||||
await objectHandler.delete(imageId);
|
await objectHandler.deleteAsSystem(imageId);
|
||||||
|
|
||||||
if (game.mBannerId === imageId) {
|
if (game.mBannerId === imageId) {
|
||||||
game.mBannerId = game.mImageLibrary[0];
|
game.mBannerId = game.mImageLibrary[0];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import * as jdenticon from "jdenticon";
|
|||||||
import objectHandler from "~/server/internal/objects";
|
import objectHandler from "~/server/internal/objects";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { writeNonLiteralDefaultMessage } from "arktype/internal/parser/shift/operator/default.ts";
|
|
||||||
|
|
||||||
const userValidator = type({
|
const userValidator = type({
|
||||||
username: "string >= 5",
|
username: "string >= 5",
|
||||||
@ -64,7 +63,7 @@ export default defineEventHandler(async (h3) => {
|
|||||||
profilePictureId,
|
profilePictureId,
|
||||||
async () => jdenticon.toPng(user.username, 256),
|
async () => jdenticon.toPng(user.username, 256),
|
||||||
{},
|
{},
|
||||||
[`internal:read`, `${userId}:write`]
|
[`internal:read`, `${userId}:read`]
|
||||||
);
|
);
|
||||||
const [linkMec] = await prisma.$transaction([
|
const [linkMec] = await prisma.$transaction([
|
||||||
prisma.linkedAuthMec.create({
|
prisma.linkedAuthMec.create({
|
||||||
|
|||||||
@ -11,6 +11,21 @@ export default defineEventHandler(async (h3) => {
|
|||||||
if (!object)
|
if (!object)
|
||||||
throw createError({ statusCode: 404, statusMessage: "Object not found" });
|
throw createError({ statusCode: 404, statusMessage: "Object not found" });
|
||||||
|
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag
|
||||||
|
const etagRequestValue = h3.headers.get("If-None-Match");
|
||||||
|
const etagActualValue = await objectHandler.fetchHash(id);
|
||||||
|
if (
|
||||||
|
etagRequestValue &&
|
||||||
|
etagActualValue &&
|
||||||
|
etagActualValue === etagRequestValue
|
||||||
|
) {
|
||||||
|
// would compare if etag is valid, but objects should never change
|
||||||
|
setResponseStatus(h3, 304);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: fix undefined etagValue
|
||||||
|
setHeader(h3, "ETag", etagActualValue ?? "");
|
||||||
setHeader(h3, "Content-Type", object.mime);
|
setHeader(h3, "Content-Type", object.mime);
|
||||||
setHeader(
|
setHeader(
|
||||||
h3,
|
h3,
|
||||||
|
|||||||
25
server/api/v1/object/[id]/index.head.ts
Normal file
25
server/api/v1/object/[id]/index.head.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import aclManager from "~/server/internal/acls";
|
||||||
|
import objectHandler from "~/server/internal/objects";
|
||||||
|
|
||||||
|
// this request method is purely used by the browser to check if etag values are still valid
|
||||||
|
export default defineEventHandler(async (h3) => {
|
||||||
|
const id = getRouterParam(h3, "id");
|
||||||
|
if (!id) throw createError({ statusCode: 400, statusMessage: "Invalid ID" });
|
||||||
|
|
||||||
|
const userId = await aclManager.getUserIdACL(h3, ["object:read"]);
|
||||||
|
|
||||||
|
const object = await objectHandler.fetchWithPermissions(id, userId);
|
||||||
|
if (!object)
|
||||||
|
throw createError({ statusCode: 404, statusMessage: "Object not found" });
|
||||||
|
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag
|
||||||
|
const etagRequestValue = h3.headers.get("If-None-Match");
|
||||||
|
const etagActualValue = await objectHandler.fetchHash(id);
|
||||||
|
if (etagRequestValue !== null && etagActualValue === etagRequestValue) {
|
||||||
|
// would compare if etag is valid, but objects should never change
|
||||||
|
setResponseStatus(h3, 304);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
@ -129,7 +129,7 @@ class NewsManager {
|
|||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
if (article.image) {
|
if (article.image) {
|
||||||
return await objectHandler.delete(article.image);
|
return await objectHandler.deleteAsSystem(article.image);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,21 +1,23 @@
|
|||||||
import {
|
import {
|
||||||
Object,
|
|
||||||
ObjectBackend,
|
ObjectBackend,
|
||||||
ObjectMetadata,
|
ObjectMetadata,
|
||||||
ObjectReference,
|
ObjectReference,
|
||||||
Source,
|
Source,
|
||||||
} from "./objectHandler";
|
} from "./objectHandler";
|
||||||
|
|
||||||
import sanitize from "sanitize-filename";
|
import { LRUCache } from "lru-cache";
|
||||||
|
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { Readable, Stream } from "stream";
|
import { Readable, Stream } from "stream";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import prisma from "../db/database";
|
||||||
|
|
||||||
export class FsObjectBackend extends ObjectBackend {
|
export class FsObjectBackend extends ObjectBackend {
|
||||||
private baseObjectPath: string;
|
private baseObjectPath: string;
|
||||||
private baseMetadataPath: string;
|
private baseMetadataPath: string;
|
||||||
|
|
||||||
|
private hashStore = new FsHashStore();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
const basePath = process.env.FS_BACKEND_PATH ?? "./.data/objects";
|
const basePath = process.env.FS_BACKEND_PATH ?? "./.data/objects";
|
||||||
@ -27,14 +29,18 @@ export class FsObjectBackend extends ObjectBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fetch(id: ObjectReference) {
|
async fetch(id: ObjectReference) {
|
||||||
const objectPath = path.join(this.baseObjectPath, sanitize(id));
|
console.log("ID: " + id);
|
||||||
|
const objectPath = path.join(this.baseObjectPath, id);
|
||||||
if (!fs.existsSync(objectPath)) return undefined;
|
if (!fs.existsSync(objectPath)) return undefined;
|
||||||
return fs.createReadStream(objectPath);
|
return fs.createReadStream(objectPath);
|
||||||
}
|
}
|
||||||
async write(id: ObjectReference, source: Source): Promise<boolean> {
|
async write(id: ObjectReference, source: Source): Promise<boolean> {
|
||||||
const objectPath = path.join(this.baseObjectPath, sanitize(id));
|
const objectPath = path.join(this.baseObjectPath, id);
|
||||||
if (!fs.existsSync(objectPath)) return false;
|
if (!fs.existsSync(objectPath)) return false;
|
||||||
|
|
||||||
|
// remove item from cache
|
||||||
|
this.hashStore.delete(id);
|
||||||
|
|
||||||
if (source instanceof Readable) {
|
if (source instanceof Readable) {
|
||||||
const outputStream = fs.createWriteStream(objectPath);
|
const outputStream = fs.createWriteStream(objectPath);
|
||||||
source.pipe(outputStream, { end: true });
|
source.pipe(outputStream, { end: true });
|
||||||
@ -50,9 +56,10 @@ export class FsObjectBackend extends ObjectBackend {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
async startWriteStream(id: ObjectReference) {
|
async startWriteStream(id: ObjectReference) {
|
||||||
const objectPath = path.join(this.baseObjectPath, sanitize(id));
|
const objectPath = path.join(this.baseObjectPath, id);
|
||||||
if (!fs.existsSync(objectPath)) return undefined;
|
if (!fs.existsSync(objectPath)) return undefined;
|
||||||
|
// remove item from cache
|
||||||
|
this.hashStore.delete(id);
|
||||||
return fs.createWriteStream(objectPath);
|
return fs.createWriteStream(objectPath);
|
||||||
}
|
}
|
||||||
async create(
|
async create(
|
||||||
@ -60,11 +67,8 @@ export class FsObjectBackend extends ObjectBackend {
|
|||||||
source: Source,
|
source: Source,
|
||||||
metadata: ObjectMetadata
|
metadata: ObjectMetadata
|
||||||
): Promise<ObjectReference | undefined> {
|
): Promise<ObjectReference | undefined> {
|
||||||
const objectPath = path.join(this.baseObjectPath, sanitize(id));
|
const objectPath = path.join(this.baseObjectPath, id);
|
||||||
const metadataPath = path.join(
|
const metadataPath = path.join(this.baseMetadataPath, `${id}.json`);
|
||||||
this.baseMetadataPath,
|
|
||||||
`${sanitize(id)}.json`
|
|
||||||
);
|
|
||||||
if (fs.existsSync(objectPath) || fs.existsSync(metadataPath))
|
if (fs.existsSync(objectPath) || fs.existsSync(metadataPath))
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
||||||
@ -80,11 +84,8 @@ export class FsObjectBackend extends ObjectBackend {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
async createWithWriteStream(id: string, metadata: ObjectMetadata) {
|
async createWithWriteStream(id: string, metadata: ObjectMetadata) {
|
||||||
const objectPath = path.join(this.baseObjectPath, sanitize(id));
|
const objectPath = path.join(this.baseObjectPath, id);
|
||||||
const metadataPath = path.join(
|
const metadataPath = path.join(this.baseMetadataPath, `${id}.json`);
|
||||||
this.baseMetadataPath,
|
|
||||||
`${sanitize(id)}.json`
|
|
||||||
);
|
|
||||||
if (fs.existsSync(objectPath) || fs.existsSync(metadataPath))
|
if (fs.existsSync(objectPath) || fs.existsSync(metadataPath))
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
||||||
@ -94,21 +95,22 @@ export class FsObjectBackend extends ObjectBackend {
|
|||||||
// Create file so write passes
|
// Create file so write passes
|
||||||
fs.writeFileSync(objectPath, "");
|
fs.writeFileSync(objectPath, "");
|
||||||
|
|
||||||
return this.startWriteStream(id);
|
const stream = await this.startWriteStream(id);
|
||||||
|
if (!stream) throw new Error("Could not create write stream");
|
||||||
|
return stream;
|
||||||
}
|
}
|
||||||
async delete(id: ObjectReference): Promise<boolean> {
|
async delete(id: ObjectReference): Promise<boolean> {
|
||||||
const objectPath = path.join(this.baseObjectPath, sanitize(id));
|
const objectPath = path.join(this.baseObjectPath, id);
|
||||||
if (!fs.existsSync(objectPath)) return true;
|
if (!fs.existsSync(objectPath)) return true;
|
||||||
fs.rmSync(objectPath);
|
fs.rmSync(objectPath);
|
||||||
|
// remove item from cache
|
||||||
|
this.hashStore.delete(id);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
async fetchMetadata(
|
async fetchMetadata(
|
||||||
id: ObjectReference
|
id: ObjectReference
|
||||||
): Promise<ObjectMetadata | undefined> {
|
): Promise<ObjectMetadata | undefined> {
|
||||||
const metadataPath = path.join(
|
const metadataPath = path.join(this.baseMetadataPath, `${id}.json`);
|
||||||
this.baseMetadataPath,
|
|
||||||
`${sanitize(id)}.json`
|
|
||||||
);
|
|
||||||
if (!fs.existsSync(metadataPath)) return undefined;
|
if (!fs.existsSync(metadataPath)) return undefined;
|
||||||
const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
|
const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
|
||||||
return metadata as ObjectMetadata;
|
return metadata as ObjectMetadata;
|
||||||
@ -117,12 +119,102 @@ export class FsObjectBackend extends ObjectBackend {
|
|||||||
id: ObjectReference,
|
id: ObjectReference,
|
||||||
metadata: ObjectMetadata
|
metadata: ObjectMetadata
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const metadataPath = path.join(
|
const metadataPath = path.join(this.baseMetadataPath, `${id}.json`);
|
||||||
this.baseMetadataPath,
|
|
||||||
`${sanitize(id)}.json`
|
|
||||||
);
|
|
||||||
if (!fs.existsSync(metadataPath)) return false;
|
if (!fs.existsSync(metadataPath)) return false;
|
||||||
fs.writeFileSync(metadataPath, JSON.stringify(metadata));
|
fs.writeFileSync(metadataPath, JSON.stringify(metadata));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
async fetchHash(id: ObjectReference): Promise<string | undefined> {
|
||||||
|
const cacheResult = await this.hashStore.get(id);
|
||||||
|
if (cacheResult !== undefined) return cacheResult;
|
||||||
|
|
||||||
|
const obj = await this.fetch(id);
|
||||||
|
if (obj === undefined) return;
|
||||||
|
|
||||||
|
// local variable to point to object
|
||||||
|
const cache = this.hashStore;
|
||||||
|
|
||||||
|
// hash object
|
||||||
|
const hash = createHash("md5");
|
||||||
|
hash.setEncoding("hex");
|
||||||
|
|
||||||
|
// read obj into hash
|
||||||
|
obj.pipe(hash);
|
||||||
|
await new Promise<void>((r) => {
|
||||||
|
obj.on("end", function () {
|
||||||
|
hash.end();
|
||||||
|
cache.save(id, hash.read());
|
||||||
|
r();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.hashStore.get(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FsHashStore {
|
||||||
|
private cache = new LRUCache<string, string>({
|
||||||
|
max: 1000, // number of items
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets hash of object
|
||||||
|
* @param id
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
async get(id: ObjectReference) {
|
||||||
|
const cacheRes = this.cache.get(id);
|
||||||
|
if (cacheRes !== undefined) return cacheRes;
|
||||||
|
|
||||||
|
const objectHash = await prisma.objectHash.findUnique({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
hash: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (objectHash === null) return undefined;
|
||||||
|
this.cache.set(id, objectHash.hash);
|
||||||
|
return objectHash.hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves hash of object
|
||||||
|
* @param id
|
||||||
|
*/
|
||||||
|
async save(id: ObjectReference, hash: string) {
|
||||||
|
await prisma.objectHash.upsert({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id,
|
||||||
|
hash,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
hash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.cache.set(id, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash is no longer valid for whatever reason
|
||||||
|
* @param id
|
||||||
|
*/
|
||||||
|
async delete(id: ObjectReference) {
|
||||||
|
this.cache.delete(id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// need to catch in case the object doesn't exist
|
||||||
|
await prisma.objectHash.delete({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
import { FsObjectBackend } from "./fsBackend";
|
import { FsObjectBackend } from "./fsBackend";
|
||||||
export const objectHandler = new FsObjectBackend();
|
import { ObjectHandler } from "./objectHandler";
|
||||||
export default objectHandler
|
|
||||||
|
export const objectHandler = new ObjectHandler(new FsObjectBackend());
|
||||||
|
export default objectHandler;
|
||||||
|
|||||||
@ -63,6 +63,15 @@ export abstract class ObjectBackend {
|
|||||||
id: ObjectReference,
|
id: ObjectReference,
|
||||||
metadata: ObjectMetadata
|
metadata: ObjectMetadata
|
||||||
): Promise<boolean>;
|
): Promise<boolean>;
|
||||||
|
abstract fetchHash(id: ObjectReference): Promise<string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ObjectHandler {
|
||||||
|
private backend: ObjectBackend;
|
||||||
|
|
||||||
|
constructor(backend: ObjectBackend) {
|
||||||
|
this.backend = backend;
|
||||||
|
}
|
||||||
|
|
||||||
private async fetchMimeType(source: Source) {
|
private async fetchMimeType(source: Source) {
|
||||||
if (source instanceof ReadableStream) {
|
if (source instanceof ReadableStream) {
|
||||||
@ -92,7 +101,7 @@ export abstract class ObjectBackend {
|
|||||||
if (!mime)
|
if (!mime)
|
||||||
throw new Error("Unable to calculate MIME type - is the source empty?");
|
throw new Error("Unable to calculate MIME type - is the source empty?");
|
||||||
|
|
||||||
await this.create(id, source, {
|
await this.backend.create(id, source, {
|
||||||
permissions,
|
permissions,
|
||||||
userMetadata: metadata,
|
userMetadata: metadata,
|
||||||
mime,
|
mime,
|
||||||
@ -104,33 +113,54 @@ export abstract class ObjectBackend {
|
|||||||
metadata: { [key: string]: string },
|
metadata: { [key: string]: string },
|
||||||
permissions: Array<string>
|
permissions: Array<string>
|
||||||
) {
|
) {
|
||||||
return this.createWithWriteStream(id, {
|
return this.backend.createWithWriteStream(id, {
|
||||||
permissions,
|
permissions,
|
||||||
userMetadata: metadata,
|
userMetadata: metadata,
|
||||||
mime: "application/octet-stream",
|
mime: "application/octet-stream",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchWithPermissions(id: ObjectReference, userId?: string) {
|
// We only need one permission, so find instead of filter is faster
|
||||||
const metadata = await this.fetchMetadata(id);
|
private hasAnyPermissions(permissions: string[], userId?: string) {
|
||||||
if (!metadata) return;
|
return !!permissions.find((e) => {
|
||||||
|
|
||||||
// We only need one permission, so find instead of filter is faster
|
|
||||||
const myPermissions = metadata.permissions.find((e) => {
|
|
||||||
if (userId !== undefined && e.startsWith(userId)) return true;
|
if (userId !== undefined && e.startsWith(userId)) return true;
|
||||||
if (userId !== undefined && e.startsWith("internal")) return true;
|
if (userId !== undefined && e.startsWith("internal")) return true;
|
||||||
if (e.startsWith("anonymous")) return true;
|
if (e.startsWith("anonymous")) return true;
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!myPermissions) {
|
private fetchPermissions(permissions: string[], userId?: string) {
|
||||||
// We do not have access to this object
|
return (
|
||||||
return;
|
permissions
|
||||||
}
|
.filter((e) => {
|
||||||
|
if (userId !== undefined && e.startsWith(userId)) return true;
|
||||||
|
if (userId !== undefined && e.startsWith("internal")) return true;
|
||||||
|
if (e.startsWith("anonymous")) return true;
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
// Strip IDs from permissions
|
||||||
|
.map((e) => e.split(":").at(1))
|
||||||
|
// Map to priority according to array
|
||||||
|
.map((e) => ObjectPermissionPriority.findIndex((c) => c === e))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches object, but also checks if user has perms to access it
|
||||||
|
* @param id object id
|
||||||
|
* @param userId user to check, or act as anon user
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
async fetchWithPermissions(id: ObjectReference, userId?: string) {
|
||||||
|
const metadata = await this.backend.fetchMetadata(id);
|
||||||
|
if (!metadata) return;
|
||||||
|
|
||||||
|
if (!this.hasAnyPermissions(metadata.permissions, userId)) return;
|
||||||
|
|
||||||
// Because any permission can be read or up, we automatically know we can read this object
|
// Because any permission can be read or up, we automatically know we can read this object
|
||||||
// So just straight return the object
|
// So just straight return the object
|
||||||
const source = await this.fetch(id);
|
const source = await this.backend.fetch(id);
|
||||||
if (!source) return undefined;
|
if (!source) return undefined;
|
||||||
const object: Object = {
|
const object: Object = {
|
||||||
data: source,
|
data: source,
|
||||||
@ -139,66 +169,78 @@ export abstract class ObjectBackend {
|
|||||||
return object;
|
return object;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we need to fetch a remote resource, it doesn't make sense
|
/**
|
||||||
// to immediately fetch the object, *then* check permissions.
|
* Fetch object hash. Permissions check should be done on read
|
||||||
// Instead the caller can pass a simple anonymous funciton, like
|
* @param id object id
|
||||||
// () => $dropFetch('/my-image');
|
* @returns
|
||||||
// And if we actually have permission to write, it fetches it then.
|
*/
|
||||||
|
async fetchHash(id: ObjectReference) {
|
||||||
|
return await this.backend.fetchHash(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param id object id
|
||||||
|
* @param sourceFetcher callback used to provide image
|
||||||
|
* @param userId user to check, or act as anon user
|
||||||
|
* @returns
|
||||||
|
* @description If we need to fetch a remote resource, it doesn't make sense
|
||||||
|
* to immediately fetch the object, *then* check permissions.
|
||||||
|
* Instead the caller can pass a simple anonymous funciton, like
|
||||||
|
* () => $dropFetch('/my-image');
|
||||||
|
* And if we actually have permission to write, it fetches it then.
|
||||||
|
*/
|
||||||
async writeWithPermissions(
|
async writeWithPermissions(
|
||||||
id: ObjectReference,
|
id: ObjectReference,
|
||||||
sourceFetcher: () => Promise<Source>,
|
sourceFetcher: () => Promise<Source>,
|
||||||
userId?: string
|
userId?: string
|
||||||
) {
|
) {
|
||||||
const metadata = await this.fetchMetadata(id);
|
const metadata = await this.backend.fetchMetadata(id);
|
||||||
if (!metadata) return false;
|
if (!metadata) return false;
|
||||||
|
|
||||||
const myPermissions = metadata.permissions
|
const permissions = this.fetchPermissions(metadata.permissions, userId);
|
||||||
.filter((e) => {
|
|
||||||
if (userId !== undefined && e.startsWith(userId)) return true;
|
|
||||||
if (userId !== undefined && e.startsWith("internal")) return true;
|
|
||||||
if (e.startsWith("anonymous")) return true;
|
|
||||||
return false;
|
|
||||||
})
|
|
||||||
// Strip IDs from permissions
|
|
||||||
.map((e) => e.split(":").at(1))
|
|
||||||
// Map to priority according to array
|
|
||||||
.map((e) => ObjectPermissionPriority.findIndex((c) => c === e));
|
|
||||||
|
|
||||||
const requiredPermissionIndex = 1;
|
const requiredPermissionIndex = 1;
|
||||||
const hasPermission =
|
const hasPermission =
|
||||||
myPermissions.find((e) => e >= requiredPermissionIndex) != undefined;
|
permissions.find((e) => e >= requiredPermissionIndex) != undefined;
|
||||||
|
|
||||||
if (!hasPermission) return false;
|
if (!hasPermission) return false;
|
||||||
|
|
||||||
const source = await sourceFetcher();
|
const source = await sourceFetcher();
|
||||||
const result = await this.write(id, source);
|
// TODO: prevent user from overwriting existing object
|
||||||
|
const result = await this.backend.write(id, source);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param id object id
|
||||||
|
* @param userId user to check, or act as anon user
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
async deleteWithPermission(id: ObjectReference, userId?: string) {
|
async deleteWithPermission(id: ObjectReference, userId?: string) {
|
||||||
const metadata = await this.fetchMetadata(id);
|
const metadata = await this.backend.fetchMetadata(id);
|
||||||
if (!metadata) return false;
|
if (!metadata) return false;
|
||||||
|
|
||||||
const myPermissions = metadata.permissions
|
const permissions = this.fetchPermissions(metadata.permissions, userId);
|
||||||
.filter((e) => {
|
|
||||||
if (userId !== undefined && e.startsWith(userId)) return true;
|
|
||||||
if (userId !== undefined && e.startsWith("internal")) return true;
|
|
||||||
if (e.startsWith("anonymous")) return true;
|
|
||||||
return false;
|
|
||||||
})
|
|
||||||
// Strip IDs from permissions
|
|
||||||
.map((e) => e.split(":").at(1))
|
|
||||||
// Map to priority according to array
|
|
||||||
.map((e) => ObjectPermissionPriority.findIndex((c) => c === e));
|
|
||||||
|
|
||||||
const requiredPermissionIndex = 2;
|
const requiredPermissionIndex = 2;
|
||||||
const hasPermission =
|
const hasPermission =
|
||||||
myPermissions.find((e) => e >= requiredPermissionIndex) != undefined;
|
permissions.find((e) => e >= requiredPermissionIndex) != undefined;
|
||||||
|
|
||||||
if (!hasPermission) return false;
|
if (!hasPermission) return false;
|
||||||
|
|
||||||
const result = await this.delete(id);
|
const result = await this.backend.delete(id);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes object without checking permission
|
||||||
|
* @param id
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
async deleteAsSystem(id: ObjectReference) {
|
||||||
|
return await this.backend.delete(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,7 @@ class SaveManager {
|
|||||||
index: number,
|
index: number,
|
||||||
objectId: string
|
objectId: string
|
||||||
) {
|
) {
|
||||||
await objectHandler.delete(objectId);
|
await objectHandler.deleteWithPermission(objectId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async pushSave(
|
async pushSave(
|
||||||
@ -62,7 +62,7 @@ class SaveManager {
|
|||||||
await Promise.all([hashPromise, uploadStream]);
|
await Promise.all([hashPromise, uploadStream]);
|
||||||
|
|
||||||
if (!hash) {
|
if (!hash) {
|
||||||
await objectHandler.delete(newSaveObjectId);
|
await objectHandler.deleteAsSystem(newSaveObjectId);
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
statusMessage: "Hash failed to generate",
|
statusMessage: "Hash failed to generate",
|
||||||
|
|||||||
@ -1,7 +1,4 @@
|
|||||||
{
|
{
|
||||||
// https://nuxt.com/docs/guide/concepts/typescript
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
"extends": "./.nuxt/tsconfig.json",
|
"extends": "./.nuxt/tsconfig.json"
|
||||||
"compilerOptions": {
|
|
||||||
"exactOptionalPropertyTypes": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user