Merge branch 'Huskydog9988-more-ui-work' into develop

This commit is contained in:
DecDuck
2025-04-14 10:54:09 +10:00
38 changed files with 1132 additions and 1827 deletions

12
app.vue
View File

@ -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
View 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));

View File

@ -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',

View File

@ -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>

View File

@ -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>

View File

@ -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"

View 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>

View File

@ -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>

View File

@ -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',

View File

@ -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>

View File

@ -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
> >

View File

@ -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">&larr;</span> Back to home</a ><span aria-hidden="true">&larr;</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>

View File

@ -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`;
}, },

View File

@ -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`;

View File

@ -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,
}, },

View File

@ -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",

View File

@ -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>

View File

@ -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">

View File

@ -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>

View File

@ -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>

View File

@ -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 &rarr;</a >Check us out here &rarr;</NuxtLink
> >
</p> </p>
</div> </div>

View File

@ -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>

View File

@ -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}`"
/> />

View File

@ -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}`"
/> />

View File

@ -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>

View File

@ -0,0 +1,7 @@
-- CreateTable
CREATE TABLE "ObjectHash" (
"id" TEXT NOT NULL,
"hash" TEXT NOT NULL,
CONSTRAINT "ObjectHash_pkey" PRIMARY KEY ("id")
);

View File

@ -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
}

View File

@ -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];
} }

View File

@ -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({

View File

@ -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,

View 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;
});

View File

@ -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;
} }

View File

@ -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 {}
}
} }

View File

@ -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;

View File

@ -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);
}
} }

View File

@ -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",

View File

@ -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
}
} }

2053
yarn.lock

File diff suppressed because it is too large Load Diff