Compare commits

...

33 Commits

Author SHA1 Message Date
DecDuck fd828d5b50 Update droplet & other small features, and bump version for v0.3.3 (#212)
* fix: bump version and fix context timeout issues

* fix: bump droplet

* feat: add appimage auto-detection (#209)
2025-08-25 13:23:46 +10:00
DecDuck b33e27e446 API tokens (#201)
* fix: small fixes to request util and version update endpoint

* feat: api token creation and management

* fix: lint

* fix: remove unneeded sidebar component
2025-08-23 13:58:52 +10:00
useless-bit c97a56eb42 Init Prisma in Dockerfile (#204) 2025-08-23 07:55:37 +10:00
dependabot[bot] 5e5519ece7 chore(deps): bump vite-plugin-static-copy from 3.1.1 to 3.1.2 (#199)
Bumps [vite-plugin-static-copy](https://github.com/sapphi-red/vite-plugin-static-copy) from 3.1.1 to 3.1.2.
- [Release notes](https://github.com/sapphi-red/vite-plugin-static-copy/releases)
- [Changelog](https://github.com/sapphi-red/vite-plugin-static-copy/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sapphi-red/vite-plugin-static-copy/compare/vite-plugin-static-copy@3.1.1...vite-plugin-static-copy@3.1.2)

---
updated-dependencies:
- dependency-name: vite-plugin-static-copy
  dependency-version: 3.1.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-22 13:49:31 +10:00
DecDuck 6d89b7e510 Admin task UI update & QoL (#194)
* feat: revise library source names & update droplet

* feat: add internal name hint to library sources

* feat: update library source table with new name + icons

* fix: admin invitation localisation issue

* feat: #164

* feat: overhaul task UIs, #163

* fix: remove debug task

* fix: lint
2025-08-19 15:03:20 +10:00
FurbyOnSteroids 6baddc10e9 Fix non-unicode characters in game path (#193)
* replace btoa with a Buffer implementation, as btoa does not support non-unicode characters.

* replace btoa with a Buffer implementation, as btoa does not support non-unicode characters.

* fix linting

* fix linting

* replace buffer implementation with a md5 hash. This also adds the ts-md5 library.

* Revert "replace buffer implementation with a md5 hash. This also adds the ts-md5 library."

This reverts commit f98b811ab9.

* replace buffer implementation with md5 hash from node:crypto

* fix linting.. again

---------

Co-authored-by: FurbyOnSteroids <codeberg@your-moms-bellybutton.hair>
2025-08-16 22:23:57 +10:00
DecDuck a2ea0060cb Merge pull request #191 from Drop-OSS/weblate
Translations update from Weblate
2025-08-16 12:06:53 +10:00
Weblate 6aaab30439 Merge remote-tracking branch 'origin/develop' into develop 2025-08-16 02:05:27 +00:00
Ribemont Francois ea5d108a10 Translated using Weblate (French)
Currently translated at 98.2% (450 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/fr/
2025-08-16 12:02:48 +10:00
Weblate Translation Memory f0b127789f Translated using Weblate (English (en_PIRATE))
Currently translated at 83.8% (384 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en_PIRATE/
2025-08-16 12:02:48 +10:00
Weblate 4c8be2bfd1 Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/
2025-08-16 12:02:47 +10:00
Ribemont Francois 7e371adeb0 Translated using Weblate (French)
Currently translated at 97.3% (446 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/fr/
2025-08-16 12:02:47 +10:00
Weblate Translation Memory 6d7b491adb Translated using Weblate (French)
Currently translated at 97.8% (448 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/fr/
2025-08-16 12:02:36 +10:00
Ribemont Francois ecc806dc07 Translated using Weblate (French)
Currently translated at 98.2% (450 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/fr/
2025-08-06 21:06:35 +00:00
Weblate Translation Memory 45c94cfcbf Translated using Weblate (English (en_PIRATE))
Currently translated at 83.8% (384 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en_PIRATE/
2025-08-06 21:06:35 +00:00
Weblate 2fec40c5a6 Update translation files
Updated by "Remove blank strings" add-on in Weblate.

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/
2025-08-06 02:57:46 +00:00
Ribemont Francois 8f572e1259 Translated using Weblate (French)
Currently translated at 97.3% (446 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/fr/
2025-08-06 02:57:46 +00:00
Weblate Admin 43aa15d45c Translated using Weblate (English)
Currently translated at 100.0% (458 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en/
2025-08-06 02:57:46 +00:00
Husky 59a5540248 Translated using Weblate (German)
Currently translated at 68.5% (314 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/de/
2025-08-06 00:05:00 +00:00
Kuschiniko 5bfb3e0f68 Translated using Weblate (German)
Currently translated at 68.5% (314 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/de/
2025-08-06 00:05:00 +00:00
Hicks c04f6cbf80 Translated using Weblate (German)
Currently translated at 68.5% (314 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/de/
2025-08-06 00:05:00 +00:00
Weblate Translation Memory d2863fa95b Translated using Weblate (French)
Currently translated at 97.8% (448 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/fr/
2025-08-06 00:05:00 +00:00
Ribemont Francois 821fd2cf2d Translated using Weblate (French)
Currently translated at 97.8% (448 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/fr/
2025-08-06 00:05:00 +00:00
Husky 6f84ad42fc Translated using Weblate (English (en_PIRATE))
Currently translated at 84.0% (385 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en_PIRATE/
2025-08-06 00:04:59 +00:00
Dmitrii 1d1157a902 Translated using Weblate (Russian)
Currently translated at 6.1% (28 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/ru/
2025-08-05 19:50:38 +00:00
Kuschiniko 6ca9e34c7e Translated using Weblate (German)
Currently translated at 54.3% (249 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/de/
2025-08-05 19:50:38 +00:00
Hicks bc29c468d8 Translated using Weblate (German)
Currently translated at 54.3% (249 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/de/
2025-08-05 19:50:38 +00:00
Ribemont Francois 925ea1a414 Translated using Weblate (French)
Currently translated at 49.1% (225 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/fr/
2025-08-05 19:50:38 +00:00
Weblate Admin c9addd407e Added translation using Weblate (Russian) 2025-08-05 01:47:18 +00:00
Husky 242ae09857 Translated using Weblate (English (en_PIRATE))
Currently translated at 83.4% (382 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en_PIRATE/
2025-08-04 17:18:11 +00:00
Husky ba28c52912 Translated using Weblate (English)
Currently translated at 100.0% (458 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en/
2025-08-04 17:18:11 +00:00
Husky a98c95e695 Translated using Weblate (English (en_PIRATE))
Currently translated at 80.7% (370 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en_PIRATE/
2025-08-04 17:18:11 +00:00
Husky 26615ccad0 Translated using Weblate (English)
Currently translated at 100.0% (458 of 458 strings)

Translation: Drop/Drop
Translate-URL: http://translate.droposs.org/projects/drop/drop/en/
2025-08-04 17:18:11 +00:00
41 changed files with 1508 additions and 355 deletions
+2
View File
@@ -42,6 +42,8 @@ ENV NUXT_TELEMETRY_DISABLED=1
# RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn add --network-timeout 1000000 --no-lockfile --ignore-scripts prisma@6.11.1
RUN apk add --no-cache pnpm
RUN pnpm install prisma@6.11.1
# init prisma to download all required files
RUN pnpm prisma init
COPY --from=build-system /app/package.json ./
COPY --from=build-system /app/.output ./app
+7
View File
@@ -45,6 +45,7 @@ import {
LockClosedIcon,
DevicePhoneMobileIcon,
WrenchScrewdriverIcon,
CodeBracketIcon,
} from "@heroicons/vue/24/outline";
import { UserIcon } from "@heroicons/vue/24/solid";
import type { Component } from "vue";
@@ -73,6 +74,12 @@ const navigation: (NavigationItem & { icon: Component; count?: number })[] = [
icon: BellIcon,
count: notifications.value.length,
},
{
label: t("account.token.title"),
route: "/account/tokens",
prefix: "/account/tokens",
icon: CodeBracketIcon,
},
{
label: t("account.settings"),
route: "/account/settings",
+10
View File
@@ -10,6 +10,16 @@
d="M4 13.5C4 11.0008 5.38798 8.76189 7.00766 7C8.43926 5.44272 10.0519 4.25811 11.0471 3.5959C11.6287 3.20893 12.3713 3.20893 12.9529 3.5959C13.9481 4.25811 15.5607 5.44272 16.9923 7C18.612 8.76189 20 11.0008 20 13.5C20 17.9183 16.4183 21.5 12 21.5C7.58172 21.5 4 17.9183 4 13.5Z"
stroke="currentColor"
stroke-width="2"
stroke-dasharray="100"
:stroke-dashoffset="dashArray"
/>
</svg>
</template>
<script setup lang="ts">
const props = defineProps<{ progress?: number }>();
const dashArray = computed(() =>
props.progress === undefined ? 0 : ((100 - props.progress) / 100) * 50 + 50,
);
</script>
+27
View File
@@ -0,0 +1,27 @@
<template>
<span class="text-xs font-mono text-zinc-400 inline-flex items-top gap-x-2"
><span v-if="!short" class="text-zinc-500">{{ log.timestamp }}</span>
<span
:class="[
colours[log.level] || 'text-green-400',
'uppercase font-display font-semibold',
]"
>{{ log.level }}</span
>
<pre :class="[short ? 'line-clamp-1' : '', 'mt-[1px]']">{{
log.message
}}</pre>
</span>
</template>
<script setup lang="ts">
import type { TaskLog } from "~/server/internal/tasks";
defineProps<{ log: typeof TaskLog.infer; short?: boolean }>();
const colours: { [key: string]: string } = {
info: "text-blue-400",
warn: "text-yellow-400",
error: "text-red-400",
};
</script>
+267
View File
@@ -0,0 +1,267 @@
<template>
<ModalTemplate v-model="model" size-class="max-w-3xl">
<template #default>
<div class="space-y-5">
<div>
<label
for="name"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("account.token.name") }}</label
>
<p class="text-zinc-400 block text-xs font-medium leading-6">
{{ $t("account.token.nameDesc") }}
</p>
<div class="mt-2">
<input
id="name"
v-model="name"
name="name"
type="text"
autocomplete="name"
:placeholder="
props.suggestedName ?? $t('account.token.namePlaceholder')
"
class="block w-full rounded-md border-0 py-1.5 px-3 bg-zinc-800 disabled:bg-zinc-900/80 text-zinc-100 disabled:text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-700 disabled:ring-zinc-800 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div>
<Listbox v-model="expiryKey" as="div">
<ListboxLabel class="block text-sm/6 font-medium text-zinc-100">{{
$t("users.admin.simple.inviteExpiryLabel")
}}</ListboxLabel>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-800 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm/6"
>
<span class="block truncate">{{ expiryKey }}</span>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
<ChevronUpDownIcon
class="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<ListboxOption
v-for="[label] in Object.entries(expiry)"
:key="label"
v-slot="{ active, selected }"
as="template"
:value="label"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-300',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<span
:class="[
selected
? 'font-semibold text-zinc-100'
: 'font-normal',
'block truncate',
]"
>{{ label }}</span
>
<span
v-if="selected"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
]"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
</div>
<div>
<label
for="name"
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("account.token.acls") }}</label
>
<p class="text-zinc-400 block text-xs font-medium leading-6">
{{ $t("account.token.aclsDesc") }}
</p>
<fieldset class="divide-y divide-zinc-700">
<div
v-for="[sectionName, sectionAcls] in Object.entries(
aclsBySection,
)"
:key="sectionName"
class="grid lg:grid-cols-3 gap-1 py-3"
>
<div
v-for="[acl, description] in Object.entries(sectionAcls)"
:key="acl"
class="flex gap-3"
>
<div class="flex h-6 shrink-0 items-center">
<div class="group grid size-4 grid-cols-1">
<input
id="acl"
v-model="currentACLs[acl]"
aria-describedby="acl-description"
name="acl"
type="checkbox"
class="col-start-1 row-start-1 appearance-none rounded-sm border checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 border-white/10 bg-white/5 dark:checked:border-blue-500 dark:checked:bg-blue-500 dark:indeterminate:border-blue-500 dark:indeterminate:bg-blue-500 dark:focus-visible:outline-blue-500 dark:disabled:border-white/5 dark:disabled:bg-white/10 dark:disabled:checked:bg-white/10 forced-colors:appearance-auto"
/>
<svg
class="pointer-events-none col-start-1 row-start-1 size-3.5 self-center justify-self-center stroke-white group-has-disabled:stroke-white/25"
viewBox="0 0 14 14"
fill="none"
>
<path
class="opacity-0 group-has-checked:opacity-100"
d="M3 8L6 11L11 3.5"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
class="opacity-0 group-has-indeterminate:opacity-100"
d="M3 7H11"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
</div>
<div class="text-sm/6">
<label
for="acl"
class="font-display font-medium text-white"
>{{ acl }}</label
>
{{ " " }}
<span id="acl-description" class="text-xs text-zinc-400"
><span class="sr-only">{{ acl }} </span
>{{ description }}</span
>
</div>
</div>
</div>
</fieldset>
</div>
</div>
</template>
<template #buttons>
<LoadingButton :loading="props.loading" @click="() => createToken()">
{{ $t("common.create") }}
</LoadingButton>
<button
class="inline-flex items-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold font-display text-white hover:bg-zinc-700"
@click="() => cancel()"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/24/outline";
import type { DurationLike } from "luxon";
// Reuse for both admin and user tokens
const model = defineModel<boolean>({ required: true });
const { t } = useI18n();
const props = defineProps<{
acls: { [key: string]: string };
loading?: boolean;
suggestedAcls?: string[];
suggestedName?: string;
}>();
// Label to parameters to moment.js .add()
const expiry: Record<string, DurationLike | undefined> = {
[t("account.token.expiryMonth")]: {
month: 1,
},
[t("account.token.expiry3Month")]: {
month: 3,
},
[t("account.token.expiry6Month")]: {
month: 6,
},
[t("account.token.expiryYear")]: {
year: 1,
},
[t("account.token.expiry5Year")]: {
year: 5,
},
[t("account.token.noExpiry")]: undefined,
};
const expiryKey = ref<keyof typeof expiry>(Object.keys(expiry)[0]); // Cast to any because we just know it's okay
const name = ref(props.suggestedName ?? "");
const currentACLs = ref<{ [key: string]: boolean }>(
Object.fromEntries((props.suggestedAcls ?? []).map((v) => [v, true])),
);
const aclsBySection = computed(() => {
const sections: { [key: string]: { [key: string]: string } } = {};
for (const [acl, description] of Object.entries(props.acls)) {
const section = acl.split(":")[0];
sections[section] ??= {};
sections[section][acl] = description;
}
return sections;
});
const emit = defineEmits<{
create: [name: string, acls: string[], expiry: DurationLike | undefined];
}>();
function createToken() {
emit(
"create",
name.value,
Object.entries(currentACLs.value)
.filter(([_acl, enabled]) => enabled)
.map(([acl, _enabled]) => acl),
expiry[expiryKey.value],
);
}
function cancel() {
model.value = false;
}
watch(model, (c) => {
if (!c) {
name.value = "";
currentACLs.value = {};
}
});
</script>
+55
View File
@@ -0,0 +1,55 @@
<template>
<div
v-if="task"
class="flex w-full items-center justify-between space-x-6 p-6"
>
<div class="flex-1 truncate">
<div class="flex items-center space-x-1">
<div>
<CheckCircleIcon v-if="task.success" class="size-5 text-green-600" />
<XMarkIcon v-else-if="task.error" class="size-5 text-red-600" />
<div
v-else
class="size-2 bg-blue-600 rounded-full animate-pulse m-1"
/>
</div>
<h3 class="truncate text-sm font-medium text-zinc-100">
{{ task.name }}
</h3>
</div>
<div
v-if="active"
class="mt-2 w-full rounded-full overflow-hidden bg-zinc-900"
>
<div
:style="{ width: `${task.progress}%` }"
class="bg-blue-600 h-[3px] transition-all"
/>
</div>
<div class="mt-2 bg-zinc-950 px-2 pb-1 rounded-sm">
<LogLine :short="true" :log="parseTaskLog(task.log.at(-1))" />
</div>
<NuxtLink
type="button"
:href="`/admin/task/${task.id}`"
class="mt-3 ml-1 rounded-md text-xs font-medium text-zinc-100 hover:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-100 focus:ring-offset-2"
>
<i18n-t keypath="tasks.admin.viewTask" tag="span" scope="global">
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</div>
</div>
<div v-else>
<!-- renders server side when we don't want to access the current tasks -->
</div>
</template>
<script setup lang="ts">
import { CheckCircleIcon, XMarkIcon } from "@heroicons/vue/24/solid";
import type { TaskMessage } from "~/server/internal/tasks";
defineProps<{ task: TaskMessage | undefined; active?: boolean }>();
</script>
+27 -25
View File
@@ -46,10 +46,28 @@ export const $dropFetch: DropFetch = async (rawRequest, opts) => {
});
const request = requestParts.join("/");
// If not in setup
if (!getCurrentInstance()?.proxy) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Excessive stack depth comparing types
return await $fetch(request, opts);
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Excessive stack depth comparing types
return await $fetch(request, opts);
} catch (e) {
if (import.meta.client && opts?.failTitle) {
console.warn(e);
createModal(
ModalType.Notification,
{
title: opts.failTitle,
description:
(e as FetchError)?.statusMessage ?? (e as string).toString(),
//buttonText: $t("common.close"),
},
(_, c) => c(),
);
}
throw e;
}
}
const id = request.toString();
@@ -64,26 +82,10 @@ export const $dropFetch: DropFetch = async (rawRequest, opts) => {
}
const headers = useRequestHeaders(["cookie", "authorization"]);
try {
const data = await $fetch(request, {
...opts,
headers: { ...headers, ...opts?.headers },
});
if (import.meta.server) state.value = data;
return data;
} catch (e) {
if (import.meta.client && opts?.failTitle) {
createModal(
ModalType.Notification,
{
title: opts.failTitle,
description:
(e as FetchError)?.statusMessage ?? (e as string).toString(),
buttonText: $t("common.close"),
},
(_, c) => c(),
);
}
throw e;
}
const data = await $fetch(request, {
...opts,
headers: { ...headers, ...opts?.headers },
});
if (import.meta.server) state.value = data;
return data;
};
+1 -1
View File
@@ -19,7 +19,7 @@
"title": "Messages from the Crows' Nest",
"unread": "Unread Messages"
},
"settings": "Settings, savvy?",
"settings": "Settings",
"title": "Yer Own Coffer"
},
"actions": "Deeds",
+53 -21
View File
@@ -19,6 +19,28 @@
"title": "Notifications",
"unread": "Unread Notifications"
},
"token": {
"title": "API Tokens",
"subheader": "Manage your API tokens, and what they can access.",
"name": "API token name",
"nameDesc": "The name of the token, for reference.",
"namePlaceholder": "My New Token",
"acls": "ACLs/scopes",
"aclsDesc": "Defines what this token has the authority to do. You should avoid selecting all ACLs, if they are not necessary.",
"expiry": "Expiry",
"noExpiry": "No expiry",
"revoke": "Revoke",
"noTokens": "No tokens connected to your account.",
"expiryMonth": "A month",
"expiry3Month": "3 months",
"expiry6Month": "6 months",
"expiryYear": "A year",
"expiry5Year": "5 years",
"success": "Successfully created token.",
"successNote": "Make sure to copy it now, as it won't be shown again."
},
"settings": "Settings",
"title": "Account Settings"
},
@@ -137,6 +159,10 @@
"usernameTaken": "Username already taken."
},
"backHome": "{arrow} Back to home",
"externalUrl": {
"subtitle": "This message is only visible to admins.",
"title": "Accessing over different EXTERNAL_URL. Please check the docs."
},
"game": {
"banner": {
"description": "Drop failed to update the banner image: {0}",
@@ -212,10 +238,6 @@
"desc": "Drop encountered an error while updating the version: {error}",
"title": "There an error while updating the version order"
}
},
"externalUrl": {
"title": "Accessing over different EXTERNAL_URL. Please check the docs.",
"subtitle": "This message is only visible to admins."
}
},
"footer": {
@@ -241,7 +263,11 @@
"admin": {
"admin": "Admin",
"metadata": "Meta",
"settings": "Settings",
"settings": {
"title": "Settings",
"store": "Store",
"tokens": "API tokens"
},
"tasks": "Tasks",
"users": "Users"
},
@@ -327,6 +353,7 @@
"description": "Companies organize games by who they were developed or published by.",
"editor": {
"action": "Add Game {plus}",
"descriptionPlaceholder": "{'<'}description{'>'}",
"developed": "Developed",
"libraryDescription": "Add, remove, or customise what this company has developed and/or published.",
"libraryTitle": "Game Library",
@@ -334,25 +361,23 @@
"published": "Published",
"uploadBanner": "Upload banner",
"uploadIcon": "Upload icon",
"descriptionPlaceholder": "{'<'}description{'>'}",
"websitePlaceholder": "{'<'}website{'>'}"
},
"modals": {
"createDescription": "Create a company to further organize your games.",
"createFieldDescription": "Company Description",
"createFieldDescriptionPlaceholder": "A small indie studio that...",
"createFieldName": "Company Name",
"createFieldNamePlaceholder": "My New Company...",
"createFieldWebsite": "Company Website",
"createFieldWebsitePlaceholder": "https://example.com/",
"createTitle": "Create a company",
"nameDescription": "Edit the company's name. Used to match to new game imports.",
"nameTitle": "Edit company name",
"shortDeckDescription": "Edit the company's description. Doesn't affect long (markdown) description.",
"shortDeckTitle": "Edit company description",
"websiteDescription": "Edit the company's website. Note: this will be a link, and won't have redirect protection.",
"websiteTitle": "Edit company website",
"createTitle": "Create a company",
"createDescription": "Create a company to further organize your games.",
"createFieldName": "Company Name",
"createFieldNamePlaceholder": "My New Company...",
"createFieldDescription": "Company Description",
"createFieldDescriptionPlaceholder": "A small indie studio that...",
"createFieldWebsite": "Company Website",
"createFieldWebsitePlaceholder": "https://example.com/"
"websiteTitle": "Edit company website"
},
"noCompanies": "No companies",
"noGames": "No games",
@@ -373,6 +398,8 @@
},
"metadataProvider": "Metadata provider",
"noGames": "No games imported",
"libraryHint": "No libraries configured.",
"libraryHintDocsLink": "What does this mean? {arrow}",
"offline": "Drop couldn't access this game.",
"offlineTitle": "Game offline",
"openEditor": "Open in Editor {arrow}",
@@ -382,12 +409,15 @@
"create": "Create source",
"createDesc": "Drop will use this source to access your game library, and make them available.",
"desc": "Configure your library sources, where Drop will look for new games and versions to import.",
"documentationLink": "Documentation {arrow}",
"edit": "Edit source",
"fsDesc": "Imports games from a path on disk. Requires version-based folder structure, and supports archived games.",
"fsFlatDesc": "Imports games from a path on disk, but without a separate version subfolder. Useful when migrating an existing library to Drop.",
"fsFlatTitle": "Compatibility",
"fsPath": "Path",
"fsPathDesc": "An absolute path to your game library.",
"fsPathPlaceholder": "/mnt/games",
"fsTitle": "Drop-style",
"link": "Sources {arrow}",
"nameDesc": "The name of your source, for reference.",
"namePlaceholder": "My New Source",
@@ -514,13 +544,13 @@
"images": "Game Images",
"lookAt": "Check it out",
"noDevelopers": "No developers",
"noGame": "NO GAME",
"noFeatured": "NO FEATURED GAMES",
"openFeatured": "Star games in Admin Library {arrow}",
"noGame": "NO GAME",
"noImages": "No images",
"noPublishers": "No publishers.",
"noTags": "No tags",
"openAdminDashboard": "Open in Admin Dashboard",
"openFeatured": "Star games in Admin Library {arrow}",
"platform": "Platform | Platform | Platforms",
"publishers": "Publishers | Publisher | Publishers",
"rating": "Rating",
@@ -560,7 +590,9 @@
"cleanupSessionsName": "Clean up sessions."
},
"viewTask": "View {arrow}",
"weeklyScheduledTitle": "Weekly scheduled tasks"
"weeklyScheduledTitle": "Weekly scheduled tasks",
"progress": "{0}%",
"execute": "{arrow} Execute"
}
},
"title": "Drop",
@@ -585,7 +617,6 @@
"admin": {
"adminHeader": "Admin?",
"adminUserLabel": "Admin user",
"authLink": "Authentication {arrow}",
"authentication": {
"configure": "Configure",
"description": "Drop supports a variety of \"authentication mechanisms\". As you enable or disable them, they are shown on the sign in screen for users to select from. Click the dot menu to configure the authentication mechanism.",
@@ -597,6 +628,7 @@
"srOpenOptions": "Open options",
"title": "Authentication"
},
"authLink": "Authentication {arrow}",
"authoptionsHeader": "Auth Options",
"delete": "Delete",
"deleteUser": "Delete user {0}",
@@ -609,7 +641,7 @@
"createInvitation": "Create invitation",
"description": "Simple authentication uses a system of 'invitations' to create users. You can create an invitation, and optionally specify a username or email for the user, and then it will generate a magic URL that can be used to create an account.",
"expires": "Expires: {expiry}",
"invitationTitle": "invitations",
"invitationTitle": "Invitations",
"invite3Days": "3 days",
"invite6Months": "6 months",
"inviteAdminSwitchDescription": "Create this user as an administrator",
+9 -5
View File
@@ -1,6 +1,8 @@
{
"account": {
"devices": {
"capabilities": "Capacités",
"lastConnected": "Dernière Connexion",
"noDevices": "Aucun appareil n'est connecté à vôtre compte.",
"platform": "Plateforme",
"revoke": "Révoquer",
@@ -12,13 +14,13 @@
"desc": "Voir et gérer vos notifications.",
"markAllAsRead": "Tout marqué comme lu",
"markAsRead": "Marquer comme lu",
"none": "Pas de notifications",
"none": "Pas de notification",
"notifications": "Notifications",
"title": "Notifications",
"unread": "Notifications non lues"
"unread": "Notifications Non Lues"
},
"settings": "Paramètres",
"title": "Paramètres du compte"
"title": "Paramètres du Compte"
},
"actions": "Actions",
"add": "Ajouter",
@@ -31,7 +33,7 @@
"authorizedClient": "Drop a réussi a autoriser le client. Vous pouvez fermer cette fenêtre.",
"issues": "Vous avez des problèmes ?",
"learn": "En savoir plus {arrow}",
"paste": "Coller ce code dans le client pour continuer :",
"paste": "Collez ce code dans le client pour continuer :",
"permWarning": "Accepter cette requête autorisera \"{name}\" sur \"{plateform} à :",
"requestedAccess": "\"{name} a demandé accès à votre compte Drop.",
"success": "Réussi !"
@@ -54,7 +56,7 @@
"signin": {
"externalProvider": "Connectez vous avec un fournisseur externe {arrow}",
"forgot": "Mot de passe oublié ?",
"noAccount": "Pas de compte ? Demande à un administrateur d'en créer un pour toi.",
"noAccount": "Pas de compte ? Demandez à un administrateur d'en créer un pour vous.",
"or": "OU",
"pageTitle": "Se connecter à Drop",
"rememberMe": "Se souvenir de moi",
@@ -109,6 +111,7 @@
"italic": "Italique",
"italicPlaceholder": "texte italique",
"link": "Lien",
"linkPlaceholder": "texte du lien",
"listItem": "Élement de liste",
"listItemPlaceholder": "élément de liste"
},
@@ -570,6 +573,7 @@
"srOpenOptions": "Ouvrir les options",
"title": "Authentification"
},
"authoptionsHeader": "Options Auth",
"delete": "Supprimer",
"deleteUser": "Supprimer l'utilisateur {0}",
"description": "Gérer les utilisateurs sur votre instance Drop, et configurer vos méthodes d'authentification.",
+1 -1
View File
@@ -200,7 +200,7 @@ const navigation: Array<NavigationItem & { icon: Component }> = [
icon: RectangleStackIcon,
},
{
label: $t("header.admin.settings"),
label: $t("header.admin.settings.title"),
route: "/admin/settings",
prefix: "/admin/settings",
icon: Cog6ToothIcon,
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "drop",
"version": "0.3.1",
"version": "0.3.3",
"private": true,
"type": "module",
"license": "AGPL-3.0-or-later",
@@ -21,7 +21,7 @@
},
"dependencies": {
"@discordapp/twemoji": "^16.0.1",
"@drop-oss/droplet": "2.3.0",
"@drop-oss/droplet": "3.0.1",
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5",
"@lobomfz/prismark": "0.0.3",
@@ -53,7 +53,7 @@
"stream-mime-type": "^2.0.0",
"turndown": "^7.2.0",
"unstorage": "^1.15.0",
"vite-plugin-static-copy": "^3.0.0",
"vite-plugin-static-copy": "^3.1.2",
"vue": "latest",
"vue-router": "latest",
"vue3-carousel": "^0.16.0",
+229
View File
@@ -0,0 +1,229 @@
<template>
<div>
<div class="w-full flex justify-between items-center">
<div>
<h2
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
>
{{ $t("account.token.title") }}
</h2>
<p
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
>
{{ $t("account.token.subheader") }}
</p>
</div>
<div>
<LoadingButton :loading="false" @click="() => (createOpen = true)">
{{ $t("common.create") }}
</LoadingButton>
</div>
</div>
<div
v-if="newToken"
class="mt-4 rounded-md p-4 bg-green-500/10 outline outline-green-500/20"
>
<div class="flex">
<div class="shrink-0">
<CheckCircleIcon class="size-5 text-green-400" aria-hidden="true" />
</div>
<div class="ml-3">
<p class="text-sm font-medium text-green-300">
{{ $t("account.token.success") }}
</p>
<p class="text-xs text-green-300/70">
{{ $t("account.token.successNote") }}
</p>
<p
class="mt-2 bg-zinc-950 px-3 py-2 font-mono text-zinc-400 rounded-xl"
>
{{ newToken }}
</p>
</div>
<div class="ml-auto pl-3">
<div class="-mx-1.5 -my-1.5">
<button
type="button"
class="inline-flex rounded-md bg-green-50 p-1.5 text-green-500 hover:bg-green-100 focus-visible:ring-2 focus-visible:ring-green-600 focus-visible:ring-offset-2 focus-visible:ring-offset-green-50 focus-visible:outline-hidden dark:bg-transparent dark:text-green-400 dark:hover:bg-green-500/10 dark:focus-visible:ring-green-500 dark:focus-visible:ring-offset-1 dark:focus-visible:ring-offset-green-900"
@click="() => (newToken = undefined)"
>
<span class="sr-only">{{ $t("common.close") }}</span>
<XMarkIcon class="size-5" aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
<div
class="mt-8 overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm"
>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-800">
<thead>
<tr class="bg-zinc-800/50">
<th
scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6"
>
{{ $t("common.name") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("account.token.acls") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("account.token.expiry") }}
</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">{{ $t("actions") }}</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-800">
<tr
v-for="(token, tokenIdx) in tokens"
:key="token.id"
class="transition-colors duration-150 hover:bg-zinc-800/50"
>
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-6"
>
{{ token.name }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<div class="flex flex-wrap gap-2">
<span
v-for="acl in token.acls"
:key="acl"
class="inline-flex items-center gap-x-1 rounded-md bg-blue-400/10 px-2 py-1 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-400/20"
>
{{ acl }}
</span>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<RelativeTime v-if="token.expiresAt" :date="token.expiresAt" />
<span v-else>{{ $t("account.token.noExpiry") }}</span>
</td>
<td
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
>
<button
class="inline-flex items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
@click="() => revokeToken(tokenIdx)"
>
{{ $t("account.token.revoke") }}
<span class="sr-only">
{{ $t("chars.srComma", [token.name]) }}
</span>
</button>
</td>
</tr>
<tr v-if="tokens.length === 0">
<td colspan="5" class="py-8 text-center text-sm text-zinc-400">
{{ $t("account.token.noTokens") }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<ModalCreateToken
v-model="createOpen"
:acls="acls"
:loading="createLoading"
:suggested-name="suggestedName"
:suggested-acls="suggestedAcls"
@create="(name, acls, expiry) => createToken(name, acls, expiry)"
/>
</div>
</template>
<script setup lang="ts">
import { ArkErrors, type } from "arktype";
import { DateTime, type DurationLike } from "luxon";
import { CheckCircleIcon, XMarkIcon } from "@heroicons/vue/20/solid";
const tokens = ref(await $dropFetch("/api/v1/user/token"));
const acls = await $dropFetch("/api/v1/user/token/acls");
const createOpen = ref(false);
const createLoading = ref(false);
const newToken = ref<string | undefined>();
const suggestedName = ref();
const suggestedAcls = ref<string[]>([]);
const payloadParser = type({
name: "string?",
acls: "string[]?",
});
const route = useRoute();
if (route.query.payload) {
try {
const rawPayload = JSON.parse(atob(route.query.payload.toString()));
const payload = payloadParser(rawPayload);
if (payload instanceof ArkErrors) throw payload;
suggestedName.value = payload.name;
suggestedAcls.value = payload.acls ?? [];
createOpen.value = true;
} catch {
throw createError({
statusCode: 400,
statusMessage: "Failed to parse the token create payload.",
fatal: true,
});
}
}
async function createToken(
name: string,
acls: string[],
expiry: DurationLike | undefined,
) {
createLoading.value = true;
try {
const result = await $dropFetch("/api/v1/user/token", {
method: "POST",
body: {
name,
acls,
expiry: expiry ? DateTime.now().plus(expiry) : undefined,
},
failTitle: "Failed to create API token.",
});
console.log(result);
newToken.value = result.token;
tokens.value.push(result);
} finally {
/* empty */
}
createOpen.value = false;
createLoading.value = false;
}
async function revokeToken(index: number) {
const token = tokens.value[index];
if (!token) return;
await $dropFetch("/api/v1/user/token/:id", {
method: "DELETE",
params: {
id: token.id,
},
failTitle: "Failed to revoke token.",
});
tokens.value.splice(index, 1);
}
</script>
+35 -2
View File
@@ -242,11 +242,40 @@
{{ $t("common.noResults") }}
</p>
<p
v-if="filteredLibraryGames.length == 0 && libraryGames.length == 0"
v-if="
filteredLibraryGames.length == 0 &&
libraryGames.length == 0 &&
libraryState.hasLibraries
"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
{{ $t("library.admin.noGames") }}
</p>
<p
v-else-if="!libraryState.hasLibraries"
class="flex flex-col gap-2 text-zinc-600 text-center col-span-4"
>
<span class="text-sm font-display font-bold uppercase">{{
$t("library.admin.libraryHint")
}}</span>
<NuxtLink
class="transition text-xs text-zinc-600 hover:underline hover:text-zinc-400"
href="https://docs.droposs.org/docs/library"
target="_blank"
>
<i18n-t
keypath="library.admin.libraryHintDocsLink"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1"
>
<template #arrow>
<ArrowTopRightOnSquareIcon class="size-4" />
</template>
</i18n-t>
</NuxtLink>
</p>
</ul>
</div>
</template>
@@ -256,7 +285,11 @@ import {
ExclamationTriangleIcon,
ExclamationCircleIcon,
} from "@heroicons/vue/16/solid";
import { InformationCircleIcon, StarIcon } from "@heroicons/vue/20/solid";
import {
ArrowTopRightOnSquareIcon,
InformationCircleIcon,
StarIcon,
} from "@heroicons/vue/20/solid";
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
const { t } = useI18n();
+47 -8
View File
@@ -64,8 +64,14 @@
>
{{ source.name }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
{{ source.backend }}
<td
class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400 inline-flex gap-x-1 items-center"
>
<component
:is="optionsMetadata[source.backend].icon"
class="size-5 text-zinc-400"
/>
{{ optionsMetadata[source.backend].title }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<CheckIcon
@@ -189,11 +195,34 @@
<RadioGroupLabel
as="span"
class="font-semibold text-zinc-100"
>{{ source }}</RadioGroupLabel
>{{ metadata.title }}
<span class="ml-2 font-mono text-zinc-500 text-xs">{{
source
}}</span></RadioGroupLabel
>
<RadioGroupDescription
as="span"
class="text-zinc-400 text-xs"
>
<RadioGroupDescription as="span" class="text-zinc-400">
{{ metadata.description }}
</RadioGroupDescription>
<NuxtLink
:href="metadata.docsLink"
:external="true"
target="_blank"
class="mt-2 block w-fit rounded-md bg-blue-600 px-2 py-1 text-center text-xs font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
keypath="library.admin.sources.documentationLink"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1"
>
<template #arrow>
<ArrowTopRightOnSquareIcon class="size-4" />
</template>
</i18n-t>
</NuxtLink>
</span>
</span>
<span
@@ -269,6 +298,7 @@
*/
import {
DropLogo,
SourceOptionsFilesystem,
SourceOptionsFlatFilesystem,
} from "#components";
@@ -279,8 +309,11 @@ import {
RadioGroupLabel,
RadioGroupOption,
} from "@headlessui/vue";
import { XCircleIcon } from "@heroicons/vue/20/solid";
import { CheckIcon, DocumentIcon, XMarkIcon } from "@heroicons/vue/24/outline";
import {
XCircleIcon,
ArrowTopRightOnSquareIcon,
} from "@heroicons/vue/20/solid";
import { BackwardIcon, CheckIcon, XMarkIcon } from "@heroicons/vue/24/outline";
import { FetchError } from "ofetch";
import type { Component } from "vue";
import type { LibraryBackend } from "~/prisma/client/enums";
@@ -324,17 +357,23 @@ const optionUIs: { [key in LibraryBackend]: Component } = {
};
const optionsMetadata: {
[key in LibraryBackend]: {
title: string;
description: string;
docsLink: string;
icon: Component;
};
} = {
Filesystem: {
title: t("library.admin.sources.fsTitle"),
description: t("library.admin.sources.fsDesc"),
icon: DocumentIcon,
docsLink: "https://docs.droposs.org/docs/library#drop-style",
icon: DropLogo,
},
FlatFilesystem: {
title: t("library.admin.sources.fsFlatTitle"),
description: t("library.admin.sources.fsFlatDesc"),
icon: DocumentIcon,
docsLink: "https://docs.droposs.org/docs/library#flat-style-or-compat",
icon: BackwardIcon,
},
};
const optionsMetadataIter = Object.entries(optionsMetadata);
+68
View File
@@ -0,0 +1,68 @@
<template>
<div class="flex flex-col">
<!-- tabs-->
<div>
<div class="border-b border-gray-200 dark:border-white/10">
<nav class="-mb-px flex gap-x-2" aria-label="Tabs">
<NuxtLink
v-for="(tab, tabIdx) in navigation"
:key="tab.route"
:href="tab.route"
:class="[
currentNavigationIndex == tabIdx
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-white/20 dark:hover:text-gray-300',
'group inline-flex items-center border-b-2 px-1 py-4 text-sm font-medium',
]"
:aria-current="tab ? 'page' : undefined"
>
<component
:is="tab.icon"
:class="[
currentNavigationIndex == tabIdx
? 'text-blue-500 dark:text-blue-400'
: 'text-gray-400 group-hover:text-gray-500 dark:text-gray-500 dark:group-hover:text-gray-400',
'mr-2 -ml-0.5 size-5',
]"
aria-hidden="true"
/>
<span>{{ tab.label }}</span>
</NuxtLink>
</nav>
</div>
</div>
<!-- content -->
<div class="mt-4 grow flex">
<NuxtPage />
</div>
</div>
</template>
<script setup lang="ts">
import {
BuildingStorefrontIcon,
CodeBracketIcon,
} from "@heroicons/vue/24/outline";
const navigation: Array<NavigationItem & { icon: Component }> = [
{
label: $t("header.admin.settings.store"),
route: "/admin/settings",
prefix: "/admin/settings",
icon: BuildingStorefrontIcon,
},
{
label: $t("header.admin.settings.tokens"),
route: "/admin/settings/tokens",
prefix: "/admin/settings/tokens",
icon: CodeBracketIcon,
},
];
// const notifications = useNotifications();
// const unreadNotifications = computed(() =>
// notifications.value.filter((e) => !e.read)
// );
const currentNavigationIndex = useCurrentNavigationIndex(navigation);
</script>
+49 -62
View File
@@ -1,68 +1,55 @@
<template>
<div class="space-y-4">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-2xl font-semibold text-zinc-100">
{{ $t("settings.admin.title") }}
</h1>
<p class="mt-2 text-base text-zinc-400">
{{ $t("settings.admin.description") }}
</p>
</div>
<form class="space-y-4" @submit.prevent="() => saveSettings()">
<div class="pb-4 border-b border-zinc-700">
<h2 class="text-xl font-semibold text-zinc-100">
{{ $t("settings.admin.store.title") }}
</h2>
<h3 class="text-base font-medium text-zinc-400 mb-3 m-x-0">
{{ $t("settings.admin.store.showGamePanelTextDecoration") }}
</h3>
<ul class="flex gap-3">
<li class="inline-block">
<OptionWrapper
:active="showGamePanelTextDecoration"
@click="setShowTitleDescription(true)"
>
<div class="flex">
<GamePanel
:animate="false"
:game="game"
:default-placeholder="true"
/>
</div>
</OptionWrapper>
</li>
<li class="inline-block">
<OptionWrapper
:active="!showGamePanelTextDecoration"
@click="setShowTitleDescription(false)"
>
<div class="flex">
<GamePanel
:game="game"
:show-title-description="false"
:animate="false"
:default-placeholder="true"
/>
</div>
</OptionWrapper>
</li>
</ul>
</div>
<form class="space-y-4" @submit.prevent="() => saveSettings()">
<div class="py-6 border-y border-zinc-700">
<h2 class="text-xl font-semibold text-zinc-100">
{{ $t("settings.admin.store.title") }}
</h2>
<h3 class="text-base font-medium text-zinc-400 mb-3 m-x-0">
{{ $t("settings.admin.store.showGamePanelTextDecoration") }}
</h3>
<ul class="flex gap-3">
<li class="inline-block">
<OptionWrapper
:active="showGamePanelTextDecoration"
@click="setShowTitleDescription(true)"
>
<div class="flex">
<GamePanel
:animate="false"
:game="game"
:default-placeholder="true"
/>
</div>
</OptionWrapper>
</li>
<li class="inline-block">
<OptionWrapper
:active="!showGamePanelTextDecoration"
@click="setShowTitleDescription(false)"
>
<div class="flex">
<GamePanel
:game="game"
:show-title-description="false"
:animate="false"
:default-placeholder="true"
/>
</div>
</OptionWrapper>
</li>
</ul>
</div>
<LoadingButton
type="submit"
class="inline-flex w-full shadow-sm sm:w-auto"
:loading="saving"
:disabled="!allowSave"
>
{{ allowSave ? $t("common.save") : $t("common.saved") }}
</LoadingButton>
</form>
</div>
<LoadingButton
type="submit"
class="inline-flex w-full shadow-sm sm:w-auto"
:loading="saving"
:disabled="!allowSave"
>
{{ allowSave ? $t("common.save") : $t("common.saved") }}
</LoadingButton>
</form>
</template>
<script setup lang="ts">
+233
View File
@@ -0,0 +1,233 @@
<template>
<div class="w-full">
<div class="w-full flex justify-between items-center">
<div>
<h2
class="mt-2 text-xl font-semibold tracking-tight text-zinc-100 sm:text-3xl"
>
{{ $t("account.token.title") }}
</h2>
<p
class="mt-2 text-pretty text-sm font-medium text-zinc-400 sm:text-md/8"
>
{{ $t("account.token.subheader") }}
</p>
</div>
<div>
<LoadingButton :loading="false" @click="() => (createOpen = true)">
{{ $t("common.create") }}
</LoadingButton>
</div>
</div>
<div
v-if="newToken"
class="mt-4 rounded-md p-4 bg-green-500/10 outline outline-green-500/20"
>
<div class="flex">
<div class="shrink-0">
<CheckCircleIcon class="size-5 text-green-400" aria-hidden="true" />
</div>
<div class="ml-3">
<p class="text-sm font-medium text-green-300">
{{ $t("account.token.success") }}
</p>
<p class="text-xs text-green-300/70">
{{ $t("account.token.successNote") }}
</p>
<p
class="mt-2 bg-zinc-950 px-3 py-2 font-mono text-zinc-400 rounded-xl"
>
{{ newToken }}
</p>
</div>
<div class="ml-auto pl-3">
<div class="-mx-1.5 -my-1.5">
<button
type="button"
class="inline-flex rounded-md bg-green-50 p-1.5 text-green-500 hover:bg-green-100 focus-visible:ring-2 focus-visible:ring-green-600 focus-visible:ring-offset-2 focus-visible:ring-offset-green-50 focus-visible:outline-hidden dark:bg-transparent dark:text-green-400 dark:hover:bg-green-500/10 dark:focus-visible:ring-green-500 dark:focus-visible:ring-offset-1 dark:focus-visible:ring-offset-green-900"
@click="() => (newToken = undefined)"
>
<span class="sr-only">{{ $t("common.close") }}</span>
<XMarkIcon class="size-5" aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
<div
class="mt-8 overflow-hidden rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm"
>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-zinc-800">
<thead>
<tr class="bg-zinc-800/50">
<th
scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6"
>
{{ $t("common.name") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("account.token.acls") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("account.token.expiry") }}
</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">{{ $t("actions") }}</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-800">
<tr
v-for="(token, tokenIdx) in tokens"
:key="token.id"
class="transition-colors duration-150 hover:bg-zinc-800/50"
>
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-6"
>
{{ token.name }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<div class="flex flex-wrap gap-2">
<span
v-for="acl in token.acls"
:key="acl"
class="inline-flex items-center gap-x-1 rounded-md bg-blue-400/10 px-2 py-1 text-xs font-medium text-blue-400 ring-1 ring-inset ring-blue-400/20"
>
{{ acl }}
</span>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<RelativeTime v-if="token.expiresAt" :date="token.expiresAt" />
<span v-else>{{ $t("account.token.noExpiry") }}</span>
</td>
<td
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
>
<button
class="inline-flex items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
@click="() => revokeToken(tokenIdx)"
>
{{ $t("account.token.revoke") }}
<span class="sr-only">
{{ $t("chars.srComma", [token.name]) }}
</span>
</button>
</td>
</tr>
<tr v-if="tokens.length === 0">
<td colspan="5" class="py-8 text-center text-sm text-zinc-400">
{{ $t("account.token.noTokens") }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<ModalCreateToken
v-model="createOpen"
:acls="acls"
:loading="createLoading"
:suggested-name="suggestedName"
:suggested-acls="suggestedAcls"
@create="(name, acls, expiry) => createToken(name, acls, expiry)"
/>
</div>
</template>
<script setup lang="ts">
import { ArkErrors, type } from "arktype";
import { DateTime, type DurationLike } from "luxon";
import { CheckCircleIcon, XMarkIcon } from "@heroicons/vue/20/solid";
definePageMeta({
layout: "admin",
});
const tokens = ref(await $dropFetch("/api/v1/admin/token"));
const acls = await $dropFetch("/api/v1/admin/token/acls");
const createOpen = ref(false);
const createLoading = ref(false);
const newToken = ref<string | undefined>();
const suggestedName = ref();
const suggestedAcls = ref<string[]>([]);
const payloadParser = type({
name: "string?",
acls: "string[]?",
});
const route = useRoute();
if (route.query.payload) {
try {
const rawPayload = JSON.parse(atob(route.query.payload.toString()));
const payload = payloadParser(rawPayload);
if (payload instanceof ArkErrors) throw payload;
suggestedName.value = payload.name;
suggestedAcls.value = payload.acls ?? [];
createOpen.value = true;
} catch {
throw createError({
statusCode: 400,
statusMessage: "Failed to parse the token create payload.",
fatal: true,
});
}
}
async function createToken(
name: string,
acls: string[],
expiry: DurationLike | undefined,
) {
createLoading.value = true;
try {
const result = await $dropFetch("/api/v1/admin/token", {
method: "POST",
body: {
name,
acls,
expiry: expiry ? DateTime.now().plus(expiry) : undefined,
},
failTitle: "Failed to create API token.",
});
console.log(result);
newToken.value = result.token;
tokens.value.push(result);
} catch {
/* empty */
}
createOpen.value = false;
createLoading.value = false;
}
async function revokeToken(index: number) {
const token = tokens.value[index];
if (!token) return;
await $dropFetch("/api/v1/admin/token/:id", {
method: "DELETE",
params: {
id: token.id,
},
failTitle: "Failed to revoke token.",
});
tokens.value.splice(index, 1);
}
</script>
+16 -14
View File
@@ -44,19 +44,26 @@
</div>
{{ task.name }}
</h1>
<div class="h-2 rounded-full bg-zinc-950 overflow-hidden">
<div
class="bg-zinc-950 p-2 rounded-md h-[80vh] flex flex-col flex-col-reverse overflow-y-scroll gap-y-1"
>
<LogLine
v-for="(_, idx) in task.log"
:key="idx"
:log="parseTaskLog(task.log.at(-(idx + 1)))"
/>
</div>
<div class="relative h-5 rounded-xl bg-zinc-950 overflow-hidden">
<div
:style="{ width: `${task.progress}%` }"
class="transition-all bg-blue-600 h-full"
/>
</div>
<div
class="relative bg-zinc-950/50 rounded-md p-2 text-zinc-100 h-[80vh] overflow-y-scroll"
>
<pre v-for="(line, idx) in task.log" :key="idx">{{
formatLine(line)
}}</pre>
<span
class="absolute inset-0 flex items-center justify-center text-blue-200 text-sm font-bold font-display"
>{{
$t("tasks.admin.progress", [Math.round(task.progress * 10) / 10])
}}</span
>
</div>
</div>
<div v-else role="status" class="w-full flex items-center justify-center">
@@ -90,11 +97,6 @@ const taskId = route.params.id.toString();
const task = useTask(taskId);
function formatLine(line: string): string {
const res = parseTaskLog(line);
return `[${res.timestamp}] ${res.message}`;
}
definePageMeta({
layout: "admin",
});
+50 -103
View File
@@ -13,62 +13,7 @@
:key="task.value?.id"
class="col-span-1 divide-y divide-gray-200 rounded-lg bg-zinc-800 border border-zinc-700 shadow-sm"
>
<div
v-if="task.value"
class="flex w-full items-center justify-between space-x-6 p-6"
>
<div class="flex-1 truncate">
<div class="flex items-center space-x-2">
<div>
<CheckIcon
v-if="task.value.success"
class="size-4 text-green-600"
/>
<XMarkIcon
v-else-if="task.value.error"
class="size-4 text-red-600"
/>
<div
v-else
class="size-2 bg-blue-600 rounded-full animate-pulse"
/>
</div>
<h3 class="truncate text-sm font-medium text-zinc-100">
{{ task.value.name }}
</h3>
</div>
<p class="text-xs text-zinc-600 mt-0.5 font-mono">
{{ task.value.id }}
</p>
<div class="mt-1 w-full rounded-full overflow-hidden bg-zinc-900">
<div
:style="{ width: `${task.value.progress}%` }"
class="bg-blue-600 h-1.5 transition-all"
/>
</div>
<p class="mt-1 truncate text-sm text-zinc-400">
{{ parseTaskLog(task.value.log.at(-1)).message }}
</p>
<NuxtLink
type="button"
:href="`/admin/task/${task.value.id}`"
class="mt-3 rounded-md text-xs font-medium text-zinc-100 hover:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-100 focus:ring-offset-2"
>
<i18n-t
keypath="tasks.admin.viewTask"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</div>
</div>
<div v-else>
<!-- renders server side when we don't want to access the current tasks -->
</div>
<TaskWidget :task="task.value" :active="true" />
</li>
</ul>
<div
@@ -89,51 +34,7 @@
:key="task.id"
class="col-span-1 divide-y divide-gray-200 rounded-lg bg-zinc-800 border border-zinc-700 shadow-sm"
>
<div class="flex w-full items-center justify-between space-x-6 p-6">
<div class="flex-1 truncate">
<div class="flex items-center space-x-2">
<div>
<CheckIcon
v-if="task.success"
class="size-4 text-green-600"
/>
<XMarkIcon
v-else-if="task.error"
class="size-4 text-red-600"
/>
<div
v-else
class="size-2 bg-blue-600 rounded-full animate-pulse"
/>
</div>
<h3 class="truncate text-sm font-medium text-zinc-100">
{{ task.name }}
</h3>
<RelativeTime class="text-zinc-500" :date="task.ended" />
</div>
<p class="text-xs text-zinc-600 mt-0.5 font-mono">
{{ task.id }}
</p>
<p class="mt-1 truncate text-sm text-zinc-400">
{{ parseTaskLog(task.log.at(-1)).message }}
</p>
<NuxtLink
type="button"
:href="`/admin/task/${task.id}`"
class="mt-3 rounded-md text-xs font-medium text-zinc-100 hover:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-100 focus:ring-offset-2"
>
<i18n-t
keypath="tasks.admin.viewTask"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</div>
</div>
<TaskWidget :task="task" />
</li>
</ul>
</div>
@@ -157,6 +58,21 @@
<p class="mt-1 text-sm text-zinc-400">
{{ scheduledTasks[task].description }}
</p>
<button
class="mt-3 rounded-md text-xs font-medium text-zinc-100 hover:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-100 focus:ring-offset-2"
@click="() => startTask(task)"
>
<i18n-t
keypath="tasks.admin.execute"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1"
>
<template #arrow>
<PlayIcon class="size-4" aria-hidden="true" />
</template>
</i18n-t>
</button>
</div>
</div>
</li>
@@ -180,6 +96,21 @@
<p class="mt-1 text-sm text-zinc-400">
{{ scheduledTasks[task].description }}
</p>
<button
class="mt-3 rounded-md text-xs font-medium text-zinc-100 hover:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-100 focus:ring-offset-2"
@click="() => startTask(task)"
>
<i18n-t
keypath="tasks.admin.execute"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1"
>
<template #arrow>
<PlayIcon class="size-4" aria-hidden="true" />
</template>
</i18n-t>
</button>
</div>
</div>
</li>
@@ -189,7 +120,7 @@
</div>
</template>
<script lang="ts" setup>
import { CheckIcon, XMarkIcon } from "@heroicons/vue/24/outline";
import { PlayIcon } from "@heroicons/vue/24/outline";
import type { TaskGroup } from "~/server/internal/tasks/group";
useHead({
@@ -205,7 +136,9 @@ const { t } = useI18n();
const { runningTasks, historicalTasks, dailyTasks, weeklyTasks } =
await $dropFetch("/api/v1/admin/task");
const liveRunningTasks = await Promise.all(runningTasks.map((e) => useTask(e)));
const liveRunningTasks = ref(
await Promise.all(runningTasks.map((e) => useTask(e))),
);
const scheduledTasks: {
[key in TaskGroup]: { name: string; description: string };
@@ -230,5 +163,19 @@ const scheduledTasks: {
name: "",
description: "",
},
debug: {
name: "Debug Task",
description: "Does debugging things.",
},
};
async function startTask(taskGroup: string) {
const task = await $dropFetch("/api/v1/admin/task", {
method: "POST",
body: { taskGroup },
failTitle: "Failed to start task",
});
const taskRef = await useTask(task.id);
liveRunningTasks.value.push(taskRef);
}
</script>
@@ -0,0 +1,8 @@
-- DropIndex
DROP INDEX "GameTag_name_idx";
-- AlterTable
ALTER TABLE "APIToken" ADD COLUMN "expiresAt" TIMESTAMP(3);
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));
+2
View File
@@ -45,6 +45,8 @@ model APIToken {
acls String[]
expiresAt DateTime?
@@index([token])
}
+2 -5
View File
@@ -17,11 +17,8 @@ export default defineEventHandler(async (h3) => {
orderBy: {
versionIndex: "asc",
},
select: {
versionIndex: true,
versionName: true,
platform: true,
delta: true,
omit: {
dropletManifest: true,
},
},
tags: true,
+36 -11
View File
@@ -18,30 +18,55 @@ export default defineEventHandler<{ body: typeof UpdateVersionOrder }>(
const body = await readDropValidatedBody(h3, UpdateVersionOrder);
const gameId = body.id;
// We expect an array of the version names for this game
const versions = body.versions;
const unsortedVersions = await prisma.gameVersion.findMany({
where: {
versionName: { in: body.versions },
},
select: {
versionName: true,
versionIndex: true,
delta: true,
platform: true,
},
});
const newVersions = await prisma.$transaction(
versions.map((versionName, versionIndex) =>
const versions = body.versions
.map((e) => unsortedVersions.find((v) => v.versionName === e))
.filter((e) => e !== undefined);
if (versions.length !== unsortedVersions.length)
throw createError({
statusCode: 500,
statusMessage: "Sorting versions yielded less results, somehow.",
});
// Validate the new order
const has: { [key: string]: boolean } = {};
for (const version of versions) {
if (version.delta && !has[version.platform])
throw createError({
statusCode: 400,
statusMessage: `"${version.versionName}" requires a base version to apply the delta to.`,
});
has[version.platform] = true;
}
await prisma.$transaction(
versions.map((version, versionIndex) =>
prisma.gameVersion.update({
where: {
gameId_versionName: {
gameId: gameId,
versionName: versionName,
versionName: version.versionName,
},
},
data: {
versionIndex: versionIndex,
},
select: {
versionIndex: true,
versionName: true,
platform: true,
delta: true,
},
}),
),
);
return newVersions;
return versions;
},
);
+2 -1
View File
@@ -7,8 +7,9 @@ export default defineEventHandler(async (h3) => {
const unimportedGames = await libraryManager.fetchUnimportedGames();
const games = await libraryManager.fetchGamesWithStatus();
const libraries = await libraryManager.fetchLibraries();
// Fetch other library data here
return { unimportedGames, games };
return { unimportedGames, games, hasLibraries: libraries.length > 0 };
});
+3 -2
View File
@@ -1,5 +1,6 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import type { TaskMessage } from "~/server/internal/tasks";
import taskHandler from "~/server/internal/tasks";
export default defineEventHandler(async (h3) => {
@@ -13,7 +14,7 @@ export default defineEventHandler(async (h3) => {
});
const runningTasks = (await taskHandler.runningTasks()).map((e) => e.id);
const historicalTasks = await prisma.task.findMany({
const historicalTasks = (await prisma.task.findMany({
where: {
OR: [
{
@@ -28,7 +29,7 @@ export default defineEventHandler(async (h3) => {
ended: "desc",
},
take: 10,
});
})) as Array<TaskMessage>;
const dailyTasks = await taskHandler.dailyTasks();
const weeklyTasks = await taskHandler.weeklyTasks();
+31
View File
@@ -0,0 +1,31 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls";
import taskHandler from "~/server/internal/tasks";
import type { TaskGroup } from "~/server/internal/tasks/group";
import { taskGroups } from "~/server/internal/tasks/group";
const StartTask = type({
taskGroup: type("string"),
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["task:start"]);
if (!allowed) throw createError({ statusCode: 403 });
const body = await readDropValidatedBody(h3, StartTask);
const taskGroup = body.taskGroup as TaskGroup;
if (!taskGroups[taskGroup])
throw createError({
statusCode: 400,
statusMessage: "Invalid task group.",
});
const task = await taskHandler.runTaskGroupByName(taskGroup);
if (!task)
throw createError({
statusCode: 500,
statusMessage: "Could not start task.",
});
return { id: task };
});
@@ -0,0 +1,23 @@
import { APITokenMode } from "~/prisma/client/enums";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, []); // No ACLs only allows session authentication
if (!allowed) throw createError({ statusCode: 403 });
const id = h3.context.params?.id;
if (!id)
throw createError({
statusCode: 400,
statusMessage: "No id in router params",
});
const deleted = await prisma.aPIToken.delete({
where: { id: id, mode: APITokenMode.System },
})!;
if (!deleted)
throw createError({ statusCode: 404, statusMessage: "Token not found" });
return;
});
+9
View File
@@ -0,0 +1,9 @@
import aclManager from "~/server/internal/acls";
import { systemACLDescriptions } from "~/server/internal/acls/descriptions";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, []); // No ACLs only allows session authentication
if (!allowed) throw createError({ statusCode: 403 });
return systemACLDescriptions;
});
+15
View File
@@ -0,0 +1,15 @@
import { APITokenMode } from "~/prisma/client/enums";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, []); // No ACLs only allows session authentication
if (!allowed) throw createError({ statusCode: 403 });
const tokens = await prisma.aPIToken.findMany({
where: { mode: APITokenMode.System },
omit: { token: true },
});
return tokens;
});
+38
View File
@@ -0,0 +1,38 @@
import { type } from "arktype";
import { APITokenMode } from "~/prisma/client/enums";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager, { systemACLs } from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
const CreateToken = type({
name: "string",
acls: "string[] > 0",
expiry: "string.date.iso.parse?",
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, []); // No ACLs only allows session authentication
if (!allowed) throw createError({ statusCode: 403 });
const body = await readDropValidatedBody(h3, CreateToken);
const invalidACLs = body.acls.filter(
(e) => systemACLs.findIndex((v) => e == v) == -1,
);
if (invalidACLs.length > 0)
throw createError({
statusCode: 400,
statusMessage: `Invalid ACLs: ${invalidACLs.join(", ")}`,
});
const token = await prisma.aPIToken.create({
data: {
mode: APITokenMode.System,
name: body.name,
acls: body.acls,
expiresAt: body.expiry ?? null,
},
});
return token;
});
+6
View File
@@ -0,0 +1,6 @@
import aclManager from "~/server/internal/acls";
export default defineEventHandler(async (h3) => {
const acls = await aclManager.fetchAllACLs(h3);
return acls;
});
+13 -20
View File
@@ -1,30 +1,22 @@
import { type } from "arktype";
import { APITokenMode } from "~/prisma/client/enums";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager, { userACLs } from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
const CreateToken = type({
name: "string",
acls: "string[] > 0",
expiry: "string.date.iso.parse?",
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, []); // No ACLs only allows session authentication
if (!userId) throw createError({ statusCode: 403 });
const body = await readBody(h3);
const name: string = body.name;
const acls: string[] = body.acls;
const body = await readDropValidatedBody(h3, CreateToken);
if (!name || typeof name !== "string")
throw createError({
statusCode: 400,
statusMessage: "Token name required",
});
if (!acls || !Array.isArray(acls))
throw createError({ statusCode: 400, statusMessage: "ACLs required" });
if (acls.length == 0)
throw createError({
statusCode: 400,
statusMessage: "Token requires more than zero ACLs",
});
const invalidACLs = acls.filter(
const invalidACLs = body.acls.filter(
(e) => userACLs.findIndex((v) => e == v) == -1,
);
if (invalidACLs.length > 0)
@@ -36,9 +28,10 @@ export default defineEventHandler(async (h3) => {
const token = await prisma.aPIToken.create({
data: {
mode: APITokenMode.User,
name: name,
name: body.name,
userId: userId,
acls: acls,
acls: body.acls,
expiresAt: body.expiry ?? null,
},
});
+1 -1
View File
@@ -36,7 +36,7 @@ export const userACLDescriptions: ObjectFromList<typeof userACLs> = {
"library:remove": "Remove a game from your library.",
"clients:read": "Read the clients connected to this account",
"clients:revoke": "",
"clients:revoke": "Remove clients connected to this account",
"news:read": "Read the server's news articles.",
+1 -1
View File
@@ -57,7 +57,7 @@ class DownloadContextManager {
async cleanup() {
for (const key of this.contexts.keys()) {
const context = this.contexts.get(key)!;
if (context.timeout.getDate() + TIMEOUT < Date.now()) {
if (context.timeout.getTime() < Date.now() - TIMEOUT) {
this.contexts.delete(key);
}
}
+14 -3
View File
@@ -14,13 +14,18 @@ import notificationSystem from "../notifications";
import { GameNotFoundError, type LibraryProvider } from "./provider";
import { logger } from "../logging";
import type { GameModel } from "~/prisma/client/models";
import { createHash } from "node:crypto";
export function createGameImportTaskId(libraryId: string, libraryPath: string) {
return btoa(`import:${libraryId}:${libraryPath}`);
return createHash("md5")
.update(`import:${libraryId}:${libraryPath}`)
.digest("hex");
}
export function createVersionImportTaskId(gameId: string, versionName: string) {
return btoa(`import:${gameId}:${versionName}`);
return createHash("md5")
.update(`import:${gameId}:${versionName}`)
.digest("hex");
}
class LibraryManager {
@@ -111,7 +116,11 @@ class LibraryManager {
async fetchGamesWithStatus() {
const games = await prisma.game.findMany({
include: {
versions: true,
versions: {
select: {
versionName: true,
},
},
library: true,
},
orderBy: {
@@ -162,6 +171,8 @@ class LibraryManager {
".sh",
// No extension is common for Linux binaries
"",
// AppImages
".appimage",
],
Windows: [".exe", ".bat"],
macOS: [
+3
View File
@@ -14,6 +14,9 @@ export const taskGroups = {
"import:game": {
concurrency: true,
},
debug: {
concurrency: true,
},
} as const;
export type TaskGroup = keyof typeof taskGroups;
+17 -7
View File
@@ -53,6 +53,7 @@ class TaskHandler {
"cleanup:invitations",
"cleanup:sessions",
"check:update",
"debug",
];
private weeklyScheduledTasks: TaskGroup[] = ["cleanup:objects"];
@@ -62,6 +63,7 @@ class TaskHandler {
this.saveScheduledTask(cleanupSessions);
this.saveScheduledTask(checkUpdate);
this.saveScheduledTask(cleanupObjects);
//this.saveScheduledTask(debug);
}
/**
@@ -162,6 +164,13 @@ class TaskHandler {
// You can configure timestamp, level, etc. here
timestamp: pino.stdTimeFunctions.isoTime,
base: null, // Remove pid/hostname if not needed
formatters: {
level(label) {
return {
level: label,
};
},
},
},
logStream,
);
@@ -339,13 +348,15 @@ class TaskHandler {
return this.weeklyScheduledTasks;
}
runTaskGroupByName(name: TaskGroup) {
const task = this.taskCreators.get(name);
if (!task) {
async runTaskGroupByName(name: TaskGroup) {
const taskConstructor = this.taskCreators.get(name);
if (!taskConstructor) {
logger.warn(`No task found for group ${name}`);
return;
}
this.create(task());
const task = taskConstructor();
await this.create(task);
return task.id;
}
/**
@@ -444,7 +455,7 @@ export type TaskMessage = {
name: string;
success: boolean;
progress: number;
error: undefined | { title: string; description: string };
error: null | undefined | { title: string; description: string };
log: string[];
reset?: boolean;
};
@@ -470,6 +481,7 @@ interface DropTask {
export const TaskLog = type({
timestamp: "string",
message: "string",
level: "string",
});
// /**
@@ -499,8 +511,6 @@ export const TaskLog = type({
// }
export function defineDropTask(buildTask: BuildTask): DropTask {
// TODO: only let one task with the same taskGroup run at the same time if specified
return {
taskGroup: buildTask.taskGroup,
build: () => ({
+18
View File
@@ -0,0 +1,18 @@
import { defineDropTask } from "..";
export default defineDropTask({
buildId: () => `debug:${new Date().toISOString()}`,
name: "Debug Task",
acls: ["system:maintenance:read"],
taskGroup: "debug",
async run({ progress, logger }) {
const amount = 1000;
for (let i = 0; i < amount; i++) {
progress((i / amount) * 100);
logger.info(`dajksdkajd ${i}`);
logger.warn("warning");
logger.error("error\nmultiline and stuff\nwoah more lines");
await new Promise((r) => setTimeout(r, 1500));
}
},
});
+19 -1
View File
@@ -1,13 +1,31 @@
import type { TaskLog } from "~/server/internal/tasks";
const labelNumberMap = {
100: "silent",
60: "fatal",
50: "error",
40: "warn",
30: "info",
20: "debug",
10: "trace",
0: "off",
};
export function parseTaskLog(
logStr?: string | undefined,
): typeof TaskLog.infer {
if (!logStr) return { message: "", timestamp: "" };
if (!logStr) return { message: "", timestamp: "", level: "" };
const log = JSON.parse(logStr);
if (typeof log.level === "number") {
log.level = labelNumberMap[
log.level as keyof typeof labelNumberMap
] as string;
}
return {
message: log.msg,
timestamp: log.time,
level: log.level,
};
}
+58 -58
View File
@@ -342,71 +342,71 @@
jsonfile "^5.0.0"
universalify "^0.1.2"
"@drop-oss/droplet-darwin-arm64@2.3.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-arm64/-/droplet-darwin-arm64-2.3.0.tgz#f4f0ded9c9f5b5cac25dd56f59817e1c13e865ab"
integrity sha512-5k1VwGZTFc61FvKyL4cvYxFYB7aCY5cWCo0Q7yTkkj+KR+ewH6ucylU8kDG7M+aBLvbC/zbntXUp4RtYZi4AZQ==
"@drop-oss/droplet-darwin-arm64@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-arm64/-/droplet-darwin-arm64-3.0.1.tgz#37acbeaedcf28623c18b545aa2ed9205533a7128"
integrity sha512-LXe8vsXUBL96boI78H6oXpSaPVwF4cCwJ5l/QVtsOWMebNo6gk9wICDZ+5IoR/Ol32t1a1lk+DjbD1zeGenPxg==
"@drop-oss/droplet-darwin-universal@2.3.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-universal/-/droplet-darwin-universal-2.3.0.tgz#1d8659bc2869e5d30308622bcc6cb230030d738e"
integrity sha512-4V/HMnNtmHgn156pTpa3mVTAwTmO9jqtZrDcVko7PdSotEbXiwBpTFzbgb4bPafbPmkSNoRh4G9d3BLQCh4mgw==
"@drop-oss/droplet-darwin-universal@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-universal/-/droplet-darwin-universal-3.0.1.tgz#8e90214758ae03e2e37501a107e5a8acaeec6d32"
integrity sha512-Mf2gjC24u6s8djV/3slZvwdr4+h0qBu2OYXBUSDfR4H/VJwV5TstnWVKF+U8d1hjmHE9eLO8elbGNnpQmSoTOQ==
"@drop-oss/droplet-darwin-x64@2.3.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-x64/-/droplet-darwin-x64-2.3.0.tgz#c7ff5dae8ba520866b7cd49714625ada8fa0a7c2"
integrity sha512-PUcNjE09N7qEFsbssKxL8rjmCt9AUYPz1yK34d8N2W9DboS1KI+PShWdd/NOk4GYzTJQuJhMp8wNcUrljfqXmQ==
"@drop-oss/droplet-darwin-x64@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-darwin-x64/-/droplet-darwin-x64-3.0.1.tgz#602cf4e7cb1ceda4ef95673f61542025b9215e9a"
integrity sha512-4IIDl/E+hzZ2Vt9m4FMPlZEXwj1EwE6qXyUidACK6TTFqpjLpsEHKuhv1FOxGyJ8qkvagtyPCc+cs1TxoZD6FA==
"@drop-oss/droplet-linux-arm64-gnu@2.3.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-gnu/-/droplet-linux-arm64-gnu-2.3.0.tgz#8819b34c5ff8bd8182c5cd0c3f1784dc2afd9507"
integrity sha512-6VyOwYu9sMrCL82UZOvvjU9G/4wHdA8P6q3+EDIVdABg5jVEYZsxkrT0Kp/5h9Xs0mPFNB/su8ZwB9FRQ63o1w==
"@drop-oss/droplet-linux-arm64-gnu@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-gnu/-/droplet-linux-arm64-gnu-3.0.1.tgz#a49d1998229fafbd42ac4b8fc5f67754ab1ac49c"
integrity sha512-klGvlLf1xSMT3iYsIAaBbmbir1ZJWtcVyOMUlsfc1lkJ8mgyB+PrW4BsnYj7Pp4G34n7WsOChjC8TdJDBBuBWg==
"@drop-oss/droplet-linux-arm64-musl@2.3.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-musl/-/droplet-linux-arm64-musl-2.3.0.tgz#06601aa8af4bffeb26956ff79ed494265e313342"
integrity sha512-2BZreAg1XOBxr+iY2hFcX4x6bFC7AKXkIHa9130rmStH/HxnGq6K5H49eJd6ezzNMH/lQ7Sm7uJP2+sH8mjeCw==
"@drop-oss/droplet-linux-arm64-musl@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-arm64-musl/-/droplet-linux-arm64-musl-3.0.1.tgz#3e0ffee4f0aba051c244236aecdb5c1221c1b999"
integrity sha512-oOjvGETlrJGC1RlNhUoVS9N89Rn/0DqBauVz3BBFjJTKSd5jU3/gLzwgmfkKDGVEU5lyGPAn2WQroiESEG9wdA==
"@drop-oss/droplet-linux-riscv64-gnu@2.3.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-riscv64-gnu/-/droplet-linux-riscv64-gnu-2.3.0.tgz#6d5629631aeeceadb292998e21b6e2b2cf839bdc"
integrity sha512-E7i86Q8IU7rh2FVtXa0NxoGRhB7AZU+AWPumTAuDQS3xPg3sj+c3q/A7YI1Ay4lnvzR/fevP2p/7iSJUEDcchQ==
"@drop-oss/droplet-linux-riscv64-gnu@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-riscv64-gnu/-/droplet-linux-riscv64-gnu-3.0.1.tgz#2208f1a038d54ced68d1537c4daa964b115d4e5c"
integrity sha512-Zf3gUsWq9Hqb275MOi7PJDhmJz7Qa/Y1XMen880bxPaOeDFqFOoKUxUr2/qv1MYp6tT3zO27NprGsHirYWqsyA==
"@drop-oss/droplet-linux-x64-gnu@2.3.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-gnu/-/droplet-linux-x64-gnu-2.3.0.tgz#a924aada38dbc54f6967b17435c10bf3b6e6ffb0"
integrity sha512-eIHhhoSgpvMAc9tn/K0ldZRXvDe1Xyd9okSSqaclCEKjdVfWU8UMycUz1SzQH9YefiqEB4Qjd3y1iRgaEa8niA==
"@drop-oss/droplet-linux-x64-gnu@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-gnu/-/droplet-linux-x64-gnu-3.0.1.tgz#ffe2e39f978d32858a003f0c28614a8a4d1bdeef"
integrity sha512-sskblycJdtNJVnRHjPHhwHkQUfQNaDIWDzXOzEaBPOcDKqYA7od7VMDAseqBkrKDn7l8bBUtRXFAipdsO8hffw==
"@drop-oss/droplet-linux-x64-musl@2.3.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-musl/-/droplet-linux-x64-musl-2.3.0.tgz#4eb71112f7641e1fad3b53f5f8d1b98b9cb84bf0"
integrity sha512-0taR945NvK+xNBicSYriKDJgBxpcozzgcALDp/cX2UaYV9cb5PF/xw80DArCyUDvKOfRzeFALx4KRC2ghPr6tw==
"@drop-oss/droplet-linux-x64-musl@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-linux-x64-musl/-/droplet-linux-x64-musl-3.0.1.tgz#4bd501eeeddfdaf3c49e6508cc1798419b0c78cc"
integrity sha512-lh+1M6UAf5+ET1/ZEFRsB3shFHjkT/9Ql9akr/vyUue91TWPmP71meqVkCugWDhP6lxBt56jg2VVrJfmPAsK6w==
"@drop-oss/droplet-win32-arm64-msvc@2.3.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-arm64-msvc/-/droplet-win32-arm64-msvc-2.3.0.tgz#36568f87024eb48ce7e82d76ea83a2c6ec25a856"
integrity sha512-5HkO98h/PboM+/wPulKVGFTklijlqht8w13iW1ipUcRFsOHmS1o8nejjLL7KEr2X8G4JwYOqBeX8tY3OhaU9bw==
"@drop-oss/droplet-win32-arm64-msvc@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-arm64-msvc/-/droplet-win32-arm64-msvc-3.0.1.tgz#9308c75d22773fbb78bba0286c101870b3eaf5f6"
integrity sha512-caQDPoDNJyyJXUEijw+hGTy0wmCrW5efTqBwnvMcQ282EOilg1d5WeJ31pfEcuLYF4MK1t9uaLcG6jZ9YLtzEQ==
"@drop-oss/droplet-win32-x64-msvc@2.3.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-x64-msvc/-/droplet-win32-x64-msvc-2.3.0.tgz#e794ea7cfdc0ea148707e4f3e60f2aa547328c03"
integrity sha512-6lNXOMyy9sPaO4wbklOIr2jbuvZHIVrd+dXu2UOI2YqFlHdxiDD1sZnqSZmlfCP58yeA+SpTfhxDHwUHJTFI/g==
"@drop-oss/droplet-win32-x64-msvc@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet-win32-x64-msvc/-/droplet-win32-x64-msvc-3.0.1.tgz#3f50f1328bd7aafd8dfe7edd0413f13217cbc9ce"
integrity sha512-bp8KwewF/T3JkVeJWkg86U3b0cGQD9i8k92x6HYPtnF5nLPAb2UIUEJgmYYFNPFe36RECBV7PIIG0ujdT1ELQw==
"@drop-oss/droplet@2.3.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet/-/droplet-2.3.0.tgz#eb2891346cf7fadcc847d5dee37674fc1106d2fc"
integrity sha512-ffEoS3LYBfPm0++p7f7F/NkYH5PfauQzuj1gTz7qVWZOSP5VQWYhOc9BEg0fsCCzTB/mct0jwOsK92URmthpxA==
"@drop-oss/droplet@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@drop-oss/droplet/-/droplet-3.0.1.tgz#e7f6772aa1f94010d41086fc8a1f396a5d392184"
integrity sha512-YhtgpwNqEHO8R03yf9Xb5LXuaLWkQvY+2lxOD1PwzpGI5V9PKlDE+x1IJBmdBF5bDPDGk9MxQidGtnYQuAEBEA==
optionalDependencies:
"@drop-oss/droplet-darwin-arm64" "2.3.0"
"@drop-oss/droplet-darwin-universal" "2.3.0"
"@drop-oss/droplet-darwin-x64" "2.3.0"
"@drop-oss/droplet-linux-arm64-gnu" "2.3.0"
"@drop-oss/droplet-linux-arm64-musl" "2.3.0"
"@drop-oss/droplet-linux-riscv64-gnu" "2.3.0"
"@drop-oss/droplet-linux-x64-gnu" "2.3.0"
"@drop-oss/droplet-linux-x64-musl" "2.3.0"
"@drop-oss/droplet-win32-arm64-msvc" "2.3.0"
"@drop-oss/droplet-win32-x64-msvc" "2.3.0"
"@drop-oss/droplet-darwin-arm64" "3.0.1"
"@drop-oss/droplet-darwin-universal" "3.0.1"
"@drop-oss/droplet-darwin-x64" "3.0.1"
"@drop-oss/droplet-linux-arm64-gnu" "3.0.1"
"@drop-oss/droplet-linux-arm64-musl" "3.0.1"
"@drop-oss/droplet-linux-riscv64-gnu" "3.0.1"
"@drop-oss/droplet-linux-x64-gnu" "3.0.1"
"@drop-oss/droplet-linux-x64-musl" "3.0.1"
"@drop-oss/droplet-win32-arm64-msvc" "3.0.1"
"@drop-oss/droplet-win32-x64-msvc" "3.0.1"
"@emnapi/core@^1.4.3":
version "1.4.5"
@@ -9086,10 +9086,10 @@ vite-plugin-inspect@^11.3.0:
unplugin-utils "^0.2.4"
vite-dev-rpc "^1.1.0"
vite-plugin-static-copy@^3.0.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.1.tgz#25d6f52c9a760d2d2e84d0803a37e3310aed644a"
integrity sha512-oR53SkL5cX4KT1t18E/xU50vJDo0N8oaHza4EMk0Fm+2/u6nQivxavOfrDk3udWj+dizRizB/QnBvJOOQrTTAQ==
vite-plugin-static-copy@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.2.tgz#5d5e6ce965e5da6a326d47a5feb5033d52db43ca"
integrity sha512-aVmYOzptLVOI2b1jL+cmkF7O6uhRv1u5fvOkQgbohWZp2CbR22kn9ZqkCUIt9umKF7UhdbsEpshn1rf4720QFg==
dependencies:
chokidar "^3.6.0"
fs-extra "^11.3.0"