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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -24,6 +24,7 @@
>
<GamePanel
v-for="entry in collection?.entries"
:key="entry.gameId"
:game="entry.game"
:href="`/library/game/${entry.game.id}`"
/>

View File

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

View File

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

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")
}
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";
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];
}

View File

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

View File

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

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 },
});
if (article.image) {
return await objectHandler.delete(article.image);
return await objectHandler.deleteAsSystem(article.image);
}
return true;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,4 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json",
"compilerOptions": {
"exactOptionalPropertyTypes": true
}
"extends": "./.nuxt/tsconfig.json"
}

2053
yarn.lock

File diff suppressed because it is too large Load Diff