-
Company
+
Documentation
-
Legal
+
About
-
+
{{
item.name }}
@@ -67,29 +67,26 @@ import GithubLogo from './GithubLogo.vue';
import DiscordLogo from './DiscordLogo.vue';
const navigation = {
- solutions: [
- { name: 'Marketing', href: '#' },
- { name: 'Analytics', href: '#' },
- { name: 'Commerce', href: '#' },
- { name: 'Insights', href: '#' },
+ games: [
+ { name: 'Newly Added', href: '#' },
+ { name: 'New Releases', href: '#' },
+ { name: 'Top Sellers', href: '#' },
+ { name: 'Find a Game', href: '#' },
],
- support: [
- { name: 'Pricing', href: '#' },
- { name: 'Documentation', href: '#' },
- { name: 'Guides', href: '#' },
- { name: 'API Status', href: '#' },
+ community: [
+ { name: 'Friends', href: '#' },
+ { name: 'Groups', href: '#' },
+ { name: 'Servers', href: '#' },
],
- company: [
- { name: 'About', href: '#' },
- { name: 'Blog', href: '#' },
- { name: 'Jobs', href: '#' },
- { name: 'Press', href: '#' },
- { name: 'Partners', href: '#' },
+ documentation: [
+ { name: 'API', href: '#' },
+ { name: 'Server Docs', href: '#' },
+ { name: 'Client Docs', href: '#' },
],
- legal: [
- { name: 'Claim', href: '#' },
- { name: 'Privacy', href: '#' },
- { name: 'Terms', href: '#' },
+ about: [
+ { name: 'About Drop', href: '#' },
+ { name: 'Features', href: '#' },
+ { name: 'FAQ', href: '#' },
],
social: [
{
diff --git a/components/UserHeader.vue b/components/UserHeader.vue
index a58743e..9bbaeef 100644
--- a/components/UserHeader.vue
+++ b/components/UserHeader.vue
@@ -3,7 +3,7 @@
-
+
{{ nav.label }}
@@ -18,7 +18,7 @@
-
+
@@ -26,8 +26,7 @@
\ No newline at end of file
+ titleTemplate(title) {
+ if (title) return `${title} | Drop`;
+ return `Drop`;
+ },
+});
+
diff --git a/middleware/require-user.global.ts b/middleware/require-user.global.ts
new file mode 100644
index 0000000..8273279
--- /dev/null
+++ b/middleware/require-user.global.ts
@@ -0,0 +1,15 @@
+const whitelistedPrefixes = ["/signin", "/register"];
+
+export default defineNuxtRouteMiddleware(async (to, from) => {
+ if (import.meta.server) return;
+ if (whitelistedPrefixes.findIndex((e) => to.fullPath.startsWith(e)) != -1)
+ return;
+
+ const user = useUser();
+ if (user === undefined) {
+ await updateUser();
+ }
+ if (!user.value) {
+ return navigateTo({ path: "/signin", query: { redirect: to.fullPath } });
+ }
+});
diff --git a/package.json b/package.json
index b733015..906ec72 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,8 @@
"postinstall": "nuxt prepare"
},
"dependencies": {
+ "@drop/droplet": "^0.2.0",
+ "@drop/droplet-linux-x64-gnu": "^0.2.0",
"@headlessui/vue": "^1.7.23",
"@heroicons/vue": "^2.1.5",
"@prisma/client": "5.20.0",
@@ -25,6 +27,7 @@
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
"devDependencies": {
+ "@tailwindcss/forms": "^0.5.9",
"@types/bcrypt": "^5.0.2",
"@types/turndown": "^5.0.5",
"@types/uuid": "^10.0.0",
diff --git a/pages/admin/index.vue b/pages/admin/index.vue
new file mode 100644
index 0000000..ad33254
--- /dev/null
+++ b/pages/admin/index.vue
@@ -0,0 +1,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/pages/index.vue b/pages/index.vue
index 99393bc..48b5df7 100644
--- a/pages/index.vue
+++ b/pages/index.vue
@@ -1,9 +1,11 @@
-
+ {{ user ?? "no user" }}
\ No newline at end of file
+ title: "Home",
+});
+
+const user = useUser();
+
diff --git a/pages/register.vue b/pages/register.vue
index 983f2de..210fbc3 100644
--- a/pages/register.vue
+++ b/pages/register.vue
@@ -12,7 +12,7 @@ const username = ref("");
const password = ref("");
async function register() {
- await $fetch('/api/v1/signup/simple', {
+ await $fetch('/api/v1/auth/signup/simple', {
method: "POST",
body: {
username: username.value,
diff --git a/pages/signin.vue b/pages/signin.vue
index 89d67a1..0057975 100644
--- a/pages/signin.vue
+++ b/pages/signin.vue
@@ -1,23 +1,172 @@
-
\ No newline at end of file
+
+async function signin() {
+ await $fetch("/api/v1/auth/signin/simple", {
+ method: "POST",
+ body: {
+ username: username.value,
+ password: password.value,
+ rememberMe: rememberMe.value,
+ },
+ });
+ const user = useUser();
+ user.value = await $fetch
("/api/v1/whoami");
+}
+
+definePageMeta({
+ layout: false,
+});
+
+useHead({
+ title: "Sign in to Drop",
+});
+
diff --git a/prisma/migrations/20241007043002_add_user_admin/migration.sql b/prisma/migrations/20241007043002_add_user_admin/migration.sql
new file mode 100644
index 0000000..554507b
--- /dev/null
+++ b/prisma/migrations/20241007043002_add_user_admin/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "User" ADD COLUMN "admin" BOOLEAN NOT NULL DEFAULT false;
diff --git a/prisma/migrations/20241007065541_add_client/migration.sql b/prisma/migrations/20241007065541_add_client/migration.sql
new file mode 100644
index 0000000..efca156
--- /dev/null
+++ b/prisma/migrations/20241007065541_add_client/migration.sql
@@ -0,0 +1,15 @@
+-- CreateEnum
+CREATE TYPE "ClientCapabilities" AS ENUM ('DownloadAggregation');
+
+-- CreateTable
+CREATE TABLE "Client" (
+ "sharedToken" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "endpoint" TEXT NOT NULL,
+ "capabilities" "ClientCapabilities"[],
+
+ CONSTRAINT "Client_pkey" PRIMARY KEY ("sharedToken")
+);
+
+-- AddForeignKey
+ALTER TABLE "Client" ADD CONSTRAINT "Client_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 31f6a07..4dddcb5 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -14,10 +14,12 @@ datasource db {
}
model User {
- id String @id @default(uuid())
- username String @unique
+ id String @id @default(uuid())
+ username String @unique
+ admin Boolean @default(false)
authMecs LinkedAuthMec[]
+ clients Client[]
}
enum AuthMec {
@@ -35,6 +37,20 @@ model LinkedAuthMec {
@@id([userId, mec])
}
+enum ClientCapabilities {
+ DownloadAggregation
+}
+
+// References a device
+model Client {
+ sharedToken String @id @default(uuid())
+ userId String
+ user User @relation(fields: [userId], references: [id])
+
+ endpoint String
+ capabilities ClientCapabilities[]
+}
+
enum MetadataSource {
Custom
GiantBomb
diff --git a/public/fonts/inter/InterVariable-Italic.ttf b/public/fonts/inter/InterVariable-Italic.ttf
new file mode 100644
index 0000000..ed674e7
Binary files /dev/null and b/public/fonts/inter/InterVariable-Italic.ttf differ
diff --git a/public/fonts/inter/InterVariable.ttf b/public/fonts/inter/InterVariable.ttf
new file mode 100644
index 0000000..2d4b470
Binary files /dev/null and b/public/fonts/inter/InterVariable.ttf differ
diff --git a/public/wallpapers/signin.jpg b/public/wallpapers/signin.jpg
new file mode 100644
index 0000000..509343e
Binary files /dev/null and b/public/wallpapers/signin.jpg differ
diff --git a/server/api/v1/signin/simple.post.ts b/server/api/v1/auth/signin/simple.post.ts
similarity index 91%
rename from server/api/v1/signin/simple.post.ts
rename to server/api/v1/auth/signin/simple.post.ts
index 7f649c2..54e9f14 100644
--- a/server/api/v1/signin/simple.post.ts
+++ b/server/api/v1/auth/signin/simple.post.ts
@@ -8,6 +8,7 @@ export default defineEventHandler(async (h3) => {
const username = body.username;
const password = body.password;
+ const rememberMe = body.rememberMe ?? false;
if (username === undefined || password === undefined)
throw createError({ statusCode: 403, statusMessage: "Username or password missing from request." });
@@ -30,7 +31,7 @@ export default defineEventHandler(async (h3) => {
if (!await checkHash(password, hash.toString()))
throw createError({ statusCode: 401, statusMessage: "Invalid username or password." });
- await h3.context.session.setUserId(h3, authMek.userId);
+ await h3.context.session.setUserId(h3, authMek.userId, rememberMe);
return { result: true, userId: authMek.userId }
});
\ No newline at end of file
diff --git a/server/api/v1/signup/simple.post.ts b/server/api/v1/auth/signup/simple.post.ts
similarity index 100%
rename from server/api/v1/signup/simple.post.ts
rename to server/api/v1/auth/signup/simple.post.ts
diff --git a/server/api/v1/client/handshake.post.ts b/server/api/v1/client/handshake.post.ts
new file mode 100644
index 0000000..7b67c41
--- /dev/null
+++ b/server/api/v1/client/handshake.post.ts
@@ -0,0 +1,3 @@
+export default defineEventHandler((h3) => {
+
+})
\ No newline at end of file
diff --git a/server/api/v1/client/initiate.post.ts b/server/api/v1/client/initiate.post.ts
new file mode 100644
index 0000000..5475725
--- /dev/null
+++ b/server/api/v1/client/initiate.post.ts
@@ -0,0 +1,3 @@
+export default defineEventHandler(async (h3) => {
+
+});
\ No newline at end of file
diff --git a/server/api/v1/client/session.post.ts b/server/api/v1/client/session.post.ts
new file mode 100644
index 0000000..7b67c41
--- /dev/null
+++ b/server/api/v1/client/session.post.ts
@@ -0,0 +1,3 @@
+export default defineEventHandler((h3) => {
+
+})
\ No newline at end of file
diff --git a/server/api/v1/whoami.get.ts b/server/api/v1/whoami.get.ts
index 02fc5db..6c34792 100644
--- a/server/api/v1/whoami.get.ts
+++ b/server/api/v1/whoami.get.ts
@@ -1,5 +1,4 @@
export default defineEventHandler(async (h3) => {
const user = await h3.context.session.getUser(h3);
-
- return user ?? {};
+ return user ?? null;
});
\ No newline at end of file
diff --git a/server/internal/clients/README.md b/server/internal/clients/README.md
new file mode 100644
index 0000000..666c5b7
--- /dev/null
+++ b/server/internal/clients/README.md
@@ -0,0 +1,26 @@
+# Client Handshake process
+
+Drop clients need to complete a handshake in order to connect to a Drop server. It also trades certificates for encrypted P2P connections.
+
+## 1. Client requests a handshake
+Client makes request: `POST /api/v1/client/initiate` with information about the client.
+
+Server responds with a URL to send the user to. It generates a device ID, which has all the metadata attached.
+
+## 2. User signs in
+Client sends user to the provided URL (in external browser). User signs in using the existing authentication stack.
+
+Server sends redirect to drop://handshake/[id]/[token], where the token is an authentication token to generate the necessary certificates, and the ID is the client ID as generated by the server.
+
+## 3. Client requests certificates
+Client makes request: `POST /api/v1/client/handshake` with the token recieved in the previous step.
+
+The server uses it's CA to generate a public-private key pair, the CN of the client ID. It then sends that pair, plus the CA's public key, to the client, which stores it all.
+
+The certificate lasts for a year, and is rotated when it has 3 months or less left on it's expiry.
+
+## 4.a Client requests one-time device endpoint
+The client generates a nonce and signs it with their private key. This is then attached to any device-related request.
+
+## 4.b Client wants a long-lived session
+The client does the same as above, but instead makes the request to `POST /api/v1/client/session`, which generates a session token that lasts for a day. This can then be used in the request to provide authentication.
\ No newline at end of file
diff --git a/server/internal/clients/ca.ts b/server/internal/clients/ca.ts
new file mode 100644
index 0000000..15b9ab9
--- /dev/null
+++ b/server/internal/clients/ca.ts
@@ -0,0 +1,34 @@
+import path from "path";
+import droplet from "@drop/droplet";
+import { CertificateStore } from "./store";
+
+export type CertificateBundle = {
+ priv: string;
+ pub: string;
+ cert: string;
+};
+
+/*
+This is designed to handle client certificates, as described in the README.md
+*/
+export class CertificateAuthority {
+ private certificateStore: CertificateStore;
+
+ private root: CertificateBundle;
+
+ constructor(store: CertificateStore, root: CertificateBundle) {
+ this.certificateStore = store;
+ this.root = root;
+ }
+
+ static async new(store: CertificateStore) {
+ const root = await store.fetch("ca");
+ if (root === undefined) {
+ const [priv, pub, cert] = droplet.generateRootCa();
+ const bundle: CertificateBundle = { priv, pub, cert };
+ await store.store("ca", bundle);
+ return new CertificateAuthority(store, bundle);
+ }
+ return new CertificateAuthority(store, root);
+ }
+}
diff --git a/server/internal/clients/store.ts b/server/internal/clients/store.ts
new file mode 100644
index 0000000..29f323d
--- /dev/null
+++ b/server/internal/clients/store.ts
@@ -0,0 +1,23 @@
+import path from "path";
+import fs from "fs";
+import { CertificateBundle } from "./ca";
+
+export type CertificateStore = {
+ store(name: string, data: CertificateBundle): Promise;
+ fetch(name: string): Promise;
+};
+
+export const fsCertificateStore = (base: string) => {
+ const store: CertificateStore = {
+ async store(name: string, data: CertificateBundle) {
+ const filepath = path.join(base, name);
+ fs.writeFileSync(filepath, JSON.stringify(data));
+ },
+ async fetch(name: string) {
+ const filepath = path.join(base, name);
+ if (!fs.existsSync(filepath)) return undefined;
+ return JSON.parse(fs.readFileSync(filepath, "utf-8"));
+ },
+ };
+ return store;
+};
diff --git a/server/internal/downloads/README.md b/server/internal/downloads/README.md
new file mode 100644
index 0000000..7369010
--- /dev/null
+++ b/server/internal/downloads/README.md
@@ -0,0 +1,5 @@
+# Drop Download System
+The Drop download system uses a torrent-*like* system. It is not torrenting, nor is it compatible with torrenting clients.
+
+## Clients
+Drop clients have built-in HTTP APIs that they forward with UPnP. This API exposes different capabilities for different Drop features, like download aggegration and P2P networking. When they sign on, they send a list of supported capabilities to the server.
\ No newline at end of file
diff --git a/server/internal/downloads/coordinator.ts b/server/internal/downloads/coordinator.ts
new file mode 100644
index 0000000..4eaac7b
--- /dev/null
+++ b/server/internal/downloads/coordinator.ts
@@ -0,0 +1,10 @@
+/*
+The download co-ordinator's job is to keep track of all the currently online clients.
+
+When a client signs on and registers itself as a peer
+
+*/
+
+class DownloadCoordinator {
+
+}
\ No newline at end of file
diff --git a/server/internal/session/index.ts b/server/internal/session/index.ts
index 34f2bdf..794dadd 100644
--- a/server/internal/session/index.ts
+++ b/server/internal/session/index.ts
@@ -26,11 +26,11 @@ export class SessionHandler {
return data[userSessionKey];
}
- async setSession(h3: H3Event, data: any) {
+ async setSession(h3: H3Event, data: any, expend = false) {
const result = await this.sessionProvider.updateSession(h3, userSessionKey, data);
if (!result) {
const toCreate = { [userSessionKey]: data };
- await this.sessionProvider.setSession(h3, toCreate);
+ await this.sessionProvider.setSession(h3, toCreate, expend);
}
}
async clearSession(h3: H3Event) {
@@ -52,11 +52,11 @@ export class SessionHandler {
return user;
}
- async setUserId(h3: H3Event, userId: string) {
+ async setUserId(h3: H3Event, userId: string, extend = false) {
const result = await this.sessionProvider.updateSession(h3, userIdKey, userId);
if (!result) {
const toCreate = { [userIdKey]: userId };
- await this.sessionProvider.setSession(h3, toCreate);
+ await this.sessionProvider.setSession(h3, toCreate, extend);
}
}
}
diff --git a/server/internal/session/memory.ts b/server/internal/session/memory.ts
index c4e486c..0ca3a50 100644
--- a/server/internal/session/memory.ts
+++ b/server/internal/session/memory.ts
@@ -1,45 +1,45 @@
import moment from "moment";
import { Session, SessionProvider } from "./types";
-import { v4 as uuidv4 } from 'uuid';
+import { v4 as uuidv4 } from "uuid";
export default function createMemorySessionHandler() {
- const sessions: { [key: string]: Session } = {}
+ const sessions: { [key: string]: Session } = {};
- const sessionCookieName = "drop-session";
+ const sessionCookieName = "drop-session";
- const memoryProvider: SessionProvider = {
- async setSession(h3, data) {
- const existingCookie = getCookie(h3, sessionCookieName);
- if (existingCookie) delete sessions[existingCookie]; // Clear any previous session
+ const memoryProvider: SessionProvider = {
+ async setSession(h3, data, extend = false) {
+ const existingCookie = getCookie(h3, sessionCookieName);
+ if (existingCookie) delete sessions[existingCookie]; // Clear any previous session
- const cookie = uuidv4();
- const expiry = moment().add(31, 'day');
- setCookie(h3, sessionCookieName, cookie, { expires: expiry.toDate() });
+ const cookie = uuidv4();
+ const expiry = moment().add(31, extend ? "month" : "day");
+ setCookie(h3, sessionCookieName, cookie, { expires: expiry.toDate() });
- sessions[cookie] = data;
- return true;
- },
- async updateSession(h3, key, data) {
- const cookie = getCookie(h3, sessionCookieName);
- if (!cookie) return false;
+ sessions[cookie] = data;
+ return true;
+ },
+ async updateSession(h3, key, data) {
+ const cookie = getCookie(h3, sessionCookieName);
+ if (!cookie) return false;
- sessions[cookie] = Object.assign({}, sessions[cookie], { [key]: data });
- return true;
- },
- async getSession(h3) {
- const cookie = getCookie(h3, sessionCookieName);
- if (!cookie) return undefined;
+ sessions[cookie] = Object.assign({}, sessions[cookie], { [key]: data });
+ return true;
+ },
+ async getSession(h3) {
+ const cookie = getCookie(h3, sessionCookieName);
+ if (!cookie) return undefined;
- return sessions[cookie] as any; // Wild type cast because we let the user specify types if they want
- },
- async clearSession(h3) {
- const cookie = getCookie(h3, sessionCookieName);
- if (!cookie) return;
+ return sessions[cookie] as any; // Wild type cast because we let the user specify types if they want
+ },
+ async clearSession(h3) {
+ const cookie = getCookie(h3, sessionCookieName);
+ if (!cookie) return;
- delete sessions[cookie];
- deleteCookie(h3, sessionCookieName);
- },
- };
+ delete sessions[cookie];
+ deleteCookie(h3, sessionCookieName);
+ },
+ };
- return memoryProvider;
-}
\ No newline at end of file
+ return memoryProvider;
+}
diff --git a/server/internal/session/types.d.ts b/server/internal/session/types.d.ts
index 10da2fd..7a6a602 100644
--- a/server/internal/session/types.d.ts
+++ b/server/internal/session/types.d.ts
@@ -3,8 +3,12 @@ import { H3Event } from "h3";
export type Session = { [key: string]: any };
export interface SessionProvider {
- setSession: (h3: H3Event, data: Session) => Promise;
- updateSession: (h3: H3Event, key: string, data: any) => Promise;
- getSession: (h3: H3Event) => Promise;
- clearSession: (h3: H3Event) => Promise;
-}
\ No newline at end of file
+ setSession: (
+ h3: H3Event,
+ data: Session,
+ extend?: boolean
+ ) => Promise;
+ updateSession: (h3: H3Event, key: string, data: any) => Promise;
+ getSession: (h3: H3Event) => Promise;
+ clearSession: (h3: H3Event) => Promise;
+}
diff --git a/server/plugins/ca.ts b/server/plugins/ca.ts
new file mode 100644
index 0000000..369d06f
--- /dev/null
+++ b/server/plugins/ca.ts
@@ -0,0 +1,18 @@
+import { CertificateAuthority } from "../internal/clients/ca";
+import fs from "fs";
+import { fsCertificateStore } from "../internal/clients/store";
+
+let ca: CertificateAuthority | undefined;
+
+export const useGlobalCertificateAuthority = () => {
+ if (!ca) throw new Error("CA not initialised");
+ return ca;
+};
+
+export default defineNitroPlugin(async (nitro) => {
+ const basePath = process.env.CLIENT_CERTIFICATES ?? "./certs";
+ fs.mkdirSync(basePath, { recursive: true });
+ const store = fsCertificateStore(basePath);
+
+ ca = await CertificateAuthority.new(store);
+});
diff --git a/server/routes/signout.get.ts b/server/routes/signout.get.ts
new file mode 100644
index 0000000..36b5a6f
--- /dev/null
+++ b/server/routes/signout.get.ts
@@ -0,0 +1,5 @@
+export default defineEventHandler(async (h3) => {
+ await h3.context.session.clearSession(h3);
+
+ return sendRedirect(h3, "/signin");
+});
diff --git a/tailwind.config.js b/tailwind.config.js
index 02d554d..c510cf2 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -11,11 +11,13 @@ export default {
theme: {
extend: {
fontFamily: {
- sans: ["Helvetica"],
+ sans: ["Inter"],
display: ["Motiva Sans"]
}
},
},
- plugins: [],
+ plugins: [
+ require('@tailwindcss/forms'),
+ ],
}
diff --git a/yarn.lock b/yarn.lock
index 6456fcf..f00a47b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -296,6 +296,24 @@
dependencies:
mime "^3.0.0"
+"@drop/droplet-linux-x64-gnu@0.2.0", "@drop/droplet-linux-x64-gnu@^0.2.0":
+ version "0.2.0"
+ resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet-linux-x64-gnu/-/@drop/droplet-linux-x64-gnu-0.2.0.tgz#e1c0133abc38cf63cc8beaf5826db1946beb1165"
+ integrity sha1-4cATOrw4z2PMi+r1gm2xlGvrEWU=
+
+"@drop/droplet-win32-x64-msvc@0.2.0":
+ version "0.2.0"
+ resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet-win32-x64-msvc/-/@drop/droplet-win32-x64-msvc-0.2.0.tgz#0531e51e225530c277afcc7ac4230c8d99c8365e"
+ integrity sha1-BTHlHiJVMMJ3r8x6xCMMjZnINl4=
+
+"@drop/droplet@^0.2.0":
+ version "0.2.0"
+ resolved "https://lab.deepcore.dev/api/v4/projects/57/packages/npm/@drop/droplet/-/@drop/droplet-0.2.0.tgz#e4b6d2cf2bd5c0416fd3452ffa5b7c34267e160a"
+ integrity sha1-5LbSzyvVwEFv00Uv+lt8NCZ+Fgo=
+ optionalDependencies:
+ "@drop/droplet-linux-x64-gnu" "0.2.0"
+ "@drop/droplet-win32-x64-msvc" "0.2.0"
+
"@esbuild/aix-ppc64@0.20.2":
version "0.20.2"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz#a70f4ac11c6a1dfc18b8bbb13284155d933b9537"
@@ -1279,6 +1297,13 @@
resolved "https://registry.yarnpkg.com/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz#719df7fb41766bc143369eaa0dd56d8dc87c9958"
integrity sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==
+"@tailwindcss/forms@^0.5.9":
+ version "0.5.9"
+ resolved "https://registry.yarnpkg.com/@tailwindcss/forms/-/forms-0.5.9.tgz#b495c12575d6eae5865b2cbd9876b26d89f16f61"
+ integrity sha512-tM4XVr2+UVTxXJzey9Twx48c1gcxFStqn1pQz0tRsX8o3DvxhN5oY5pvyAbUx7VTaZxpej4Zzvc6h+1RJBzpIg==
+ dependencies:
+ mini-svg-data-uri "^1.2.3"
+
"@tanstack/virtual-core@3.10.8":
version "3.10.8"
resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.10.8.tgz#975446a667755222f62884c19e5c3c66d959b8b4"
@@ -3502,6 +3527,11 @@ mimic-fn@^4.0.0:
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-4.0.0.tgz#60a90550d5cb0b239cca65d893b1a53b29871ecc"
integrity sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==
+mini-svg-data-uri@^1.2.3:
+ version "1.4.4"
+ resolved "https://registry.yarnpkg.com/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz#8ab0aabcdf8c29ad5693ca595af19dd2ead09939"
+ integrity sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==
+
minimatch@^3.0.4, minimatch@^3.1.1:
version "3.1.2"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"