Various bug fixes (#102)

* feat: set lang in html head

* fix: add # in front of git ref

* fix: remove unused vars from example env

* fix: package name and license field

* fix: enable sourcemap for client and server

* fix: emojis not showing in prod

this is extremely cursed, but it works

* chore: refactor auth manager

* feat: disable invitations if simple auth disabled

* feat: add drop version to footer

* feat: translate auth endpoints

* chore: move oidc module

* feat: add weekly tasks

enabled object cleanup as weekly task

* feat: add timestamp to task log msgs

* feat: add guard to prevent invalid progress %

* fix: add missing global scope to i18n components

* feat: set base url for i18n

* feat: switch task log to json format

* ci: run ci on develop branch only

* fix: UserWidget text not updating #109

* fix: EXTERNAL_URL being computed at build

* feat: add basic language outlines for translation

* feat: add more english dialects
This commit is contained in:
Husky
2025-06-07 23:49:43 -04:00
committed by GitHub
parent 9f5a3b3976
commit 72ae7a2884
43 changed files with 577 additions and 229 deletions

View File

@ -1,8 +1,5 @@
DATABASE_URL="postgres://drop:drop@127.0.0.1:5432/drop"
CLIENT_CERTIFICATES="./.data/ca"
FS_BACKEND_PATH="./.data/objects"
GIANT_BOMB_API_KEY=""
EXTERNAL_URL="localhost:3000"

View File

@ -1,6 +1,12 @@
name: CI
on: [pull_request, push]
on:
push:
branches:
- develop
pull_request:
branches:
- develop
jobs:
typecheck:

View File

@ -31,6 +31,7 @@
],
"i18n-ally.extract.ignoredByFiles": {
"pages/admin/library/sources/index.vue": ["Filesystem"],
"components/NewsArticleCreateButton.vue": ["[", "`", "Enter"]
"components/NewsArticleCreateButton.vue": ["[", "`", "Enter"],
"server/api/v1/auth/signin/simple.post.ts": ["boolean | undefined"]
}
}

View File

@ -9,9 +9,7 @@ const props = defineProps<{
emoji: string;
}>();
const emojiEl = ref<HTMLElement | null>(null);
const url = computed(() => {
return `/api/v1/emojis/${twemoji.convert.toCodePoint(props.emoji)}.svg`;
return `/twemoji/${twemoji.convert.toCodePoint(props.emoji)}.svg`;
});
</script>

View File

@ -9,9 +9,10 @@
class="grid w-full cursor-default grid-cols-1 rounded-md bg-zinc-900 py-1.5 pr-2 pl-3 text-left text-zinc-300 outline-1 -outline-offset-1 outline-zinc-700 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
>
<span class="col-start-1 row-start-1 flex items-center gap-3 pr-6">
<span alt="" class="-mt-0.5 shrink-0 rounded-full">{{
localeToEmoji(wiredLocale)
}}</span>
<EmojiText
:emoji="localeToEmoji(wiredLocale)"
class="-mt-0.5 shrink-0 max-w-6"
/>
<span class="block truncate">{{
currentLocaleInformation?.name ?? wiredLocale
}}</span>
@ -46,9 +47,10 @@
]"
>
<div class="flex items-center">
<span class="-mt-0.5 shrink-0 rounded-full">
{{ localeToEmoji(listLocale.code) }}
</span>
<EmojiText
:emoji="localeToEmoji(listLocale.code)"
class="-mt-0.5 shrink-0 max-w-6"
/>
<span
:class="[
selected ? 'font-semibold' : 'font-normal',
@ -106,21 +108,50 @@ import {
} from "@headlessui/vue";
import { ChevronUpDownIcon } from "@heroicons/vue/16/solid";
import { ArrowTopRightOnSquareIcon } from "@heroicons/vue/24/outline";
import type { Locale } from "vue-i18n";
const { locales, locale, setLocale } = useI18n();
const { locales, locale: currLocale, setLocale } = useI18n();
function changeLocale(locale: Locale) {
setLocale(locale);
// dynamically update the HTML attributes for language and direction
// this is necessary for proper rendering of the page in the new language
useHead({
htmlAttrs: {
lang: locale,
dir: locales.value.find((l) => l.code === locale)?.dir || "ltr",
},
});
}
function localeToEmoji(local: string): string {
switch (local) {
// Default locale
case "en":
case "en-gb":
case "en-ca":
case "en-au":
case "en-us": {
case "en-us":
return "🇺🇸";
}
case "en-pirate": {
case "en-gb":
return "🇬🇧";
case "en-ca":
return "🇨🇦";
case "en-au":
return "🇦🇺";
case "en-pirate":
return "🏴‍☠️";
}
case "fr":
return "🇫🇷";
case "de":
return "🇩🇪";
case "es":
return "🇪🇸";
case "it":
return "🇮🇹";
case "zh":
return "🇨🇳";
case "zh-tw":
return "🇹🇼";
default: {
return "❓";
@ -130,10 +161,10 @@ function localeToEmoji(local: string): string {
const wiredLocale = computed({
get() {
return locale.value;
return currLocale.value;
},
set(v) {
setLocale(v);
changeLocale(v);
},
});
const currentLocaleInformation = computed(() =>

View File

@ -2,6 +2,7 @@
<footer class="bg-zinc-950" aria-labelledby="footer-heading">
<h2 id="footer-heading" class="sr-only">{{ $t("footer.footer") }}</h2>
<div class="mx-auto max-w-7xl px-6 py-16 sm:py-24 lg:px-8">
<!-- Drop Info -->
<div class="xl:grid xl:grid-cols-3 xl:gap-8">
<div class="space-y-8">
<DropWordmark class="h-10" />
@ -24,6 +25,8 @@
</NuxtLink>
</div>
</div>
<!-- Foot links -->
<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>
@ -86,6 +89,21 @@
</div>
</div>
</div>
<div class="flex items-center justify-center xl:col-span-3 mt-8">
<p
class="text-xs text-zinc-700 hover:text-zinc-400 transition-colors duration-200 cursor-default select-none"
>
<i18n-t keypath="footer.version" tag="p" scope="global">
<template #version>
<span>{{ versionInfo.version }}</span>
</template>
<template #gitRef>
<span>{{ versionInfo.gitRef }}</span>
</template>
</i18n-t>
</p>
</div>
</div>
</div>
</footer>
@ -96,6 +114,8 @@ import { IconsDiscordLogo, IconsGithubLogo } from "#components";
const { t } = useI18n();
const versionInfo = await $dropFetch("/api/v1");
const navigation = {
games: [
{ name: t("store.recentlyAdded"), href: "#" },

View File

@ -85,20 +85,21 @@ import { useObject } from "~/composables/objects";
import type { NavigationItem } from "~/composables/types";
const user = useUser();
const { t } = useI18n();
const navigation: NavigationItem[] = [
user.value?.admin
? {
label: t("userHeader.profile.admin"),
route: "/admin",
prefix: "",
}
: undefined,
{
label: t("userHeader.profile.settings"),
route: "/account",
prefix: "",
},
].filter((e) => e !== undefined);
const navigation = computed<NavigationItem[]>(() =>
[
user.value?.admin
? {
label: $t("userHeader.profile.admin"),
route: "/admin",
prefix: "",
}
: undefined,
{
label: $t("userHeader.profile.settings"),
route: "/account",
prefix: "",
},
].filter((e) => e !== undefined),
);
</script>

View File

@ -21,7 +21,15 @@ export default defineI18nConfig(() => {
// https://vue-i18n.intlify.dev/guide/essentials/datetime.html
datetimeFormats: {
"en-us": defaultDateTimeFormat,
"en-gb": defaultDateTimeFormat,
"en-au": defaultDateTimeFormat,
"en-pirate": defaultDateTimeFormat,
fr: defaultDateTimeFormat,
de: defaultDateTimeFormat,
it: defaultDateTimeFormat,
es: defaultDateTimeFormat,
zh: defaultDateTimeFormat,
"zh-tw": defaultDateTimeFormat,
},
};
});

1
i18n/locales/de.json Normal file
View File

@ -0,0 +1 @@
{}

1
i18n/locales/en_au.json Normal file
View File

@ -0,0 +1 @@
{}

1
i18n/locales/en_gb.json Normal file
View File

@ -0,0 +1 @@
{}

View File

@ -107,6 +107,17 @@
"listItemPlaceholder": "list item"
},
"errors": {
"auth": {
"method": {
"signinDisabled": "Sign in method not enabled"
},
"invalidUserOrPass": "Invalid username or password.",
"disabled": "Invalid or disabled account. Please contact the server administrator.",
"invalidPassState": "Invalid password state. Please contact the server administrator.",
"inviteIdRequired": "id required in fetching invitation",
"invalidInvite": "Invalid or expired invitation",
"usernameTaken": "Username already taken."
},
"backHome": "{arrow} Back to home",
"invalidBody": "Invalid request body: {0}",
"inviteRequired": "Invitation required to sign up.",
@ -201,7 +212,8 @@
"discord": "Discord",
"github": "GitHub"
},
"topSellers": "Top Sellers"
"topSellers": "Top Sellers",
"version": "Drop {version} {gitRef}"
},
"header": {
"admin": {
@ -218,17 +230,14 @@
"admin": {
"description": "Manage the users on your Drop instance, and configure your authentication methods.",
"authLink": "Authentication {arrow}",
"displayNameHeader": "Display Name",
"usernameHeader": "Username",
"emailHeader": "Email",
"adminHeader": "Admin?",
"authoptionsHeader": "Auth Options",
"srEditLabel": "Edit",
"adminUserLabel": "Admin user",
"normalUserLabel": "Normal user",
"authentication": {
"title": "Authentication",
"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.",
@ -237,47 +246,33 @@
"disabled": "Disabled",
"srOpenOptions": "Open options",
"configure": "Configure",
"simple": "Simple (username/password)",
"oidc": "OpenID Connect"
},
"simple": {
"title": "Simple authentication",
"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.",
"invitationTitle": "invitations",
"createInvitation": "Create invitation",
"noUsernameEnforced": "No username enforced.",
"noEmailEnforced": "No email enforced.",
"adminInvitation": "Admin invitation",
"userInvitation": "User invitation",
"expires": "Expires: {expiry}",
"neverExpires": "Never expires.",
"noInvitations": "No invitations.",
"inviteTitle": "Invite user to Drop",
"inviteDescription": "Drop will generate a URL that you can send to the person you want to invite. You can optionally specify a username or email for them to use.",
"inviteUsernameLabel": "Username (optional)",
"inviteUsernameFormat": "Must be 5 or more characters",
"inviteUsernamePlaceholder": "myUsername",
"inviteEmailLabel": "Email address (optional)",
"inviteEmailDescription": "Must be in the format user{'@'}example.com",
"inviteEmailPlaceholder": "me{'@'}example.com",
"inviteAdminSwitchLabel": "Admin invitation",
"inviteAdminSwitchDescription": "Create this user as an administrator",
"inviteExpiryLabel": "Expires",
"inviteButton": "Invite",
"invite3Days": "3 days",
"inviteWeek": "1 week",
"inviteMonth": "1 month",
@ -347,18 +342,14 @@
"imageCarouselEmpty": "No images added to the carousel yet.",
"removeImageCarousel": "Remove image",
"addCarouselNoImages": "No images to add.",
"imageLibrary": "Image library",
"imageLibraryDescription": "Please note all images uploaded are accessible to all users through browser dev-tools.",
"setBanner": "Set as banner",
"setCover": "Set as cover",
"deleteImage": "Delete image",
"currentBanner": "banner",
"currentCover": "cover",
"addDescriptionNoImages": "No images to add.",
"editGameName": "Game Name",
"editGameDescription": "Game Description"
},
@ -407,23 +398,19 @@
"scheduled": {
"cleanupInvitationsName": "Clean up invitations",
"cleanupInvitationsDescription": "Cleans up expired invitations from the database to save space.",
"cleanupObjectsName": "Clean up objects",
"cleanupObjectsDescription": "Detects and deletes unreferenced and unused objects to save space.",
"cleanupSessionsName": "Clean up sessions.",
"cleanupSessionsDescription": "Cleans up expired sessions to save space and ensure security.",
"checkUpdateName": "Check update.",
"checkUpdateDescription": "Check if Drop has an update."
},
"runningTasksTitle": "Running tasks",
"noTasksRunning": "No tasks currently running",
"completedTasksTitle": "Completed tasks",
"dailyScheduledTitle": "Daily scheduled tasks",
"weeklyScheduledTitle": "Weekly scheduled tasks",
"viewTask": "View {arrow}",
"back": "{arrow} Back to Tasks"
}
},

1
i18n/locales/es.json Normal file
View File

@ -0,0 +1 @@
{}

1
i18n/locales/fr.json Normal file
View File

@ -0,0 +1 @@
{}

1
i18n/locales/it.json Normal file
View File

@ -0,0 +1 @@
{}

1
i18n/locales/zh.json Normal file
View File

@ -0,0 +1 @@
{}

1
i18n/locales/zh_tw.json Normal file
View File

@ -0,0 +1 @@
{}

View File

@ -170,36 +170,36 @@ import { useCurrentNavigationIndex } from "~/composables/current-page-engine";
import { ArrowLeftIcon } from "@heroicons/vue/16/solid";
import { XMarkIcon } from "@heroicons/vue/24/solid";
const { t } = useI18n();
const i18nHead = useLocaleHead();
const navigation: Array<NavigationItem & { icon: Component }> = [
{ label: t("home"), route: "/admin", prefix: "/admin", icon: HomeIcon },
{ label: $t("home"), route: "/admin", prefix: "/admin", icon: HomeIcon },
{
label: t("userHeader.links.library"),
label: $t("userHeader.links.library"),
route: "/admin/library",
prefix: "/admin/library",
icon: ServerStackIcon,
},
{
label: t("header.admin.users"),
label: $t("header.admin.users"),
route: "/admin/users",
prefix: "/admin/users",
icon: UserGroupIcon,
},
{
label: t("header.admin.tasks"),
label: $t("header.admin.tasks"),
route: "/admin/task",
prefix: "/admin/task",
icon: RectangleStackIcon,
},
{
label: t("settings"),
label: $t("settings"),
route: "/admin/settings",
prefix: "/admin/settings",
icon: Cog6ToothIcon,
},
{
label: t("header.back"),
label: $t("header.back"),
route: "/store",
prefix: ".",
icon: ArrowLeftIcon,
@ -221,11 +221,12 @@ router.afterEach(() => {
useHead({
htmlAttrs: {
lang: "en",
lang: i18nHead.value.htmlAttrs.lang,
// @ts-expect-error head.value.htmlAttrs.dir is not typed as strictly as it should be
dir: i18nHead.value.htmlAttrs.dir,
},
link: [],
titleTemplate(title) {
return title ? t("adminTitleTemplate", [title]) : t("adminTitle");
return title ? $t("adminTitleTemplate", [title]) : $t("adminTitle");
},
});
</script>

View File

@ -13,15 +13,20 @@
<script setup lang="ts">
const route = useRoute();
const i18nHead = useLocaleHead();
const noWrapper = !!route.query.noWrapper;
const { t } = useI18n();
useHead({
htmlAttrs: {
lang: "en",
lang: i18nHead.value.htmlAttrs.lang,
// @ts-expect-error head.value.htmlAttrs.dir is not typed as strictly as it should be
dir: i18nHead.value.htmlAttrs.dir,
},
link: [],
// // seo headers
// link: [...i18nHead.value.link],
// meta: [...i18nHead.value.meta],
titleTemplate(title) {
return title ? t("titleTemplate", [title]) : t("title");
},

View File

@ -1,5 +1,9 @@
import tailwindcss from "@tailwindcss/vite";
import { execSync } from "node:child_process";
import { cpSync } from "node:fs";
import path from "node:path";
import module from "module";
import { viteStaticCopy } from "vite-plugin-static-copy";
// get drop version
const dropVersion = process.env.BUILD_DROP_VERSION ?? "v0.3.0-alpha.1";
@ -12,6 +16,14 @@ const commitHash =
console.log(`Building Drop ${dropVersion} #${commitHash}`);
const twemojiJson = module.findPackageJSON(
"@discordapp/twemoji",
import.meta.url,
);
if (!twemojiJson) {
throw new Error("Could not find @discordapp/twemoji package.");
}
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
extends: ["./drop-base"],
@ -40,6 +52,11 @@ export default defineNuxtConfig({
},
css: ["~/assets/tailwindcss.css", "~/assets/core.scss"],
sourcemap: {
server: true,
client: true,
},
experimental: {
buildCache: true,
viewTransition: true,
@ -51,7 +68,31 @@ export default defineNuxtConfig({
// },
vite: {
plugins: [tailwindcss()],
plugins: [
tailwindcss(),
// only used in dev server, not build because nitro sucks
// see build hook below
viteStaticCopy({
targets: [
{
src: "node_modules/@discordapp/twemoji/dist/svg/*",
dest: "twemoji",
},
],
}),
],
},
hooks: {
"nitro:build:public-assets": (nitro) => {
// this is only run during build, not dev server
// https://github.com/nuxt/nuxt/issues/18918#issuecomment-1925774964
// copy emojis to .output/public/twemoji
const targetDir = path.join(nitro.options.output.publicDir, "twemoji");
cpSync(path.join(path.dirname(twemojiJson), "dist", "svg"), targetDir, {
recursive: true,
});
},
},
runtimeConfig: {
@ -130,6 +171,7 @@ export default defineNuxtConfig({
strategy: "no_prefix",
experimental: {
localeDetector: "localeDetector.ts",
autoImportTranslationFunctions: true,
},
detectBrowserLanguage: {
useCookie: true,
@ -137,12 +179,61 @@ export default defineNuxtConfig({
fallbackLocale: "en-us",
},
locales: [
{ code: "en-us", name: "English", file: "en_us.json" },
{ code: "en-us", language: "en-us", name: "English", file: "en_us.json" },
{
code: "en-gb",
language: "en-gb",
name: "English (UK)",
file: "en_gb.json",
},
{
code: "en-au",
language: "en-au",
name: "English (Australia)",
file: "en_au.json",
},
{
code: "en-pirate",
language: "en-pirate",
name: "English (Pirate)",
file: "en_pirate.json",
},
{
code: "fr",
language: "fr",
name: "French",
file: "fr.json",
},
{
code: "de",
language: "de",
name: "German",
file: "de.json",
},
{
code: "it",
language: "it",
name: "Italian",
file: "it.json",
},
{
code: "es",
language: "es",
name: "Spanish",
file: "es.json",
},
{
code: "zh",
language: "zh",
name: "Chinese",
file: "zh.json",
},
{
code: "zh-tw",
language: "zh-tw",
name: "Chinese (Taiwan)",
file: "zh_tw.json",
},
],
},

View File

@ -1,8 +1,9 @@
{
"name": "nuxt-app",
"name": "drop",
"version": "0.3.0",
"private": true,
"type": "module",
"license": "AGPL-3.0-or-later",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
@ -45,6 +46,7 @@
"stream-mime-type": "^2.0.0",
"turndown": "^7.2.0",
"unstorage": "^1.15.0",
"vite-plugin-static-copy": "^3.0.0",
"vue": "latest",
"vue-router": "latest",
"vue3-carousel": "^0.15.0",

View File

@ -14,7 +14,11 @@
to="/admin/library/sources"
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t keypath="library.admin.sources.link" tag="span">
<i18n-t
keypath="library.admin.sources.link"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
@ -39,7 +43,11 @@
href="/admin/library/import"
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
>
<i18n-t keypath="library.admin.import.link" tag="span">
<i18n-t
keypath="library.admin.import.link"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
@ -140,7 +148,11 @@
:href="`/admin/library/${game.id}/import`"
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
>
<i18n-t keypath="library.admin.import.link" tag="span">
<i18n-t
keypath="library.admin.import.link"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>

View File

@ -54,7 +54,9 @@
<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">{{ line }}</pre>
<pre v-for="(line, idx) in task.log" :key="idx">{{
formatLine(line)
}}</pre>
</div>
</div>
<div v-else role="status" class="w-full flex items-center justify-center">
@ -88,6 +90,11 @@ 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",
});

View File

@ -115,7 +115,7 @@
{{ task.id }}
</p>
<p class="mt-1 truncate text-sm text-zinc-400">
{{ task.log.at(-1) }}
{{ parseTaskLog(task.log.at(-1) ?? "").message }}
</p>
<NuxtLink
type="button"
@ -151,11 +151,34 @@
<div class="flex-1">
<div class="flex items-center space-x-2">
<h3 class="text-sm font-medium text-zinc-100">
{{ dailyScheduledTasks[task].name }}
{{ scheduledTasks[task].name }}
</h3>
</div>
<p class="mt-1 text-sm text-zinc-400">
{{ dailyScheduledTasks[task].description }}
{{ scheduledTasks[task].description }}
</p>
</div>
</div>
</li>
</ul>
<h2 class="text-sm font-medium text-zinc-400 mt-8">
{{ $t("tasks.admin.weeklyScheduledTitle") }}
</h2>
<ul role="list" class="mt-4 grid grid-cols-1 lg:grid-cols-2 gap-6">
<li
v-for="task in weeklyTasks"
:key="task"
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">
<div class="flex items-center space-x-2">
<h3 class="text-sm font-medium text-zinc-100">
{{ scheduledTasks[task].name }}
</h3>
</div>
<p class="mt-1 text-sm text-zinc-400">
{{ scheduledTasks[task].description }}
</p>
</div>
</div>
@ -179,12 +202,12 @@ definePageMeta({
const { t } = useI18n();
const { runningTasks, historicalTasks, dailyTasks } =
const { runningTasks, historicalTasks, dailyTasks, weeklyTasks } =
await $dropFetch("/api/v1/admin/task");
const liveRunningTasks = await Promise.all(runningTasks.map((e) => useTask(e)));
const dailyScheduledTasks: {
const scheduledTasks: {
[key in TaskGroup]: { name: string; description: string };
} = {
"cleanup:invitations": {

View File

@ -72,7 +72,7 @@
{{ $t("store.recentlyReleased") }}
</h1>
<NuxtLink class="text-blue-600 font-semibold">
<i18n-t keypath="store.exploreMore" tag="span">
<i18n-t keypath="store.exploreMore" tag="span" scope="global">
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
@ -89,7 +89,7 @@
{{ $t("store.recentlyUpdated") }}
</h1>
<NuxtLink class="text-blue-600 font-semibold">
<i18n-t keypath="store.exploreMore" tag="span">
<i18n-t keypath="store.exploreMore" tag="span" scope="global">
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>

View File

@ -1,11 +1,13 @@
import { AuthMec } from "~/prisma/client";
import aclManager from "~/server/internal/acls";
import { enabledAuthManagers } from "~/server/plugins/04.auth-init";
import authManager from "~/server/internal/auth";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["auth:read"]);
if (!allowed) throw createError({ statusCode: 403 });
const enabledAuthManagers = authManager.getAuthProviders();
const authData = {
[AuthMec.Simple]: enabledAuthManagers.Simple,
[AuthMec.OpenID]:

View File

@ -30,6 +30,7 @@ export default defineEventHandler(async (h3) => {
take: 10,
});
const dailyTasks = await taskHandler.dailyTasks();
const weeklyTasks = await taskHandler.weeklyTasks();
return { runningTasks, historicalTasks, dailyTasks };
return { runningTasks, historicalTasks, dailyTasks, weeklyTasks };
});

View File

@ -1,9 +1,5 @@
import { enabledAuthManagers } from "~/server/plugins/04.auth-init";
import authManager from "~/server/internal/auth";
export default defineEventHandler(() => {
const authManagers = Object.entries(enabledAuthManagers)
.filter((e) => !!e[1])
.map((e) => e[0]);
return authManagers;
return authManager.getEnabledAuthProviders();
});

View File

@ -2,12 +2,11 @@ import { AuthMec } from "~/prisma/client";
import type { JsonArray } from "@prisma/client/runtime/library";
import { type } from "arktype";
import prisma from "~/server/internal/db/database";
import {
import sessionHandler from "~/server/internal/session";
import authManager, {
checkHashArgon2,
checkHashBcrypt,
} from "~/server/internal/security/simple";
import sessionHandler from "~/server/internal/session";
import { enabledAuthManagers } from "~/server/plugins/04.auth-init";
} from "~/server/internal/auth";
const signinValidator = type({
username: "string",
@ -18,10 +17,12 @@ const signinValidator = type({
export default defineEventHandler<{
body: typeof signinValidator.infer;
}>(async (h3) => {
if (!enabledAuthManagers.Simple)
const t = await useTranslation(h3);
if (!authManager.getAuthProviders().Simple)
throw createError({
statusCode: 403,
statusMessage: "Sign in method not enabled",
statusMessage: t("errors.auth.method.signinDisabled"),
});
const body = signinValidator(await readBody(h3));
@ -55,14 +56,13 @@ export default defineEventHandler<{
if (!authMek)
throw createError({
statusCode: 401,
statusMessage: "Invalid username or password.",
statusMessage: t("errors.auth.invalidUserOrPass"),
});
if (!authMek.user.enabled)
throw createError({
statusCode: 403,
statusMessage:
"Invalid or disabled account. Please contact the server administrator.",
statusMessage: t("errors.auth.disabled"),
});
// LEGACY bcrypt
@ -72,15 +72,14 @@ export default defineEventHandler<{
if (!hash)
throw createError({
statusCode: 403,
statusMessage:
"Invalid password state. Please contact the server administrator.",
statusCode: 500,
statusMessage: t("errors.auth.invalidPassState"),
});
if (!(await checkHashBcrypt(body.password, hash)))
throw createError({
statusCode: 401,
statusMessage: "Invalid username or password.",
statusMessage: t("errors.auth.invalidUserOrPass"),
});
// TODO: send user to forgot password screen or something to force them to change their password to new system
@ -93,14 +92,13 @@ export default defineEventHandler<{
if (!hash || typeof hash !== "string")
throw createError({
statusCode: 500,
statusMessage:
"Invalid password state. Please contact the server administrator.",
statusMessage: t("errors.auth.invalidPassState"),
});
if (!(await checkHashArgon2(body.password, hash)))
throw createError({
statusCode: 401,
statusMessage: "Invalid username or password.",
statusMessage: t("errors.auth.invalidUserOrPass"),
});
await sessionHandler.signin(h3, authMek.userId, body.rememberMe);

View File

@ -1,13 +1,22 @@
import prisma from "~/server/internal/db/database";
import taskHandler from "~/server/internal/tasks";
import authManager from "~/server/internal/auth";
export default defineEventHandler(async (h3) => {
const t = await useTranslation(h3);
if (!authManager.getAuthProviders().Simple)
throw createError({
statusCode: 403,
statusMessage: t("errors.auth.method.signinDisabled"),
});
const query = getQuery(h3);
const id = query.id?.toString();
if (!id)
throw createError({
statusCode: 400,
statusMessage: "id required in fetching invitation",
statusMessage: t("errors.auth.inviteIdRequired"),
});
taskHandler.runTaskGroupByName("cleanup:invitations");
@ -15,7 +24,7 @@ export default defineEventHandler(async (h3) => {
if (!invitation)
throw createError({
statusCode: 404,
statusMessage: "Invalid or expired invitation",
statusMessage: t("errors.auth.invalidInvite"),
});
return invitation;

View File

@ -1,6 +1,6 @@
import { AuthMec } from "~/prisma/client";
import prisma from "~/server/internal/db/database";
import { createHashArgon2 } from "~/server/internal/security/simple";
import authManager, { createHashArgon2 } from "~/server/internal/auth";
import * as jdenticon from "jdenticon";
import objectHandler from "~/server/internal/objects";
import { type } from "arktype";
@ -18,13 +18,21 @@ export const CreateUserValidator = type({
export default defineEventHandler<{
body: typeof CreateUserValidator.infer;
}>(async (h3) => {
const t = await useTranslation(h3);
if (!authManager.getAuthProviders().Simple)
throw createError({
statusCode: 403,
statusMessage: t("errors.auth.method.signinDisabled"),
});
const user = await readValidatedBody(h3, CreateUserValidator);
const invitationId = user.invitation;
if (!invitationId)
throw createError({
statusCode: 401,
statusMessage: "Invalid or expired invitation.",
statusMessage: t("errors.auth.invalidInvite"),
});
const invitation = await prisma.invitation.findUnique({
@ -33,7 +41,7 @@ export default defineEventHandler<{
if (!invitation)
throw createError({
statusCode: 401,
statusMessage: "Invalid or expired invitation.",
statusMessage: t("errors.auth.invalidInvite"),
});
// reuse items from invite
@ -46,7 +54,7 @@ export default defineEventHandler<{
if (existing > 0)
throw createError({
statusCode: 400,
statusMessage: "Username already taken.",
statusMessage: t("errors.auth.usernameTaken"),
});
const userId = randomUUID();

View File

@ -1,45 +0,0 @@
import path from "path";
import module from "module";
import fs from "fs/promises";
import sanitize from "sanitize-filename";
import aclManager from "~/server/internal/acls";
const twemojiJson = module.findPackageJSON(
"@discordapp/twemoji",
import.meta.url,
);
export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["object:read"]);
if (!userId)
throw createError({
statusCode: 403,
});
if (!twemojiJson)
throw createError({
statusCode: 500,
statusMessage: "Failed to resolve emoji package",
});
const unsafeId = getRouterParam(h3, "id");
if (!unsafeId)
throw createError({ statusCode: 400, statusMessage: "Invalid ID" });
const svgPath = path.join(
path.dirname(twemojiJson),
"dist",
"svg",
sanitize(unsafeId),
);
setHeader(
h3,
"Cache-Control",
// 7 days
"public, max-age=604800, s-maxage=604800",
);
setHeader(h3, "Content-Type", "image/svg+xml");
return await fs.readFile(svgPath);
});

View File

@ -4,6 +4,6 @@ export default defineEventHandler((_h3) => {
return {
appName: "Drop",
version: systemConfig.getDropVersion(),
ref: systemConfig.getGitRef(),
gitRef: `#${systemConfig.getGitRef()}`,
};
});

View File

@ -0,0 +1,62 @@
import { AuthMec } from "~/prisma/client";
import { OIDCManager } from "./oidc";
class AuthManager {
private authProviders: {
[AuthMec.Simple]: boolean;
[AuthMec.OpenID]: OIDCManager | undefined;
} = {
[AuthMec.Simple]: false,
[AuthMec.OpenID]: undefined,
};
private initFuncs: {
[K in keyof typeof this.authProviders]: () => Promise<unknown>;
} = {
[AuthMec.OpenID]: OIDCManager.prototype.create,
[AuthMec.Simple]: async () => {
const disabled = process.env.DISABLE_SIMPLE_AUTH as string | undefined;
return !disabled;
},
};
constructor() {
console.log("AuthManager initialized");
}
async init() {
for (const [key, init] of Object.entries(this.initFuncs)) {
try {
const object = await init();
if (!object) break;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.authProviders as any)[key] = object;
console.log(`enabled auth: ${key}`);
} catch (e) {
console.warn(e);
}
}
// Add every other auth mechanism here, and fall back to simple if none of them are enabled
if (!this.authProviders[AuthMec.OpenID]) {
this.authProviders[AuthMec.Simple] = true;
}
}
getAuthProviders() {
return this.authProviders;
}
getEnabledAuthProviders() {
const authManagers = Object.entries(this.authProviders)
.filter((e) => !!e[1])
.map((e) => e[0]);
return authManagers;
}
}
const authManager = new AuthManager();
export default authManager;
export * from "./passwordHash";

View File

@ -1,10 +1,11 @@
import { randomUUID } from "crypto";
import prisma from "../db/database";
import prisma from "../../db/database";
import type { User } from "~/prisma/client";
import { AuthMec } from "~/prisma/client";
import objectHandler from "../objects";
import objectHandler from "../../objects";
import type { Readable } from "stream";
import * as jdenticon from "jdenticon";
import { systemConfig } from "../../config/sys-conf";
interface OIDCWellKnown {
authorization_endpoint: string;
@ -118,7 +119,7 @@ export class OIDCManager {
const clientId = process.env.OIDC_CLIENT_ID as string | undefined;
const clientSecret = process.env.OIDC_CLIENT_SECRET as string | undefined;
const externalUrl = process.env.EXTERNAL_URL as string | undefined;
const externalUrl = systemConfig.getExternalUrl();
if (!clientId || !clientSecret)
throw new Error("Missing client ID or secret for OIDC");

View File

@ -2,6 +2,7 @@ class SystemConfig {
private libraryFolder = process.env.LIBRARY ?? "./.data/library";
private dataFolder = process.env.DATA ?? "./.data/data";
private externalUrl = process.env.EXTERNAL_URL ?? "http://localhost:3000";
private dropVersion;
private gitRef;
@ -33,6 +34,10 @@ class SystemConfig {
shouldCheckForUpdates() {
return this.checkForUpdates;
}
getExternalUrl() {
return this.externalUrl;
}
}
export const systemConfig = new SystemConfig();

View File

@ -9,6 +9,7 @@ import checkUpdate from "./registry/update";
import cleanupObjects from "./registry/objects";
import { taskGroups, type TaskGroup } from "./group";
import prisma from "../db/database";
import { type } from "arktype";
// a task that has been run
type FinishedTask = {
@ -45,11 +46,12 @@ class TaskHandler {
// list of all clients currently connected to tasks
private clientRegistry = new Map<string, PeerImpl>();
private scheduledTasks: TaskGroup[] = [
private dailyScheduledTasks: TaskGroup[] = [
"cleanup:invitations",
"cleanup:sessions",
"check:update",
];
private weeklyScheduledTasks: TaskGroup[] = ["cleanup:objects"];
constructor() {
// register the cleanup invitations task
@ -124,18 +126,22 @@ class TaskHandler {
}, 100);
});
const progress = (progress: number) => {
const taskEntry = this.taskPool.get(task.id);
if (!taskEntry) return;
taskEntry.progress = progress;
updateAllClients();
};
const log = (entry: string) => {
const taskEntry = this.taskPool.get(task.id);
if (!taskEntry) return;
taskEntry.log.push(entry);
// console.log(`[Task ${task.taskGroup}]: ${entry}`);
taskEntry.log.push(msgWithTimestamp(entry));
updateAllClients();
};
const progress = (progress: number) => {
if (progress < 0 || progress > 100) {
console.error("Progress must be between 0 and 100", { progress });
return;
}
const taskEntry = this.taskPool.get(task.id);
if (!taskEntry) return;
taskEntry.progress = progress;
// log(`Progress: ${progress}%`);
updateAllClients();
};
@ -288,7 +294,11 @@ class TaskHandler {
}
dailyTasks() {
return this.scheduledTasks;
return this.dailyScheduledTasks;
}
weeklyTasks() {
return this.weeklyScheduledTasks;
}
runTaskGroupByName(name: TaskGroup) {
@ -304,7 +314,7 @@ class TaskHandler {
* Runs all daily tasks that are scheduled to run once a day.
*/
async triggerDailyTasks() {
for (const taskGroup of this.scheduledTasks) {
for (const taskGroup of this.dailyScheduledTasks) {
const mostRecent = await prisma.task.findFirst({
where: {
taskGroup,
@ -324,6 +334,32 @@ class TaskHandler {
}
await this.runTaskGroupByName(taskGroup);
}
// After running daily tasks, trigger weekly tasks as well
await this.triggerWeeklyTasks();
}
private async triggerWeeklyTasks() {
for (const taskGroup of this.weeklyScheduledTasks) {
const mostRecent = await prisma.task.findFirst({
where: {
taskGroup,
},
orderBy: {
ended: "desc",
},
});
if (mostRecent) {
const currentTime = Date.now();
const lastRun = mostRecent.ended.getTime();
const difference = currentTime - lastRun;
if (difference < 1000 * 60 * 60 * 24 * 7) {
// If it's been less than one week
continue; // skip
}
}
await this.runTaskGroupByName(taskGroup);
}
}
}
@ -383,6 +419,37 @@ interface DropTask {
build: () => Task;
}
export const TaskLog = type({
timestamp: "string",
message: "string",
});
/**
* Create a log message with a timestamp in the format YYYY-MM-DD HH:mm:ss.SSS UTC
* @param message
* @returns
*/
function msgWithTimestamp(message: string): string {
const now = new Date();
const pad = (n: number, width = 2) => n.toString().padStart(width, "0");
const year = now.getUTCFullYear();
const month = pad(now.getUTCMonth() + 1);
const day = pad(now.getUTCDate());
const hours = pad(now.getUTCHours());
const minutes = pad(now.getUTCMinutes());
const seconds = pad(now.getUTCSeconds());
const milliseconds = pad(now.getUTCMilliseconds(), 3);
const log: typeof TaskLog.infer = {
timestamp: `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds} UTC`,
message,
};
return JSON.stringify(log);
}
export function defineDropTask(buildTask: BuildTask): DropTask {
// TODO: only let one task with the same taskGroup run at the same time if specified

View File

@ -1,39 +1,5 @@
import { AuthMec } from "~/prisma/client";
import { OIDCManager } from "../internal/oidc";
export const enabledAuthManagers: {
[AuthMec.Simple]: boolean;
[AuthMec.OpenID]: OIDCManager | undefined;
} = {
[AuthMec.Simple]: false,
[AuthMec.OpenID]: undefined,
};
const initFunctions: {
[K in keyof typeof enabledAuthManagers]: () => Promise<unknown>;
} = {
[AuthMec.OpenID]: OIDCManager.prototype.create,
[AuthMec.Simple]: async () => {
const disabled = process.env.DISABLE_SIMPLE_AUTH as string | undefined;
return !disabled;
},
};
import authManager from "~/server/internal/auth";
export default defineNitroPlugin(async () => {
for (const [key, init] of Object.entries(initFunctions)) {
try {
const object = await init();
if (!object) break;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(enabledAuthManagers as any)[key] = object;
console.log(`enabled auth: ${key}`);
} catch (e) {
console.warn(e);
}
}
// Add every other auth mechanism here, and fall back to simple if none of them are enabled
if (!enabledAuthManagers[AuthMec.OpenID]) {
enabledAuthManagers[AuthMec.Simple] = true;
}
await authManager.init();
});

View File

@ -1,5 +1,5 @@
import sessionHandler from "~/server/internal/session";
import { enabledAuthManagers } from "~/server/plugins/04.auth-init";
import authManager from "~/server/internal/auth";
defineRouteMeta({
openAPI: {
@ -10,6 +10,7 @@ defineRouteMeta({
});
export default defineEventHandler(async (h3) => {
const enabledAuthManagers = authManager.getAuthProviders();
if (!enabledAuthManagers.OpenID) return sendRedirect(h3, "/auth/signin");
const manager = enabledAuthManagers.OpenID;

View File

@ -1,4 +1,4 @@
import { enabledAuthManagers } from "~/server/plugins/04.auth-init";
import authManager from "~/server/internal/auth";
defineRouteMeta({
openAPI: {
@ -11,6 +11,7 @@ defineRouteMeta({
export default defineEventHandler((h3) => {
const redirect = getQuery(h3).redirect?.toString();
const enabledAuthManagers = authManager.getAuthProviders();
if (!enabledAuthManagers.OpenID)
return sendRedirect(
h3,

10
utils/parseTaskLog.ts Normal file
View File

@ -0,0 +1,10 @@
import type { TaskLog } from "~/server/internal/tasks";
export function parseTaskLog(logStr: string): typeof TaskLog.infer {
const log = JSON.parse(logStr);
return {
message: log.message,
timestamp: log.timestamp,
};
}

View File

@ -2938,7 +2938,7 @@ ansis@^3.17.0:
resolved "https://registry.yarnpkg.com/ansis/-/ansis-3.17.0.tgz#fa8d9c2a93fe7d1177e0c17f9eeb562a58a832d7"
integrity sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==
anymatch@^3.1.3:
anymatch@^3.1.3, anymatch@~3.1.2:
version "3.1.3"
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==
@ -3116,6 +3116,11 @@ bcryptjs@*, bcryptjs@^3.0.2:
resolved "https://registry.yarnpkg.com/bcryptjs/-/bcryptjs-3.0.2.tgz#caadcca1afefe372ed6e20f86db8e8546361c1ca"
integrity sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==
binary-extensions@^2.0.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522"
integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==
bindings@^1.4.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df"
@ -3162,7 +3167,7 @@ brace-expansion@^2.0.1:
dependencies:
balanced-match "^1.0.0"
braces@^3.0.3:
braces@^3.0.3, braces@~3.0.2:
version "3.0.3"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
@ -3354,6 +3359,21 @@ cheerio@^1.0.0:
undici "^6.19.5"
whatwg-mimetype "^4.0.0"
chokidar@^3.5.3:
version "3.6.0"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b"
integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==
dependencies:
anymatch "~3.1.2"
braces "~3.0.2"
glob-parent "~5.1.2"
is-binary-path "~2.1.0"
is-glob "~4.0.1"
normalize-path "~3.0.0"
readdirp "~3.6.0"
optionalDependencies:
fsevents "~2.3.2"
chokidar@^4.0.0, chokidar@^4.0.1, chokidar@^4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30"
@ -4793,6 +4813,15 @@ fs-constants@^1.0.0:
resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
fs-extra@^11.3.0:
version "11.3.0"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.3.0.tgz#0daced136bbaf65a555a326719af931adc7a314d"
integrity sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==
dependencies:
graceful-fs "^4.2.0"
jsonfile "^6.0.1"
universalify "^2.0.0"
fs-extra@^8.0.1:
version "8.1.0"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0"
@ -4920,7 +4949,7 @@ github-from-package@0.0.0:
resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce"
integrity sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==
glob-parent@^5.1.2:
glob-parent@^5.1.2, glob-parent@~5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
@ -5264,6 +5293,13 @@ is-arrayish@^0.3.1:
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03"
integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==
is-binary-path@~2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
dependencies:
binary-extensions "^2.0.0"
is-builtin-module@^3.1.0:
version "3.2.1"
resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.2.1.tgz#f03271717d8654cfcaf07ab0463faa3571581169"
@ -5305,7 +5341,7 @@ is-fullwidth-code-point@^3.0.0:
resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3:
is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1:
version "4.0.3"
resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
@ -5546,6 +5582,15 @@ jsonfile@^5.0.0:
optionalDependencies:
graceful-fs "^4.1.6"
jsonfile@^6.0.1:
version "6.1.0"
resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae"
integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==
dependencies:
universalify "^2.0.0"
optionalDependencies:
graceful-fs "^4.1.6"
junk@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/junk/-/junk-4.0.1.tgz#7ee31f876388c05177fe36529ee714b07b50fbed"
@ -6468,7 +6513,7 @@ normalize-path@^2.1.1:
dependencies:
remove-trailing-separator "^1.0.1"
normalize-path@^3.0.0:
normalize-path@^3.0.0, normalize-path@~3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
@ -6762,7 +6807,7 @@ p-locate@^6.0.0:
dependencies:
p-limit "^4.0.0"
p-map@^7.0.0:
p-map@^7.0.0, p-map@^7.0.3:
version "7.0.3"
resolved "https://registry.yarnpkg.com/p-map/-/p-map-7.0.3.tgz#7ac210a2d36f81ec28b736134810f7ba4418cdb6"
integrity sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==
@ -6942,7 +6987,7 @@ picocolors@^1.0.0, picocolors@^1.1.1:
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
picomatch@^2.0.4, picomatch@^2.3.1:
picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
@ -7453,6 +7498,13 @@ readdirp@^4.0.1:
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d"
integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==
readdirp@~3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==
dependencies:
picomatch "^2.2.1"
redis-errors@^1.0.0, redis-errors@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad"
@ -8486,6 +8538,11 @@ universalify@^0.1.0, universalify@^0.1.2:
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
universalify@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d"
integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==
unixify@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/unixify/-/unixify-1.0.0.tgz#3a641c8c2ffbce4da683a5c70f03a462940c2090"
@ -8730,6 +8787,17 @@ vite-plugin-inspect@^11.1.0:
unplugin-utils "^0.2.4"
vite-dev-rpc "^1.0.7"
vite-plugin-static-copy@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/vite-plugin-static-copy/-/vite-plugin-static-copy-3.0.0.tgz#5d9bdf240ec25205280e48d67ab5a4c642517092"
integrity sha512-Uki9pPUQ4ZnoMEdIFabvoh9h6Bh9Q1m3iF7BrZvoiF30reREpJh2gZb4jOnW1/uYFzyRiLCmFSkM+8hwiq1vWQ==
dependencies:
chokidar "^3.5.3"
fs-extra "^11.3.0"
p-map "^7.0.3"
picocolors "^1.1.1"
tinyglobby "^0.2.13"
vite-plugin-vue-tracer@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/vite-plugin-vue-tracer/-/vite-plugin-vue-tracer-0.1.4.tgz#4e6c36a8f59c1b1b1fd9bceffa5d237a7687060b"