mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-10 04:22:09 +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">
|
||||
await updateUser();
|
||||
</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">
|
||||
<button
|
||||
v-for="(_, i) in amount"
|
||||
:key="i"
|
||||
@click="() => slideTo(i)"
|
||||
:class="[
|
||||
carousel.currentSlide == i ? 'bg-blue-600 w-6' : 'bg-zinc-700 w-3',
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div ref="currentComponent">
|
||||
<ClientOnly>
|
||||
<ClientOnly fallback-tag="span">
|
||||
<VueCarousel :itemsToShow="singlePage" :itemsToScroll="singlePage">
|
||||
<VueSlide
|
||||
class="justify-start"
|
||||
@ -14,6 +14,18 @@
|
||||
<VueNavigation />
|
||||
</template>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
@ -37,13 +49,22 @@ const games: Ref<Array<SerializeObject<Game> | undefined>> = computed(() =>
|
||||
.map((_, i) => props.items[i])
|
||||
);
|
||||
|
||||
const singlePage = ref(1);
|
||||
const singlePage = ref(2);
|
||||
const sizeOfCard = 192 + 10;
|
||||
|
||||
onMounted(() => {
|
||||
const handleResize = () => {
|
||||
singlePage.value =
|
||||
(props.width ??
|
||||
currentComponent.value?.parentElement?.clientWidth ??
|
||||
window.innerWidth) / sizeOfCard;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -3,45 +3,59 @@
|
||||
v-if="game"
|
||||
: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"
|
||||
@click.native="active = game.id"
|
||||
>
|
||||
<div class="absolute inset-0 transition-all duration-300 group-hover:scale-110">
|
||||
<img
|
||||
:src="useObject(game.mCoverId)"
|
||||
class="w-full h-full object-cover brightness-[90%]"
|
||||
<div
|
||||
class="absolute inset-0 transition-all duration-300 group-hover:scale-110"
|
||||
>
|
||||
<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 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">
|
||||
<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"
|
||||
>
|
||||
{{ game.mName }}
|
||||
</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 }}
|
||||
</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<div
|
||||
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>
|
||||
<SkeletonCard v-else message="no game" />>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SerializeObject } from "nitropack";
|
||||
|
||||
const props = defineProps<{
|
||||
game: SerializeObject<{
|
||||
id: string;
|
||||
mCoverId: string;
|
||||
mName: string;
|
||||
mShortDescription: string;
|
||||
}> | undefined;
|
||||
game:
|
||||
| SerializeObject<{
|
||||
id: string;
|
||||
mCoverId: string;
|
||||
mName: string;
|
||||
mShortDescription: string;
|
||||
}>
|
||||
| undefined;
|
||||
href?: string;
|
||||
}>();
|
||||
|
||||
const active = useState();
|
||||
</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) =>
|
||||
e.split('|')
|
||||
)"
|
||||
:key="name"
|
||||
type="button"
|
||||
: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"
|
||||
|
||||
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>
|
||||
<footer class="bg-zinc-950" aria-labelledby="footer-heading">
|
||||
<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="xl:grid xl:grid-cols-3 xl:gap-8">
|
||||
<div class="space-y-8">
|
||||
<Wordmark class="h-10" />
|
||||
<p class="text-sm leading-6 text-zinc-300">An open-source game distribution platform built for
|
||||
speed, flexibility and beauty.</p>
|
||||
<div class="flex space-x-6">
|
||||
<a v-for="item in navigation.social" :key="item.name" :href="item.href" target="_blank"
|
||||
class="text-zinc-400 hover:text-zinc-400">
|
||||
<span class="sr-only">{{ item.name }}</span>
|
||||
<component :is="item.icon" class="h-6 w-6" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
<footer class="bg-zinc-950" aria-labelledby="footer-heading">
|
||||
<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="xl:grid xl:grid-cols-3 xl:gap-8">
|
||||
<div class="space-y-8">
|
||||
<Wordmark class="h-10" />
|
||||
<p class="text-sm leading-6 text-zinc-300">
|
||||
An open-source game distribution platform built for speed,
|
||||
flexibility and beauty.
|
||||
</p>
|
||||
<div class="flex space-x-6">
|
||||
<NuxtLink
|
||||
v-for="item in navigation.social"
|
||||
:key="item.name"
|
||||
:to="item.href"
|
||||
target="_blank"
|
||||
class="text-zinc-400 hover:text-zinc-400"
|
||||
>
|
||||
<span class="sr-only">{{ item.name }}</span>
|
||||
<component :is="item.icon" class="h-6 w-6" aria-hidden="true" />
|
||||
</NuxtLink>
|
||||
</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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { IconsDiscordLogo, IconsGithubLogo } from '#components';
|
||||
import { IconsDiscordLogo, IconsGithubLogo } from "#components";
|
||||
|
||||
const navigation = {
|
||||
games: [
|
||||
{ name: 'Newly Added', href: '#' },
|
||||
{ name: 'New Releases', href: '#' },
|
||||
{ name: 'Top Sellers', href: '#' },
|
||||
{ name: 'Find a Game', href: '#' },
|
||||
],
|
||||
community: [
|
||||
{ name: 'Friends', href: '#' },
|
||||
{ name: 'Groups', href: '#' },
|
||||
{ name: 'Servers', href: '#' },
|
||||
],
|
||||
documentation: [
|
||||
{ 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' },
|
||||
],
|
||||
about: [
|
||||
{ name: 'About Drop', href: 'https://droposs.org/' },
|
||||
{ name: 'Features', href: 'https://droposs.org/features' },
|
||||
{ name: 'FAQ', href: 'https://droposs.org/faq' },
|
||||
],
|
||||
social: [
|
||||
{
|
||||
name: 'GitHub',
|
||||
href: 'https://github.com/Drop-OSS',
|
||||
icon: IconsGithubLogo,
|
||||
},
|
||||
{
|
||||
name: "Discord",
|
||||
href: "https://discord.gg/NHx46XKJWA",
|
||||
icon: IconsDiscordLogo
|
||||
}
|
||||
],
|
||||
}
|
||||
</script>
|
||||
games: [
|
||||
{ name: "Newly Added", href: "#" },
|
||||
{ name: "New Releases", href: "#" },
|
||||
{ name: "Top Sellers", href: "#" },
|
||||
{ name: "Find a Game", href: "#" },
|
||||
],
|
||||
community: [
|
||||
{ name: "Friends", href: "#" },
|
||||
{ name: "Groups", href: "#" },
|
||||
{ name: "Servers", href: "#" },
|
||||
],
|
||||
documentation: [
|
||||
{ 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",
|
||||
},
|
||||
],
|
||||
about: [
|
||||
{ name: "About Drop", href: "https://droposs.org/" },
|
||||
{ name: "Features", href: "https://droposs.org/features" },
|
||||
{ name: "FAQ", href: "https://droposs.org/faq" },
|
||||
],
|
||||
social: [
|
||||
{
|
||||
name: "GitHub",
|
||||
href: "https://github.com/Drop-OSS",
|
||||
icon: IconsGithubLogo,
|
||||
},
|
||||
{
|
||||
name: "Discord",
|
||||
href: "https://discord.gg/NHx46XKJWA",
|
||||
icon: IconsDiscordLogo,
|
||||
},
|
||||
],
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
<template>
|
||||
<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">
|
||||
<NuxtLink to="/">
|
||||
<NuxtLink to="/store">
|
||||
<Wordmark class="h-8" />
|
||||
</NuxtLink>
|
||||
<nav class="inline-flex items-center">
|
||||
<ol class="inline-flex items-center gap-x-12">
|
||||
<NuxtLink
|
||||
v-for="(nav, navIdx) in navigation"
|
||||
:key="navIdx"
|
||||
:href="nav.route"
|
||||
:class="[
|
||||
'transition hover:text-zinc-200 uppercase font-display font-semibold text-md',
|
||||
@ -141,6 +142,7 @@
|
||||
<ol class="flex flex-col gap-y-3">
|
||||
<NuxtLink
|
||||
v-for="(nav, navIdx) in navigation"
|
||||
:key="navIdx"
|
||||
:href="nav.route"
|
||||
:class="[
|
||||
'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">
|
||||
<Notification
|
||||
v-for="notification in props.notifications"
|
||||
:key="notification.id"
|
||||
:notification="notification"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -41,6 +41,7 @@
|
||||
<div class="flex flex-col">
|
||||
<MenuItem
|
||||
v-for="(nav, navIdx) in navigation"
|
||||
:key="navIdx"
|
||||
v-slot="{ active, close }"
|
||||
hydrate-on-visible
|
||||
>
|
||||
|
||||
12
error.vue
12
error.vue
@ -63,11 +63,11 @@ if (import.meta.client) {
|
||||
</p>
|
||||
<div class="mt-10">
|
||||
<!-- full app reload to fix errors -->
|
||||
<a
|
||||
<NuxtLink
|
||||
v-if="user && !showSignIn"
|
||||
href="/"
|
||||
to="/"
|
||||
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
|
||||
v-else
|
||||
@ -92,9 +92,9 @@ if (import.meta.client) {
|
||||
>
|
||||
<circle cx="1" cy="1" r="1" />
|
||||
</svg>
|
||||
<a href="https://discord.gg/NHx46XKJWA" target="_blank"
|
||||
>Support Discord</a
|
||||
>
|
||||
<NuxtLink to="https://discord.gg/NHx46XKJWA" target="_blank">
|
||||
Support Discord
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@ -223,6 +223,16 @@ router.afterEach(() => {
|
||||
});
|
||||
|
||||
useHead({
|
||||
htmlAttrs: {
|
||||
lang: "en",
|
||||
},
|
||||
link: [
|
||||
{
|
||||
rel: "icon",
|
||||
type: "image/png",
|
||||
href: "/favicon.png",
|
||||
},
|
||||
],
|
||||
titleTemplate(title) {
|
||||
return title ? `${title} | Admin | Drop` : `Admin Dashboard | Drop`;
|
||||
},
|
||||
|
||||
@ -16,6 +16,16 @@ const route = useRoute();
|
||||
const noWrapper = !!route.query.noWrapper;
|
||||
|
||||
useHead({
|
||||
htmlAttrs: {
|
||||
lang: "en",
|
||||
},
|
||||
link: [
|
||||
{
|
||||
rel: "icon",
|
||||
type: "image/png",
|
||||
href: "/favicon.png",
|
||||
},
|
||||
],
|
||||
titleTemplate(title) {
|
||||
if (title) return `${title} | Drop`;
|
||||
return `Drop`;
|
||||
|
||||
@ -6,11 +6,19 @@ export default defineNuxtConfig({
|
||||
// Nuxt-only config
|
||||
telemetry: false,
|
||||
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"],
|
||||
|
||||
experimental: {
|
||||
buildCache: true,
|
||||
viewTransition: true,
|
||||
},
|
||||
|
||||
vite: {
|
||||
@ -48,6 +56,8 @@ export default defineNuxtConfig({
|
||||
tsConfig: {
|
||||
compilerOptions: {
|
||||
verbatimModuleSyntax: false,
|
||||
strictNullChecks: true,
|
||||
exactOptionalPropertyTypes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -78,6 +88,8 @@ export default defineNuxtConfig({
|
||||
"https://images.pcgamingwiki.com",
|
||||
"https://images.igdb.com",
|
||||
],
|
||||
|
||||
"script-src": ["'nonce-{{nonce}}'"],
|
||||
},
|
||||
strictTransportSecurity: false,
|
||||
},
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "prisma generate && nuxt prepare",
|
||||
"postinstall": "prisma generate && nuxt prepare && node build/fix-prisma.js",
|
||||
"typecheck": "nuxt typecheck"
|
||||
},
|
||||
"dependencies": {
|
||||
@ -17,7 +17,6 @@
|
||||
"@heroicons/vue": "^2.1.5",
|
||||
"@nuxt/fonts": "^0.11.0",
|
||||
"@nuxt/image": "^1.10.0",
|
||||
"@nuxtjs/tailwindcss": "^6.12.2",
|
||||
"@prisma/client": "^6.5.0",
|
||||
"@tailwindcss/vite": "^4.0.6",
|
||||
"argon2": "^0.41.1",
|
||||
@ -31,12 +30,10 @@
|
||||
"lru-cache": "^11.1.0",
|
||||
"luxon": "^3.6.1",
|
||||
"micromark": "^4.0.1",
|
||||
"nuxt": "3.15.4",
|
||||
"nuxt": "^3.16.2",
|
||||
"nuxt-security": "2.2.0",
|
||||
"prisma": "^6.5.0",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"sharp": "^0.33.5",
|
||||
"stream": "^0.0.3",
|
||||
"stream-mime-type": "^2.0.0",
|
||||
"turndown": "^7.2.0",
|
||||
"vue": "latest",
|
||||
@ -48,7 +45,7 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/luxon": "^3.6.2",
|
||||
"@types/node": "^22.13.16",
|
||||
"@types/turndown": "^5.0.5",
|
||||
|
||||
@ -62,6 +62,7 @@
|
||||
<li
|
||||
class="inline-flex items-center gap-x-0.5"
|
||||
v-for="capability in client.capabilities"
|
||||
:key="capability"
|
||||
>
|
||||
<CheckIcon class="size-4" /> {{ capability }}
|
||||
</li>
|
||||
|
||||
@ -50,29 +50,32 @@
|
||||
</div>
|
||||
|
||||
<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 v-else role="status" class="w-full h-screen flex items-center justify-center">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="size-8 text-transparent animate-spin fill-white"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
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="currentColor"
|
||||
/>
|
||||
<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"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
role="status"
|
||||
class="w-full h-screen flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="size-8 text-transparent animate-spin fill-white"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
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="currentColor"
|
||||
/>
|
||||
<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"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@ -80,6 +80,7 @@
|
||||
<div v-if="authMech.settings">
|
||||
<div
|
||||
v-for="[key, value] in Object.entries(authMech.settings)"
|
||||
:key="key"
|
||||
class="flex justify-between gap-x-4 py-2"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<!--
|
||||
<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"
|
||||
>, {{ user.displayName }}</span
|
||||
></a
|
||||
></NuxtLink
|
||||
>
|
||||
-->
|
||||
</td>
|
||||
|
||||
@ -175,11 +175,11 @@
|
||||
<p v-if="false" class="mt-10 text-center text-sm text-zinc-400">
|
||||
What's Drop?
|
||||
{{ " " }}
|
||||
<a
|
||||
href="https://github.com/Drop-OSS/drop"
|
||||
<NuxtLink
|
||||
to="https://github.com/Drop-OSS/drop"
|
||||
target="_blank"
|
||||
class="font-semibold leading-6 text-blue-600 hover:text-blue-500"
|
||||
>Check us out here →</a
|
||||
>Check us out here →</NuxtLink
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -74,10 +74,10 @@
|
||||
</div>
|
||||
|
||||
<div class="text-sm leading-6">
|
||||
<a
|
||||
href="#"
|
||||
<NuxtLink
|
||||
to="#"
|
||||
class="font-semibold text-blue-600 hover:text-blue-500"
|
||||
>Forgot password?</a
|
||||
>Forgot password?</NuxtLink
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -24,6 +24,7 @@
|
||||
>
|
||||
<GamePanel
|
||||
v-for="entry in collection?.entries"
|
||||
:key="entry.gameId"
|
||||
:game="entry.game"
|
||||
:href="`/library/game/${entry.game.id}`"
|
||||
/>
|
||||
|
||||
@ -72,12 +72,11 @@
|
||||
|
||||
<!-- game library grid -->
|
||||
<div>
|
||||
<h1 class="text-zinc-100 text-xl font-bold font-display">
|
||||
All Games
|
||||
</h1>
|
||||
<h1 class="text-zinc-100 text-xl font-bold font-display">All Games</h1>
|
||||
<div class="mt-4 flex flex-row flex-wrap justify-left gap-4">
|
||||
<GamePanel
|
||||
v-for="game in games"
|
||||
:key="game.id"
|
||||
:game="game"
|
||||
: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"
|
||||
>
|
||||
<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)"
|
||||
:alt="game.mName"
|
||||
/>
|
||||
<div class="flex items-center gap-x-2">
|
||||
<AddLibraryButton :gameId="game.id" />
|
||||
@ -70,6 +71,7 @@
|
||||
>
|
||||
<component
|
||||
v-for="platform in platforms"
|
||||
:key="platform"
|
||||
:is="PLATFORM_ICONS[platform]"
|
||||
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"
|
||||
>
|
||||
<StarIcon
|
||||
v-for="value in ratingArray"
|
||||
v-for="(value, idx) in ratingArray"
|
||||
:key="idx"
|
||||
:class="[
|
||||
value ? 'text-yellow-600' : 'text-zinc-600',
|
||||
'w-4 h-4',
|
||||
@ -219,3 +222,19 @@ useHead({
|
||||
title: game.mName,
|
||||
});
|
||||
</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")
|
||||
}
|
||||
|
||||
model ObjectHash {
|
||||
id String @id
|
||||
hash String
|
||||
}
|
||||
|
||||
@ -3,9 +3,7 @@ import prisma from "~/server/internal/db/database";
|
||||
import objectHandler from "~/server/internal/objects";
|
||||
|
||||
export default defineEventHandler(async (h3) => {
|
||||
const allowed = await aclManager.allowSystemACL(h3, [
|
||||
"game:image:delete",
|
||||
]);
|
||||
const allowed = await aclManager.allowSystemACL(h3, ["game:image:delete"]);
|
||||
if (!allowed) throw createError({ statusCode: 403 });
|
||||
|
||||
const body = await readBody(h3);
|
||||
@ -37,8 +35,8 @@ export default defineEventHandler(async (h3) => {
|
||||
throw createError({ statusCode: 400, statusMessage: "Image not found" });
|
||||
|
||||
game.mImageLibrary.splice(imageIndex, 1);
|
||||
await objectHandler.delete(imageId);
|
||||
|
||||
await objectHandler.deleteAsSystem(imageId);
|
||||
|
||||
if (game.mBannerId === imageId) {
|
||||
game.mBannerId = game.mImageLibrary[0];
|
||||
}
|
||||
|
||||
@ -5,7 +5,6 @@ import * as jdenticon from "jdenticon";
|
||||
import objectHandler from "~/server/internal/objects";
|
||||
import { type } from "arktype";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { writeNonLiteralDefaultMessage } from "arktype/internal/parser/shift/operator/default.ts";
|
||||
|
||||
const userValidator = type({
|
||||
username: "string >= 5",
|
||||
@ -64,7 +63,7 @@ export default defineEventHandler(async (h3) => {
|
||||
profilePictureId,
|
||||
async () => jdenticon.toPng(user.username, 256),
|
||||
{},
|
||||
[`internal:read`, `${userId}:write`]
|
||||
[`internal:read`, `${userId}:read`]
|
||||
);
|
||||
const [linkMec] = await prisma.$transaction([
|
||||
prisma.linkedAuthMec.create({
|
||||
|
||||
@ -11,6 +11,21 @@ export default defineEventHandler(async (h3) => {
|
||||
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 &&
|
||||
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,
|
||||
|
||||
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 },
|
||||
});
|
||||
if (article.image) {
|
||||
return await objectHandler.delete(article.image);
|
||||
return await objectHandler.deleteAsSystem(article.image);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -1,21 +1,23 @@
|
||||
import {
|
||||
Object,
|
||||
ObjectBackend,
|
||||
ObjectMetadata,
|
||||
ObjectReference,
|
||||
Source,
|
||||
} from "./objectHandler";
|
||||
|
||||
import sanitize from "sanitize-filename";
|
||||
|
||||
import { LRUCache } from "lru-cache";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { Readable, Stream } from "stream";
|
||||
import { createHash } from "crypto";
|
||||
import prisma from "../db/database";
|
||||
|
||||
export class FsObjectBackend extends ObjectBackend {
|
||||
private baseObjectPath: string;
|
||||
private baseMetadataPath: string;
|
||||
|
||||
private hashStore = new FsHashStore();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const basePath = process.env.FS_BACKEND_PATH ?? "./.data/objects";
|
||||
@ -27,14 +29,18 @@ export class FsObjectBackend extends ObjectBackend {
|
||||
}
|
||||
|
||||
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;
|
||||
return fs.createReadStream(objectPath);
|
||||
}
|
||||
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;
|
||||
|
||||
// remove item from cache
|
||||
this.hashStore.delete(id);
|
||||
|
||||
if (source instanceof Readable) {
|
||||
const outputStream = fs.createWriteStream(objectPath);
|
||||
source.pipe(outputStream, { end: true });
|
||||
@ -50,9 +56,10 @@ export class FsObjectBackend extends ObjectBackend {
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
|
||||
// remove item from cache
|
||||
this.hashStore.delete(id);
|
||||
return fs.createWriteStream(objectPath);
|
||||
}
|
||||
async create(
|
||||
@ -60,11 +67,8 @@ export class FsObjectBackend extends ObjectBackend {
|
||||
source: Source,
|
||||
metadata: ObjectMetadata
|
||||
): Promise<ObjectReference | undefined> {
|
||||
const objectPath = path.join(this.baseObjectPath, sanitize(id));
|
||||
const metadataPath = path.join(
|
||||
this.baseMetadataPath,
|
||||
`${sanitize(id)}.json`
|
||||
);
|
||||
const objectPath = path.join(this.baseObjectPath, id);
|
||||
const metadataPath = path.join(this.baseMetadataPath, `${id}.json`);
|
||||
if (fs.existsSync(objectPath) || fs.existsSync(metadataPath))
|
||||
return undefined;
|
||||
|
||||
@ -80,11 +84,8 @@ export class FsObjectBackend extends ObjectBackend {
|
||||
return id;
|
||||
}
|
||||
async createWithWriteStream(id: string, metadata: ObjectMetadata) {
|
||||
const objectPath = path.join(this.baseObjectPath, sanitize(id));
|
||||
const metadataPath = path.join(
|
||||
this.baseMetadataPath,
|
||||
`${sanitize(id)}.json`
|
||||
);
|
||||
const objectPath = path.join(this.baseObjectPath, id);
|
||||
const metadataPath = path.join(this.baseMetadataPath, `${id}.json`);
|
||||
if (fs.existsSync(objectPath) || fs.existsSync(metadataPath))
|
||||
return undefined;
|
||||
|
||||
@ -94,21 +95,22 @@ export class FsObjectBackend extends ObjectBackend {
|
||||
// Create file so write passes
|
||||
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> {
|
||||
const objectPath = path.join(this.baseObjectPath, sanitize(id));
|
||||
const objectPath = path.join(this.baseObjectPath, id);
|
||||
if (!fs.existsSync(objectPath)) return true;
|
||||
fs.rmSync(objectPath);
|
||||
// remove item from cache
|
||||
this.hashStore.delete(id);
|
||||
return true;
|
||||
}
|
||||
async fetchMetadata(
|
||||
id: ObjectReference
|
||||
): Promise<ObjectMetadata | undefined> {
|
||||
const metadataPath = path.join(
|
||||
this.baseMetadataPath,
|
||||
`${sanitize(id)}.json`
|
||||
);
|
||||
const metadataPath = path.join(this.baseMetadataPath, `${id}.json`);
|
||||
if (!fs.existsSync(metadataPath)) return undefined;
|
||||
const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
|
||||
return metadata as ObjectMetadata;
|
||||
@ -117,12 +119,102 @@ export class FsObjectBackend extends ObjectBackend {
|
||||
id: ObjectReference,
|
||||
metadata: ObjectMetadata
|
||||
): Promise<boolean> {
|
||||
const metadataPath = path.join(
|
||||
this.baseMetadataPath,
|
||||
`${sanitize(id)}.json`
|
||||
);
|
||||
const metadataPath = path.join(this.baseMetadataPath, `${id}.json`);
|
||||
if (!fs.existsSync(metadataPath)) return false;
|
||||
fs.writeFileSync(metadataPath, JSON.stringify(metadata));
|
||||
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";
|
||||
export const objectHandler = new FsObjectBackend();
|
||||
export default objectHandler
|
||||
import { ObjectHandler } from "./objectHandler";
|
||||
|
||||
export const objectHandler = new ObjectHandler(new FsObjectBackend());
|
||||
export default objectHandler;
|
||||
|
||||
@ -63,6 +63,15 @@ export abstract class ObjectBackend {
|
||||
id: ObjectReference,
|
||||
metadata: ObjectMetadata
|
||||
): 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) {
|
||||
if (source instanceof ReadableStream) {
|
||||
@ -92,7 +101,7 @@ export abstract class ObjectBackend {
|
||||
if (!mime)
|
||||
throw new Error("Unable to calculate MIME type - is the source empty?");
|
||||
|
||||
await this.create(id, source, {
|
||||
await this.backend.create(id, source, {
|
||||
permissions,
|
||||
userMetadata: metadata,
|
||||
mime,
|
||||
@ -104,33 +113,54 @@ export abstract class ObjectBackend {
|
||||
metadata: { [key: string]: string },
|
||||
permissions: Array<string>
|
||||
) {
|
||||
return this.createWithWriteStream(id, {
|
||||
return this.backend.createWithWriteStream(id, {
|
||||
permissions,
|
||||
userMetadata: metadata,
|
||||
mime: "application/octet-stream",
|
||||
});
|
||||
}
|
||||
|
||||
async fetchWithPermissions(id: ObjectReference, userId?: string) {
|
||||
const metadata = await this.fetchMetadata(id);
|
||||
if (!metadata) return;
|
||||
|
||||
// We only need one permission, so find instead of filter is faster
|
||||
const myPermissions = metadata.permissions.find((e) => {
|
||||
// We only need one permission, so find instead of filter is faster
|
||||
private hasAnyPermissions(permissions: string[], userId?: string) {
|
||||
return !!permissions.find((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;
|
||||
});
|
||||
}
|
||||
|
||||
if (!myPermissions) {
|
||||
// We do not have access to this object
|
||||
return;
|
||||
}
|
||||
private fetchPermissions(permissions: string[], userId?: string) {
|
||||
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
|
||||
// So just straight return the object
|
||||
const source = await this.fetch(id);
|
||||
const source = await this.backend.fetch(id);
|
||||
if (!source) return undefined;
|
||||
const object: Object = {
|
||||
data: source,
|
||||
@ -139,66 +169,78 @@ export abstract class ObjectBackend {
|
||||
return object;
|
||||
}
|
||||
|
||||
// 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.
|
||||
/**
|
||||
* Fetch object hash. Permissions check should be done on read
|
||||
* @param id object id
|
||||
* @returns
|
||||
*/
|
||||
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(
|
||||
id: ObjectReference,
|
||||
sourceFetcher: () => Promise<Source>,
|
||||
userId?: string
|
||||
) {
|
||||
const metadata = await this.fetchMetadata(id);
|
||||
const metadata = await this.backend.fetchMetadata(id);
|
||||
if (!metadata) return false;
|
||||
|
||||
const myPermissions = metadata.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));
|
||||
const permissions = this.fetchPermissions(metadata.permissions, userId);
|
||||
|
||||
const requiredPermissionIndex = 1;
|
||||
const hasPermission =
|
||||
myPermissions.find((e) => e >= requiredPermissionIndex) != undefined;
|
||||
permissions.find((e) => e >= requiredPermissionIndex) != undefined;
|
||||
|
||||
if (!hasPermission) return false;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param id object id
|
||||
* @param userId user to check, or act as anon user
|
||||
* @returns
|
||||
*/
|
||||
async deleteWithPermission(id: ObjectReference, userId?: string) {
|
||||
const metadata = await this.fetchMetadata(id);
|
||||
const metadata = await this.backend.fetchMetadata(id);
|
||||
if (!metadata) return false;
|
||||
|
||||
const myPermissions = metadata.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));
|
||||
const permissions = this.fetchPermissions(metadata.permissions, userId);
|
||||
|
||||
const requiredPermissionIndex = 2;
|
||||
const hasPermission =
|
||||
myPermissions.find((e) => e >= requiredPermissionIndex) != undefined;
|
||||
permissions.find((e) => e >= requiredPermissionIndex) != undefined;
|
||||
|
||||
if (!hasPermission) return false;
|
||||
|
||||
const result = await this.delete(id);
|
||||
const result = await this.backend.delete(id);
|
||||
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,
|
||||
objectId: string
|
||||
) {
|
||||
await objectHandler.delete(objectId);
|
||||
await objectHandler.deleteWithPermission(objectId, userId);
|
||||
}
|
||||
|
||||
async pushSave(
|
||||
@ -62,7 +62,7 @@ class SaveManager {
|
||||
await Promise.all([hashPromise, uploadStream]);
|
||||
|
||||
if (!hash) {
|
||||
await objectHandler.delete(newSaveObjectId);
|
||||
await objectHandler.deleteAsSystem(newSaveObjectId);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Hash failed to generate",
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
{
|
||||
// https://nuxt.com/docs/guide/concepts/typescript
|
||||
"extends": "./.nuxt/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"exactOptionalPropertyTypes": true
|
||||
}
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user