From 9ba20c8a9eceb601523f57dfb0e62a0cfa8319ba Mon Sep 17 00:00:00 2001 From: Richard R Date: Sat, 24 Jan 2026 17:36:11 -0700 Subject: [PATCH 01/55] feat(auth): add user authentication and rate limiting - Implement user sign-in, sign-up, and account management using better-auth - Add rate limiting for TTS API with daily character limits for authenticated and anonymous users - Integrate SQLite and PostgreSQL database support for user sessions and data persistence - Update UI components to include authentication flows, user menu, and privacy popup - Modify Dockerfile and package.json for new dependencies and entrypoint script - Bump version to v1.3.0 --- Dockerfile | 5 + README.md | 7 +- .../2026-01-24T20-06-28.748Z.sql | 13 + docker-entrypoint.sh | 11 + next.config.ts | 1 + package.json | 11 +- pnpm-lock.yaml | 1456 ++++++++++++++++- src/app/api/account/delete/route.ts | 36 + src/app/api/auth/[...all]/route.ts | 11 + src/app/api/rate-limit/status/route.ts | 73 + src/app/api/tts/route.ts | 69 +- src/app/layout.tsx | 11 +- src/app/page.tsx | 41 +- src/app/providers.tsx | 25 +- src/app/signin/page.tsx | 286 ++++ src/app/signup/page.tsx | 240 +++ src/components/SettingsModal.tsx | 316 ++-- src/components/Spinner.tsx | 8 +- src/components/auth/AuthLoader.tsx | 76 + src/components/auth/SessionManager.tsx | 48 + src/components/auth/UserMenu.tsx | 66 + src/components/privacy-popup.tsx | 211 +++ src/components/rate-limit-banner.tsx | 85 + src/components/rate-limit-provider.tsx | 206 +++ src/contexts/AuthConfigContext.tsx | 33 + src/hooks/useAuth.ts | 36 + src/lib/auth-client.ts | 35 + src/lib/server/auth-config.ts | 17 + src/lib/server/auth.ts | 106 ++ src/lib/server/db-adapter.ts | 125 ++ src/lib/server/db.ts | 45 + src/lib/server/rate-limiter.ts | 230 +++ src/lib/session-utils.ts | 26 + src/types/config.ts | 4 + tests/helpers.ts | 10 +- 35 files changed, 3780 insertions(+), 199 deletions(-) create mode 100644 better-auth_migrations/2026-01-24T20-06-28.748Z.sql create mode 100755 docker-entrypoint.sh create mode 100644 src/app/api/account/delete/route.ts create mode 100644 src/app/api/auth/[...all]/route.ts create mode 100644 src/app/api/rate-limit/status/route.ts create mode 100644 src/app/signin/page.tsx create mode 100644 src/app/signup/page.tsx create mode 100644 src/components/auth/AuthLoader.tsx create mode 100644 src/components/auth/SessionManager.tsx create mode 100644 src/components/auth/UserMenu.tsx create mode 100644 src/components/privacy-popup.tsx create mode 100644 src/components/rate-limit-banner.tsx create mode 100644 src/components/rate-limit-provider.tsx create mode 100644 src/contexts/AuthConfigContext.tsx create mode 100644 src/hooks/useAuth.ts create mode 100644 src/lib/auth-client.ts create mode 100644 src/lib/server/auth-config.ts create mode 100644 src/lib/server/auth.ts create mode 100644 src/lib/server/db-adapter.ts create mode 100644 src/lib/server/db.ts create mode 100644 src/lib/server/rate-limiter.ts create mode 100644 src/lib/session-utils.ts diff --git a/Dockerfile b/Dockerfile index 255275d..94aef56 100644 --- a/Dockerfile +++ b/Dockerfile @@ -65,4 +65,9 @@ ENV LD_LIBRARY_PATH=/opt/whisper.cpp/build EXPOSE 3003 # Start the application +# Copy entrypoint script +COPY docker-entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +ENTRYPOINT ["docker-entrypoint.sh"] CMD ["pnpm", "start"] diff --git a/README.md b/README.md index 44354eb..fa51d1f 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,12 @@ Optionally required for different features: ``` > Note: The base URL for the TTS API should be accessible and relative to the Next.js server -4. Start the development server: +4. Run SQLite creation: + ```bash + npx @better-auth/cli migrate -y + ``` + +5. Start the development server: With pnpm (recommended): ```bash diff --git a/better-auth_migrations/2026-01-24T20-06-28.748Z.sql b/better-auth_migrations/2026-01-24T20-06-28.748Z.sql new file mode 100644 index 0000000..e57bdc3 --- /dev/null +++ b/better-auth_migrations/2026-01-24T20-06-28.748Z.sql @@ -0,0 +1,13 @@ +create table "user" ("id" text not null primary key, "name" text not null, "email" text not null unique, "emailVerified" boolean not null, "image" text, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz default CURRENT_TIMESTAMP not null, "isAnonymous" boolean); + +create table "session" ("id" text not null primary key, "expiresAt" timestamptz not null, "token" text not null unique, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz not null, "ipAddress" text, "userAgent" text, "userId" text not null references "user" ("id") on delete cascade); + +create table "account" ("id" text not null primary key, "accountId" text not null, "providerId" text not null, "userId" text not null references "user" ("id") on delete cascade, "accessToken" text, "refreshToken" text, "idToken" text, "accessTokenExpiresAt" timestamptz, "refreshTokenExpiresAt" timestamptz, "scope" text, "password" text, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz not null); + +create table "verification" ("id" text not null primary key, "identifier" text not null, "value" text not null, "expiresAt" timestamptz not null, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz default CURRENT_TIMESTAMP not null); + +create index "session_userId_idx" on "session" ("userId"); + +create index "account_userId_idx" on "account" ("userId"); + +create index "verification_identifier_idx" on "verification" ("identifier"); \ No newline at end of file diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..c913a3f --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -e + +# Run migrations if we have the SQLite file path set or default +if [ -z "$POSTGRES_URL" ]; then + echo "Running SQLite migrations..." + npx @better-auth/cli migrate -y +fi + +# Start the application +exec "$@" diff --git a/next.config.ts b/next.config.ts index e7f0bee..c6b910e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,6 +6,7 @@ const nextConfig: NextConfig = { canvas: './empty-module.ts', }, }, + serverExternalPackages: ["better-sqlite3"], }; export default nextConfig; diff --git a/package.json b/package.json index 23dd947..fa3a5f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openreader-webui", - "version": "v1.2.1", + "version": "v1.3.0", "private": true, "scripts": { "dev": "next dev --turbopack -p 3003", @@ -10,10 +10,13 @@ "test": "playwright test" }, "dependencies": { + "@better-auth/cli": "1.4.17", "@headlessui/react": "^2.2.9", "@types/howler": "^2.2.12", "@types/uuid": "^10.0.0", "@vercel/analytics": "^1.6.1", + "better-auth": "^1.4.17", + "better-sqlite3": "^12.6.2", "cmpstr": "^3.2.0", "compromise": "^14.14.5", "core-js": "^3.48.0", @@ -25,6 +28,7 @@ "next": "^15.5.9", "openai": "^6.16.0", "pdfjs-dist": "4.8.69", + "pg": "^8.17.2", "react": "^19.2.3", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", @@ -41,7 +45,9 @@ "@eslint/eslintrc": "^3.3.3", "@playwright/test": "^1.58.0", "@tailwindcss/typography": "^0.5.19", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^20.19.30", + "@types/pg": "^8.16.0", "@types/react": "^19.2.9", "@types/react-dom": "^19.2.3", "eslint": "^9.39.2", @@ -51,6 +57,9 @@ "typescript": "^5.9.3" }, "pnpm": { + "onlyBuiltDependencies": [ + "better-sqlite3" + ], "overrides": { "lodash": "^4.17.23", "@xmldom/xmldom": "^0.9.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9212d5..32800bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,9 @@ importers: .: dependencies: + '@better-auth/cli': + specifier: 1.4.17 + version: 1.4.17(@better-fetch/fetch@1.1.21)(@types/better-sqlite3@7.6.13)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0)(next@15.5.9(@babel/core@7.28.6)(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@headlessui/react': specifier: ^2.2.9 version: 2.2.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -24,7 +27,13 @@ importers: version: 10.0.0 '@vercel/analytics': specifier: ^1.6.1 - version: 1.6.1(next@15.5.9(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 1.6.1(next@15.5.9(@babel/core@7.28.6)(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + better-auth: + specifier: ^1.4.17 + version: 1.4.17(@prisma/client@5.22.0)(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2))(next@15.5.9(@babel/core@7.28.6)(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.17.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + better-sqlite3: + specifier: ^12.6.2 + version: 12.6.2 cmpstr: specifier: ^3.2.0 version: 3.2.0 @@ -51,13 +60,16 @@ importers: version: 11.2.4 next: specifier: ^15.5.9 - version: 15.5.9(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 15.5.9(@babel/core@7.28.6)(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) openai: specifier: ^6.16.0 version: 6.16.0(zod@4.3.6) pdfjs-dist: specifier: 4.8.69 version: 4.8.69 + pg: + specifier: ^8.17.2 + version: 8.17.2 react: specifier: ^19.2.3 version: 19.2.3 @@ -101,9 +113,15 @@ importers: '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@3.4.19) + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 '@types/node': specifier: ^20.19.30 version: 20.19.30 + '@types/pg': + specifier: ^8.16.0 + version: 8.16.0 '@types/react': specifier: ^19.2.9 version: 19.2.9 @@ -132,10 +150,212 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@babel/code-frame@7.28.6': + resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.6': + resolution: {integrity: sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.6': + resolution: {integrity: sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.6': + resolution: {integrity: sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.28.6': + resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.6': + resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.28.6': + resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-display-name@7.28.0': + resolution: {integrity: sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-development@7.27.1': + resolution: {integrity: sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx@7.28.6': + resolution: {integrity: sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-pure-annotations@7.27.1': + resolution: {integrity: sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.28.6': + resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-react@7.28.5': + resolution: {integrity: sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.28.5': + resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.6': resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.6': + resolution: {integrity: sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.6': + resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} + engines: {node: '>=6.9.0'} + + '@better-auth/cli@1.4.17': + resolution: {integrity: sha512-GT0epRZCRAxwDnDNVsAVXx2W4PNjBfm8NwrtjxfYKRHsnrCHkT0N/cZyKCYG1sReN/wlsyYk8bJ6f1N1P4VT8w==} + hasBin: true + + '@better-auth/core@1.4.17': + resolution: {integrity: sha512-WSaEQDdUO6B1CzAmissN6j0lx9fM9lcslEYzlApB5UzFaBeAOHNUONTdglSyUs6/idiZBoRvt0t/qMXCgIU8ug==} + peerDependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + better-call: 1.1.8 + jose: ^6.1.0 + kysely: ^0.28.5 + nanostores: ^1.0.1 + + '@better-auth/telemetry@1.4.17': + resolution: {integrity: sha512-R1BC4e/bNjQbXu7lG6ubpgmsPj7IMqky5DvMlzAtnAJWJhh99pMh/n6w5gOHa0cqDZgEAuj75IPTxv+q3YiInA==} + peerDependencies: + '@better-auth/core': 1.4.17 + + '@better-auth/utils@0.3.0': + resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==} + + '@better-fetch/fetch@1.1.21': + resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + + '@chevrotain/cst-dts-gen@10.5.0': + resolution: {integrity: sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==} + + '@chevrotain/gast@10.5.0': + resolution: {integrity: sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==} + + '@chevrotain/types@10.5.0': + resolution: {integrity: sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==} + + '@chevrotain/utils@10.5.0': + resolution: {integrity: sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==} + + '@clack/core@0.5.0': + resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==} + + '@clack/prompts@0.11.0': + resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==} + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -367,6 +587,9 @@ packages: '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -377,6 +600,10 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mrleebo/prisma-ast@0.13.1': + resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==} + engines: {node: '>=16'} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -434,6 +661,14 @@ packages: cpu: [x64] os: [win32] + '@noble/ciphers@2.1.1': + resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -455,6 +690,15 @@ packages: engines: {node: '>=18'} hasBin: true + '@prisma/client@5.22.0': + resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==} + engines: {node: '>=16.13'} + peerDependencies: + prisma: '*' + peerDependenciesMeta: + prisma: + optional: true + '@react-aria/focus@3.21.3': resolution: {integrity: sha512-FsquWvjSCwC2/sBk4b+OqJyONETUIXQ2vM0YdPAuC+QFQh2DT6TIBo6dOZVSezlhudDla69xFBd6JvCFq1AbUw==} peerDependencies: @@ -507,6 +751,9 @@ packages: '@rushstack/eslint-patch@1.15.0': resolution: {integrity: sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -530,6 +777,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -560,6 +810,9 @@ packages: '@types/node@20.19.30': resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==} + '@types/pg@8.16.0': + resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -862,10 +1115,91 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.9.18: + resolution: {integrity: sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==} + hasBin: true + + better-auth@1.4.17: + resolution: {integrity: sha512-VmHGQyKsEahkEs37qguROKg/6ypYpNF13D7v/lkbO7w7Aivz0Bv2h+VyUkH4NzrGY0QBKXi1577mGhDCVwp0ew==} + peerDependencies: + '@lynx-js/react': '*' + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + '@sveltejs/kit': ^2.0.0 + '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 + better-sqlite3: ^12.0.0 + drizzle-kit: '>=0.31.4' + drizzle-orm: '>=0.41.0' + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@prisma/client': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + '@tanstack/solid-start': + optional: true + better-sqlite3: + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-call@1.1.8: + resolution: {integrity: sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + better-sqlite3@12.6.2: + resolution: {integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -879,9 +1213,26 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + c12@3.3.3: + resolution: {integrity: sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -916,6 +1267,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -928,13 +1283,26 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chevrotain@10.5.0: + resolution: {integrity: sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + citty@0.2.0: + resolution: {integrity: sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA==} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -956,6 +1324,10 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -967,6 +1339,16 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + confbox@0.2.2: + resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + core-js@3.48.0: resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} @@ -1035,18 +1417,36 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.4.0: + resolution: {integrity: sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==} + engines: {node: '>=18'} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1077,6 +1477,99 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + + drizzle-orm@0.41.0: + resolution: {integrity: sha512-7A4ZxhHk9gdlXmTdPj/lREtP+3u8KvZ4yEN6MYVxBzZGex5Wtdc+CWSbu7btgF6TB0N+MNPrvW7RKBbxJchs/Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1085,6 +1578,9 @@ packages: resolution: {integrity: sha512-/RInbCy1d4P6Zdfa+TMVsf/ufZVotat5hCw3QXmWtjU+3pFEOvOQ7ibo3aIxyCJw2leIeAMjmPj+1SLJiCpdrQ==} engines: {node: '>=12.0.0'} + electron-to-chromium@1.5.278: + resolution: {integrity: sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw==} + emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} @@ -1140,6 +1636,10 @@ packages: resolution: {integrity: sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==} engines: {node: '>=0.12'} + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1278,6 +1778,9 @@ packages: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + ext@1.7.0: resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} @@ -1321,6 +1824,9 @@ packages: resolution: {integrity: sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==} engines: {node: '>= 12'} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -1367,6 +1873,10 @@ packages: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -1382,6 +1892,10 @@ packages: get-tsconfig@4.13.0: resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + giget@2.0.0: + resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + hasBin: true + github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} @@ -1539,6 +2053,11 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1558,6 +2077,11 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + is-map@2.0.3: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} @@ -1614,6 +2138,10 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + is-wsl@3.1.0: + resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} + engines: {node: '>=16'} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -1631,6 +2159,13 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1638,6 +2173,11 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -1651,6 +2191,11 @@ packages: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -1661,6 +2206,14 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + kysely@0.28.10: + resolution: {integrity: sha512-ksNxfzIW77OcZ+QWSAPC7yDqUSaIVwkTWnTPNiIy//vifNbwsSgQ57OkkncHxxpcBHM3LRfLAZVEh7kjq5twVA==} + engines: {node: '>=20.0.0'} + language-subtag-registry@0.3.23: resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} @@ -1678,6 +2231,10 @@ packages: lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -1709,6 +2266,9 @@ packages: resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} engines: {node: 20 || >=22} + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + make-cancellable-promise@1.3.2: resolution: {integrity: sha512-GCXh3bq/WuMbS+Ky4JBPW1hYTOU+znU+Q5m9Pu+pI8EoUqIHk9+tviOKC6/qhHh8C4/As3tzJ69IF32kdz85ww==} @@ -1898,6 +2458,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanostores@1.1.0: + resolution: {integrity: sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==} + engines: {node: ^20.0.0 || >=22.0.0} + napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} @@ -1940,10 +2504,21 @@ packages: node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + nypm@0.6.4: + resolution: {integrity: sha512-1TvCKjZyyklN+JJj2TS3P4uSQEInrM/HkkuSXsEzm1ApPgBffOn8gFguNnZf07r/1X6vlryfIqMUkJKQMzlZiw==} + engines: {node: '>=18'} + hasBin: true + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1980,9 +2555,16 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + openai@6.16.0: resolution: {integrity: sha512-fZ1uBqjFUjXzbGc35fFtYKEOxd20kd9fDpFeqWtsOZWiubY8CZ1NAlXHW3iathaFvqmNtCWMIsosCuyeI7Joxg==} hasBin: true @@ -2039,10 +2621,50 @@ packages: resolution: {integrity: sha512-+vnG6S4dYcYxZd+CZxzXCNKdELYZSKfohrk98yajCo1PtRoDgCTrrwOvK1GT0UoAdVszagDVllQc0U1vaX4NUQ==} engines: {node: '>=6'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pdfjs-dist@4.8.69: resolution: {integrity: sha512-IHZsA4T7YElCKNNXtiLgqScw4zPd3pG9do8UrznC757gMd7UPeHSL2qwNNMJo4r79fl8oj1Xx+1nh2YkzdMpLQ==} engines: {node: '>=18'} + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.10.1: + resolution: {integrity: sha512-iNzslsoeSH2/gmDDKiyMqF64DATUCWj3YJ0wP14kqcsf2TUklwimd+66yYojKwZCA7h2yRNLGug71hCBA2a4sw==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.11.0: + resolution: {integrity: sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.11.0: + resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.17.2: + resolution: {integrity: sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2062,6 +2684,9 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + playwright-core@1.58.0: resolution: {integrity: sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==} engines: {node: '>=18'} @@ -2131,6 +2756,22 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -2140,9 +2781,18 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -2159,6 +2809,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -2244,6 +2897,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + redux@4.2.1: resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} @@ -2251,6 +2908,9 @@ packages: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} + regexp-to-ast@0.5.0: + resolution: {integrity: sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==} + regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} @@ -2287,6 +2947,13 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rou3@0.7.12: + resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -2320,6 +2987,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -2369,6 +3039,9 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2376,6 +3049,10 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -2487,6 +3164,10 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -2575,6 +3256,12 @@ packages: unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -2627,10 +3314,29 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yocto-spinner@0.2.3: + resolution: {integrity: sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ==} + engines: {node: '>=18.19'} + + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -2641,8 +3347,354 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@babel/code-frame@7.28.6': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.6': {} + + '@babel/core@7.28.6': + dependencies: + '@babel/code-frame': 7.28.6 + '@babel/generator': 7.28.6 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.6) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.28.6 + '@babel/template': 7.28.6 + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.6': + dependencies: + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.28.6 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.28.6) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.28.6 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.28.6 + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.28.6 + + '@babel/parser@7.28.6': + dependencies: + '@babel/types': 7.28.6 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.6) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.28.6) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-jsx@7.28.6(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.28.6) + '@babel/types': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-pure-annotations@7.27.1(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.28.6) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.28.6) + transitivePeerDependencies: + - supports-color + + '@babel/preset-react@7.28.5(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.28.6) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.28.6) + '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.28.6) + '@babel/plugin-transform-react-pure-annotations': 7.27.1(@babel/core@7.28.6) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.28.5(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.28.6) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.28.6) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.28.6) + transitivePeerDependencies: + - supports-color + '@babel/runtime@7.28.6': {} + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.28.6 + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + + '@babel/traverse@7.28.6': + dependencies: + '@babel/code-frame': 7.28.6 + '@babel/generator': 7.28.6 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.6 + '@babel/template': 7.28.6 + '@babel/types': 7.28.6 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.6': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@better-auth/cli@1.4.17(@better-fetch/fetch@1.1.21)(@types/better-sqlite3@7.6.13)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0)(next@15.5.9(@babel/core@7.28.6)(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@babel/core': 7.28.6 + '@babel/preset-react': 7.28.5(@babel/core@7.28.6) + '@babel/preset-typescript': 7.28.5(@babel/core@7.28.6) + '@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0) + '@better-auth/telemetry': 1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0)) + '@better-auth/utils': 0.3.0 + '@clack/prompts': 0.11.0 + '@mrleebo/prisma-ast': 0.13.1 + '@prisma/client': 5.22.0 + '@types/pg': 8.16.0 + better-auth: 1.4.17(@prisma/client@5.22.0)(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2))(next@15.5.9(@babel/core@7.28.6)(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.17.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + better-sqlite3: 12.6.2 + c12: 3.3.3 + chalk: 5.6.2 + commander: 12.1.0 + dotenv: 17.2.3 + drizzle-orm: 0.41.0(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2) + open: 10.2.0 + pg: 8.17.2 + prettier: 3.8.1 + prompts: 2.4.2 + semver: 7.7.3 + yocto-spinner: 0.2.3 + zod: 4.3.6 + transitivePeerDependencies: + - '@aws-sdk/client-rds-data' + - '@better-fetch/fetch' + - '@cloudflare/workers-types' + - '@electric-sql/pglite' + - '@libsql/client' + - '@libsql/client-wasm' + - '@lynx-js/react' + - '@neondatabase/serverless' + - '@op-engineering/op-sqlite' + - '@opentelemetry/api' + - '@planetscale/database' + - '@sveltejs/kit' + - '@tanstack/react-start' + - '@tanstack/solid-start' + - '@tidbcloud/serverless' + - '@types/better-sqlite3' + - '@types/sql.js' + - '@vercel/postgres' + - '@xata.io/client' + - better-call + - bun-types + - drizzle-kit + - expo-sqlite + - gel + - jose + - knex + - kysely + - magicast + - mongodb + - mysql2 + - nanostores + - next + - pg-native + - postgres + - prisma + - react + - react-dom + - solid-js + - sql.js + - sqlite3 + - supports-color + - svelte + - vitest + - vue + + '@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0)': + dependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + '@standard-schema/spec': 1.1.0 + better-call: 1.1.8(zod@4.3.6) + jose: 6.1.3 + kysely: 0.28.10 + nanostores: 1.1.0 + zod: 4.3.6 + + '@better-auth/telemetry@1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0))': + dependencies: + '@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0) + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + + '@better-auth/utils@0.3.0': {} + + '@better-fetch/fetch@1.1.21': {} + + '@chevrotain/cst-dts-gen@10.5.0': + dependencies: + '@chevrotain/gast': 10.5.0 + '@chevrotain/types': 10.5.0 + lodash: 4.17.23 + + '@chevrotain/gast@10.5.0': + dependencies: + '@chevrotain/types': 10.5.0 + lodash: 4.17.23 + + '@chevrotain/types@10.5.0': {} + + '@chevrotain/utils@10.5.0': {} + + '@clack/core@0.5.0': + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@clack/prompts@0.11.0': + dependencies: + '@clack/core': 0.5.0 + picocolors: 1.1.1 + sisteransi: 1.0.5 + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -2853,6 +3905,11 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -2862,6 +3919,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mrleebo/prisma-ast@0.13.1': + dependencies: + chevrotain: 10.5.0 + lilconfig: 2.1.0 + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.8.1 @@ -2899,6 +3961,10 @@ snapshots: '@next/swc-win32-x64-msvc@15.5.7': optional: true + '@noble/ciphers@2.1.1': {} + + '@noble/hashes@2.0.1': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2917,6 +3983,8 @@ snapshots: dependencies: playwright: 1.58.0 + '@prisma/client@5.22.0': {} + '@react-aria/focus@3.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@react-aria/interactions': 3.26.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -2976,6 +4044,8 @@ snapshots: '@rushstack/eslint-patch@1.15.0': {} + '@standard-schema/spec@1.1.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -3002,6 +4072,10 @@ snapshots: tslib: 2.8.1 optional: true + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 20.19.30 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -3032,6 +4106,12 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/pg@8.16.0': + dependencies: + '@types/node': 20.19.30 + pg-protocol: 1.11.0 + pg-types: 2.2.0 + '@types/react-dom@19.2.3(@types/react@19.2.9)': dependencies: '@types/react': 19.2.9 @@ -3198,9 +4278,9 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vercel/analytics@1.6.1(next@15.5.9(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': + '@vercel/analytics@1.6.1(next@15.5.9(@babel/core@7.28.6)(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': optionalDependencies: - next: 15.5.9(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + next: 15.5.9(@babel/core@7.28.6)(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 '@xmldom/xmldom@0.9.8': {} @@ -3320,17 +4400,58 @@ snapshots: balanced-match@1.0.2: {} - base64-js@1.5.1: - optional: true + base64-js@1.5.1: {} + + baseline-browser-mapping@2.9.18: {} + + better-auth@1.4.17(@prisma/client@5.22.0)(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2))(next@15.5.9(@babel/core@7.28.6)(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.17.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0) + '@better-auth/telemetry': 1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0)) + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + '@noble/ciphers': 2.1.1 + '@noble/hashes': 2.0.1 + better-call: 1.1.8(zod@4.3.6) + defu: 6.1.4 + jose: 6.1.3 + kysely: 0.28.10 + nanostores: 1.1.0 + zod: 4.3.6 + optionalDependencies: + '@prisma/client': 5.22.0 + better-sqlite3: 12.6.2 + drizzle-orm: 0.41.0(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2) + next: 15.5.9(@babel/core@7.28.6)(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + pg: 8.17.2 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + better-call@1.1.8(zod@4.3.6): + dependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + rou3: 0.7.12 + set-cookie-parser: 2.7.2 + optionalDependencies: + zod: 4.3.6 + + better-sqlite3@12.6.2: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 binary-extensions@2.3.0: {} + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + bl@4.1.0: dependencies: buffer: 5.7.1 inherits: 2.0.4 readable-stream: 3.6.2 - optional: true brace-expansion@1.1.12: dependencies: @@ -3345,11 +4466,37 @@ snapshots: dependencies: fill-range: 7.1.1 + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.18 + caniuse-lite: 1.0.30001766 + electron-to-chromium: 1.5.278 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + buffer@5.7.1: dependencies: base64-js: 1.5.1 ieee754: 1.2.1 - optional: true + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + c12@3.3.3: + dependencies: + chokidar: 5.0.0 + confbox: 0.2.2 + defu: 6.1.4 + dotenv: 17.2.3 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.0 + rc9: 2.1.2 call-bind-apply-helpers@1.0.2: dependencies: @@ -3387,6 +4534,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -3395,6 +4544,15 @@ snapshots: character-reference-invalid@2.0.1: {} + chevrotain@10.5.0: + dependencies: + '@chevrotain/cst-dts-gen': 10.5.0 + '@chevrotain/gast': 10.5.0 + '@chevrotain/types': 10.5.0 + '@chevrotain/utils': 10.5.0 + lodash: 4.17.23 + regexp-to-ast: 0.5.0 + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -3407,8 +4565,17 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - chownr@1.1.4: - optional: true + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + chownr@1.1.4: {} + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + citty@0.2.0: {} client-only@0.0.1: {} @@ -3424,6 +4591,8 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@12.1.0: {} + commander@4.1.1: {} compromise@14.14.5: @@ -3434,6 +4603,12 @@ snapshots: concat-map@0.0.1: {} + confbox@0.2.2: {} + + consola@3.4.2: {} + + convert-source-map@2.0.0: {} + core-js@3.48.0: {} core-util-is@1.0.3: {} @@ -3488,29 +4663,39 @@ snapshots: decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 - optional: true - deep-extend@0.6.0: - optional: true + deep-extend@0.6.0: {} deep-is@0.1.4: {} + default-browser-id@5.0.1: {} + + default-browser@5.4.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 es-errors: 1.3.0 gopd: 1.2.0 + define-lazy-prop@3.0.0: {} + define-properties@1.2.1: dependencies: define-data-property: 1.1.4 has-property-descriptors: 1.0.2 object-keys: 1.1.1 + defu@6.1.4: {} + dequal@2.0.3: {} - detect-libc@2.1.2: - optional: true + destr@2.0.5: {} + + detect-libc@2.1.2: {} devlop@1.1.0: dependencies: @@ -3538,6 +4723,17 @@ snapshots: dependencies: esutils: 2.0.3 + dotenv@17.2.3: {} + + drizzle-orm@0.41.0(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2): + optionalDependencies: + '@prisma/client': 5.22.0 + '@types/better-sqlite3': 7.6.13 + '@types/pg': 8.16.0 + better-sqlite3: 12.6.2 + kysely: 0.28.10 + pg: 8.17.2 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -3546,6 +4742,8 @@ snapshots: efrt@2.7.0: {} + electron-to-chromium@1.5.278: {} + emoji-regex@9.2.2: {} empty-module@0.0.2: {} @@ -3553,7 +4751,6 @@ snapshots: end-of-stream@1.4.5: dependencies: once: 1.4.0 - optional: true epubjs@0.3.93: dependencies: @@ -3686,6 +4883,8 @@ snapshots: d: 1.0.2 ext: 1.7.0 + escalade@3.2.0: {} + escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} @@ -3900,8 +5099,9 @@ snapshots: d: 1.0.2 es5-ext: 0.10.64 - expand-template@2.0.3: - optional: true + expand-template@2.0.3: {} + + exsolve@1.0.8: {} ext@1.7.0: dependencies: @@ -3947,6 +5147,8 @@ snapshots: dependencies: tslib: 2.8.1 + file-uri-to-path@1.0.0: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -3967,8 +5169,7 @@ snapshots: dependencies: is-callable: 1.2.7 - fs-constants@1.0.0: - optional: true + fs-constants@1.0.0: {} fsevents@2.3.2: optional: true @@ -3991,6 +5192,8 @@ snapshots: generator-function@2.0.1: {} + gensync@1.0.0-beta.2: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4019,8 +5222,16 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - github-from-package@0.0.0: - optional: true + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.4 + node-fetch-native: 1.6.7 + nypm: 0.6.4 + pathe: 2.0.3 + + github-from-package@0.0.0: {} glob-parent@5.1.2: dependencies: @@ -4099,8 +5310,7 @@ snapshots: html-url-attributes@3.0.1: {} - ieee754@1.2.1: - optional: true + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -4117,8 +5327,7 @@ snapshots: inherits@2.0.4: {} - ini@1.3.8: - optional: true + ini@1.3.8: {} inline-style-parser@0.2.7: {} @@ -4185,6 +5394,8 @@ snapshots: is-decimal@2.0.1: {} + is-docker@3.0.0: {} + is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: @@ -4205,6 +5416,10 @@ snapshots: is-hexadecimal@2.0.1: {} + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + is-map@2.0.3: {} is-negative-zero@2.0.3: {} @@ -4257,6 +5472,10 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-wsl@3.1.0: + dependencies: + is-inside-container: 1.0.0 + isarray@1.0.0: {} isarray@2.0.5: {} @@ -4274,12 +5493,18 @@ snapshots: jiti@1.21.7: {} + jiti@2.6.1: {} + + jose@6.1.3: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: dependencies: argparse: 2.0.1 + jsesc@3.1.0: {} + json-buffer@3.0.1: {} json-schema-traverse@0.4.1: {} @@ -4290,6 +5515,8 @@ snapshots: dependencies: minimist: 1.2.8 + json5@2.2.3: {} + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -4308,6 +5535,10 @@ snapshots: dependencies: json-buffer: 3.0.1 + kleur@3.0.3: {} + + kysely@0.28.10: {} + language-subtag-registry@0.3.23: {} language-tags@1.0.9: @@ -4327,6 +5558,8 @@ snapshots: dependencies: immediate: 3.0.6 + lilconfig@2.1.0: {} + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -4351,6 +5584,10 @@ snapshots: lru-cache@11.2.4: {} + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + make-cancellable-promise@1.3.2: {} make-event-props@1.6.2: {} @@ -4716,8 +5953,7 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 - mimic-response@3.1.0: - optional: true + mimic-response@3.1.0: {} minimatch@3.1.2: dependencies: @@ -4729,8 +5965,7 @@ snapshots: minimist@1.2.8: {} - mkdirp-classic@0.5.3: - optional: true + mkdirp-classic@0.5.3: {} ms@2.1.3: {} @@ -4742,8 +5977,9 @@ snapshots: nanoid@3.3.11: {} - napi-build-utils@2.0.0: - optional: true + nanostores@1.1.0: {} + + napi-build-utils@2.0.0: {} napi-postinstall@0.3.4: {} @@ -4751,7 +5987,7 @@ snapshots: next-tick@1.1.0: {} - next@15.5.9(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@15.5.9(@babel/core@7.28.6)(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@next/env': 15.5.9 '@swc/helpers': 0.5.15 @@ -4759,7 +5995,7 @@ snapshots: postcss: 8.4.31 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(react@19.2.3) + styled-jsx: 5.1.6(@babel/core@7.28.6)(react@19.2.3) optionalDependencies: '@next/swc-darwin-arm64': 15.5.7 '@next/swc-darwin-x64': 15.5.7 @@ -4778,13 +6014,22 @@ snapshots: node-abi@3.87.0: dependencies: semver: 7.7.3 - optional: true node-addon-api@7.1.1: optional: true + node-fetch-native@1.6.7: {} + + node-releases@2.0.27: {} + normalize-path@3.0.0: {} + nypm@0.6.4: + dependencies: + citty: 0.2.0 + pathe: 2.0.3 + tinyexec: 1.0.2 + object-assign@4.1.1: {} object-hash@3.0.0: {} @@ -4829,10 +6074,18 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + ohash@2.0.11: {} + once@1.4.0: dependencies: wrappy: 1.0.2 - optional: true + + open@10.2.0: + dependencies: + default-browser: 5.4.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 openai@6.16.0(zod@4.3.6): optionalDependencies: @@ -4888,11 +6141,50 @@ snapshots: path2d@0.2.2: optional: true + pathe@2.0.3: {} + pdfjs-dist@4.8.69: optionalDependencies: canvas: 3.2.1 path2d: 0.2.2 + perfect-debounce@2.1.0: {} + + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.10.1: {} + + pg-int8@1.0.1: {} + + pg-pool@3.11.0(pg@8.17.2): + dependencies: + pg: 8.17.2 + + pg-protocol@1.11.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.17.2: + dependencies: + pg-connection-string: 2.10.1 + pg-pool: 3.11.0(pg@8.17.2) + pg-protocol: 1.11.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -4903,6 +6195,12 @@ snapshots: pirates@4.0.7: {} + pkg-types@2.3.0: + dependencies: + confbox: 0.2.2 + exsolve: 1.0.8 + pathe: 2.0.3 + playwright-core@1.58.0: {} playwright@1.58.0: @@ -4961,6 +6259,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 @@ -4975,12 +6283,18 @@ snapshots: simple-get: 4.0.1 tar-fs: 2.1.4 tunnel-agent: 0.6.0 - optional: true prelude-ls@1.2.1: {} + prettier@3.8.1: {} + process-nextick-args@2.0.1: {} + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -4993,19 +6307,22 @@ snapshots: dependencies: end-of-stream: 1.4.5 once: 1.4.0 - optional: true punycode@2.3.1: {} queue-microtask@1.2.3: {} + rc9@2.1.2: + dependencies: + defu: 6.1.4 + destr: 2.0.5 + rc@1.2.8: dependencies: deep-extend: 0.6.0 ini: 1.3.8 minimist: 1.2.8 strip-json-comments: 2.0.1 - optional: true react-dnd-html5-backend@16.0.1: dependencies: @@ -5109,12 +6426,13 @@ snapshots: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 - optional: true readdirp@3.6.0: dependencies: picomatch: 2.3.1 + readdirp@5.0.0: {} + redux@4.2.1: dependencies: '@babel/runtime': 7.28.6 @@ -5130,6 +6448,8 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 + regexp-to-ast@0.5.0: {} + regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.8 @@ -5191,6 +6511,10 @@ snapshots: reusify@1.1.0: {} + rou3@0.7.12: {} + + run-applescript@7.1.0: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -5205,8 +6529,7 @@ snapshots: safe-buffer@5.1.2: {} - safe-buffer@5.2.1: - optional: true + safe-buffer@5.2.1: {} safe-push-apply@1.0.0: dependencies: @@ -5225,6 +6548,8 @@ snapshots: semver@7.7.3: {} + set-cookie-parser@2.7.2: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -5315,20 +6640,22 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 - simple-concat@1.0.1: - optional: true + simple-concat@1.0.1: {} simple-get@4.0.1: dependencies: decompress-response: 6.0.0 once: 1.4.0 simple-concat: 1.0.1 - optional: true + + sisteransi@1.0.5: {} source-map-js@1.2.1: {} space-separated-tokens@2.0.2: {} + split2@4.2.0: {} + stable-hash@0.0.5: {} stop-iteration-iterator@1.1.0: @@ -5393,7 +6720,6 @@ snapshots: string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 - optional: true stringify-entities@4.0.4: dependencies: @@ -5402,8 +6728,7 @@ snapshots: strip-bom@3.0.0: {} - strip-json-comments@2.0.1: - optional: true + strip-json-comments@2.0.1: {} strip-json-comments@3.1.1: {} @@ -5415,10 +6740,12 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(react@19.2.3): + styled-jsx@5.1.6(@babel/core@7.28.6)(react@19.2.3): dependencies: client-only: 0.0.1 react: 19.2.3 + optionalDependencies: + '@babel/core': 7.28.6 sucrase@3.35.1: dependencies: @@ -5474,7 +6801,6 @@ snapshots: mkdirp-classic: 0.5.3 pump: 3.0.3 tar-stream: 2.2.0 - optional: true tar-stream@2.2.0: dependencies: @@ -5483,7 +6809,6 @@ snapshots: fs-constants: 1.0.0 inherits: 2.0.4 readable-stream: 3.6.2 - optional: true thenify-all@1.6.0: dependencies: @@ -5495,6 +6820,8 @@ snapshots: tiny-invariant@1.3.3: {} + tinyexec@1.0.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -5526,7 +6853,6 @@ snapshots: tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 - optional: true type-check@0.4.0: dependencies: @@ -5635,6 +6961,12 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -5708,12 +7040,24 @@ snapshots: word-wrap@1.2.5: {} - wrappy@1.0.2: - optional: true + wrappy@1.0.2: {} + + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.0 + + xtend@4.0.2: {} + + yallist@3.1.1: {} yocto-queue@0.1.0: {} - zod@4.3.6: - optional: true + yocto-spinner@0.2.3: + dependencies: + yoctocolors: 2.1.2 + + yoctocolors@2.1.2: {} + + zod@4.3.6: {} zwitch@2.0.4: {} diff --git a/src/app/api/account/delete/route.ts b/src/app/api/account/delete/route.ts new file mode 100644 index 0000000..cc7d01d --- /dev/null +++ b/src/app/api/account/delete/route.ts @@ -0,0 +1,36 @@ +import { headers } from 'next/headers'; +import { NextResponse } from 'next/server'; +import { auth } from '@/lib/server/auth'; +import { db } from '@/lib/server/db'; +import { isAuthEnabled } from '@/lib/server/auth-config'; + +export async function DELETE() { + if (!isAuthEnabled() || !auth) { + return NextResponse.json({ error: 'Authentication disabled' }, { status: 403 }); + } + + const session = await auth.api.getSession({ + headers: await headers() + }); + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + // Directly delete user from database + await db.transaction(async (client) => { + // Deleting user usually cascades to sessions, accounts, etc if FKs are set up correctly + // But better-auth schemas do cascade. + await client.query('DELETE FROM "user" WHERE id = $1', [session.user.id]); + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Failed to delete account:', error); + return NextResponse.json( + { error: 'Failed to delete account' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..b49da69 --- /dev/null +++ b/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,11 @@ +import { auth } from "@/lib/server/auth"; // path to your auth file +import { toNextJsHandler } from "better-auth/next-js"; + +const handlers = auth + ? toNextJsHandler(auth) + : { + POST: async () => new Response("Auth disabled", { status: 404 }), + GET: async () => new Response("Auth disabled", { status: 404 }) + }; + +export const { POST, GET } = handlers; \ No newline at end of file diff --git a/src/app/api/rate-limit/status/route.ts b/src/app/api/rate-limit/status/route.ts new file mode 100644 index 0000000..12e1a07 --- /dev/null +++ b/src/app/api/rate-limit/status/route.ts @@ -0,0 +1,73 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@/lib/server/auth'; +import { rateLimiter, RATE_LIMITS } from '@/lib/server/rate-limiter'; +import { headers } from 'next/headers'; +import { isAuthEnabled } from '@/lib/server/auth-config'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + // If auth is not enabled, return unlimited status + if (!isAuthEnabled() || !auth) { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); + + return NextResponse.json({ + allowed: true, + currentCount: 0, + limit: Infinity, + remainingChars: Infinity, + resetTime: tomorrow.toISOString(), + userType: 'unauthenticated', + authEnabled: false + }); + } + + // Get session from auth + const session = await auth.api.getSession({ + headers: await headers() + }); + + // No session means unauthenticated + if (!session?.user) { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); + + return NextResponse.json({ + allowed: true, + currentCount: 0, + limit: RATE_LIMITS.ANONYMOUS, + remainingChars: RATE_LIMITS.ANONYMOUS, + resetTime: tomorrow.toISOString(), + userType: 'unauthenticated', + authEnabled: true + }); + } + + const isAnonymous = !session.user.email || session.user.email === ''; + + const result = await rateLimiter.getCurrentUsage({ + id: session.user.id, + isAnonymous + }); + + return NextResponse.json({ + allowed: result.allowed, + currentCount: result.currentCount, + limit: result.limit, + remainingChars: result.remainingChars, + resetTime: result.resetTime.toISOString(), + userType: isAnonymous ? 'anonymous' : 'authenticated', + authEnabled: true + }); + } catch (error) { + console.error('Error getting rate limit status:', error); + return NextResponse.json( + { error: 'Failed to get rate limit status' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index 6195d32..33a0bdd 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -6,6 +6,10 @@ import { LRUCache } from 'lru-cache'; import { createHash } from 'crypto'; import type { TTSRequestPayload } from '@/types/client'; import type { TTSError, TTSAudioBuffer } from '@/types/tts'; +import { headers } from 'next/headers'; +import { auth } from '@/lib/server/auth'; +import { rateLimiter, RATE_LIMITS } from '@/lib/server/rate-limiter'; +import { isAuthEnabled } from '@/lib/server/auth-config'; type CustomVoice = string; type ExtendedSpeechParams = Omit & { @@ -47,7 +51,7 @@ async function fetchTTSBufferWithRetry( const backoff = Number(process.env.TTS_RETRY_BACKOFF ?? 2); // Retry on 429 and 5xx only; never retry aborts - for (;;) { + for (; ;) { try { const response = await openai.audio.speech.create(createParams as SpeechCreateParams, { signal }); return await response.arrayBuffer(); @@ -98,13 +102,9 @@ function makeCacheKey(input: { export async function POST(req: NextRequest) { try { - // Get API credentials from headers or fall back to environment variables - const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none'; - const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE; - const provider = req.headers.get('x-tts-provider') || 'openai'; + // Parse body first to get text for rate limiting const body = (await req.json()) as TTSRequestPayload; const { text, voice, speed, format, model: req_model, instructions } = body; - console.log('Received TTS request:', { provider, req_model, voice, speed, format, hasInstructions: Boolean(instructions) }); if (!text || !voice || !speed) { const errorBody: TTSError = { @@ -113,6 +113,53 @@ export async function POST(req: NextRequest) { }; return NextResponse.json(errorBody, { status: 400 }); } + + // Auth and rate limiting check (only when auth is enabled) + if (isAuthEnabled() && auth) { + const session = await auth.api.getSession({ + headers: await headers() + }); + + if (!session?.user) { + return NextResponse.json( + { code: 'UNAUTHORIZED', message: 'Authentication required' }, + { status: 401 } + ); + } + + const isAnonymous = !session.user.email || session.user.email === ''; + const charCount = text.length; + + // Check rate limit + const rateLimitResult = await rateLimiter.checkAndIncrementLimit( + { id: session.user.id, isAnonymous }, + charCount + ); + + if (!rateLimitResult.allowed) { + return NextResponse.json( + { + code: 'RATE_LIMIT_EXCEEDED', + message: 'Daily character limit exceeded', + currentCount: rateLimitResult.currentCount, + limit: rateLimitResult.limit, + remainingChars: rateLimitResult.remainingChars, + resetTime: rateLimitResult.resetTime.toISOString(), + userType: isAnonymous ? 'anonymous' : 'authenticated', + upgradeHint: isAnonymous + ? `Sign up to increase your limit from ${(RATE_LIMITS.ANONYMOUS / 1000).toFixed(0)}K to ${(RATE_LIMITS.AUTHENTICATED / 1_000_000).toFixed(0)}M characters per day` + : undefined + }, + { status: 429 } + ); + } + } + + // Get API credentials from headers or fall back to environment variables + const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none'; + const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE; + const provider = req.headers.get('x-tts-provider') || 'openai'; + console.log('Received TTS request:', { provider, req_model, voice, speed, format, hasInstructions: Boolean(instructions) }); // Use default Kokoro model for Deepinfra if none specified, then fall back to a safe default const rawModel = provider === 'deepinfra' && !req_model ? 'hexgrad/Kokoro-82M' : req_model; const model: SpeechCreateParams['model'] = (rawModel ?? 'gpt-4o-mini-tts') as SpeechCreateParams['model']; @@ -125,10 +172,10 @@ export async function POST(req: NextRequest) { const normalizedVoice = ( !isKokoroModel(model as string) && voice.includes('+') - ? (voice.split('+')[0].trim()) - : voice + ? (voice.split('+')[0].trim()) + : voice ) as SpeechCreateParams['voice']; - + const createParams: ExtendedSpeechParams = { model: model, voice: normalizedVoice, @@ -214,7 +261,7 @@ export async function POST(req: NextRequest) { } }); } finally { - try { req.signal.removeEventListener('abort', onAbort); } catch {} + try { req.signal.removeEventListener('abort', onAbort); } catch { } } } @@ -248,7 +295,7 @@ export async function POST(req: NextRequest) { try { buffer = await entry.promise; } finally { - try { req.signal.removeEventListener('abort', onAbort); } catch {} + try { req.signal.removeEventListener('abort', onAbort); } catch { } } return new NextResponse(buffer, { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 164a6f8..1eed6f7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,7 @@ import { Metadata } from "next"; import { Footer } from "@/components/Footer"; import { Toaster } from 'react-hot-toast'; import { Analytics } from "@vercel/analytics/next"; +import { isAuthEnabled, getAuthBaseUrl } from '@/lib/server/auth-config'; export const metadata: Metadata = { title: "OpenReader WebUI", @@ -45,17 +46,23 @@ export const metadata: Metadata = { }, }; +// Force dynamic rendering so env vars are read at runtime, not build time +export const dynamic = 'force-dynamic'; + const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; -//const isDev = false; export default function RootLayout({ children }: { children: ReactNode }) { + // Read auth config inside the component to ensure runtime evaluation + const authEnabled = isAuthEnabled(); + const authBaseUrl = getAuthBaseUrl(); + return ( - +
{children} diff --git a/src/app/page.tsx b/src/app/page.tsx index fa48f73..9d83fd5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,27 +1,32 @@ import { HomeContent } from '@/components/HomeContent'; import { SettingsModal } from '@/components/SettingsModal'; +import { UserMenu } from '@/components/auth/UserMenu'; +import { AuthLoader } from '@/components/auth/AuthLoader'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; export default function Home() { return ( -
- -
-
-

OpenReader WebUI

-

- Open source document reader {isDev ? 'self-hosted server' : 'demo app'}. - Read & listen to PDF, EPUB, MD, and TXT documents with high quality text to speech voices. -

-
-
-
-
- -
-
+ +
+ + +
+
+

OpenReader WebUI

+

+ Open source document reader {isDev ? 'self-hosted server' : 'demo app'}. + Read & listen to PDF, EPUB, MD, and TXT documents with high quality text to speech voices. +

+
+
+
+
+ +
+
+
); } diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 3eb1602..5d9016d 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -9,9 +9,18 @@ import { TTSProvider } from '@/contexts/TTSContext'; import { ThemeProvider } from '@/contexts/ThemeContext'; import { ConfigProvider } from '@/contexts/ConfigContext'; import { HTMLProvider } from '@/contexts/HTMLContext'; +import { RateLimitProvider } from '@/components/rate-limit-provider'; +import { PrivacyPopup } from '@/components/privacy-popup'; +import { AuthConfigProvider } from '@/contexts/AuthConfigContext'; -export function Providers({ children }: { children: ReactNode }) { - return ( +interface ProvidersProps { + children: ReactNode; + authEnabled: boolean; + authBaseUrl: string | null; +} + +export function Providers({ children, authEnabled, authBaseUrl }: ProvidersProps) { + const content = ( @@ -20,6 +29,7 @@ export function Providers({ children }: { children: ReactNode }) { {children} + @@ -28,4 +38,15 @@ export function Providers({ children }: { children: ReactNode }) { ); + + // Wrap with RateLimitProvider only when auth is enabled + const wrappedContent = authEnabled ? ( + {content} + ) : content; + + return ( + + {wrappedContent} + + ); } diff --git a/src/app/signin/page.tsx b/src/app/signin/page.tsx new file mode 100644 index 0000000..4b9637d --- /dev/null +++ b/src/app/signin/page.tsx @@ -0,0 +1,286 @@ +'use client'; + +import { useState, useEffect, Suspense } from 'react'; +import { Button, Input } from '@headlessui/react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { getAuthClient } from '@/lib/auth-client'; +import { useAuthConfig } from '@/contexts/AuthConfigContext'; +import { showPrivacyPopup } from '@/components/privacy-popup'; +import { wasSignedOut, clearSignedOut } from '@/lib/session-utils'; +import { GithubIcon } from '@/components/icons/Icons'; +import { LoadingSpinner } from '@/components/Spinner'; + +function SessionExpiredLoader({ setSessionExpired }: { setSessionExpired: (v: boolean) => void }) { + const searchParams = useSearchParams(); + useEffect(() => { + const reason = searchParams.get('reason'); + setSessionExpired(reason === 'expired'); + }, [searchParams, setSessionExpired]); + return null; +} + +function SignInContent() { + const router = useRouter(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loadingEmail, setLoadingEmail] = useState(false); + const [loadingGithub, setLoadingGithub] = useState(false); + const [loadingGuest, setLoadingGuest] = useState(false); + const [rememberMe, setRememberMe] = useState(true); + const [sessionExpired, setSessionExpired] = useState(false); + const [justSignedOut, setJustSignedOut] = useState(false); + const [error, setError] = useState(null); + const { authEnabled, baseUrl } = useAuthConfig(); + + const isAnyLoading = loadingEmail || loadingGithub || loadingGuest; + + // Check if auth is enabled, redirect home if not + useEffect(() => { + if (!authEnabled) { + router.push('/'); + } + }, [router, authEnabled]); + + // Detect explicit sign-out + useEffect(() => { + wasSignedOut().then(signedOut => { + if (signedOut) { + setJustSignedOut(true); + clearSignedOut(); + } + }); + }, []); + + const validateEmail = (email: string): boolean => { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + }; + + const handleSignIn = async () => { + setError(null); + + if (!email.trim() || !validateEmail(email)) { + setError('Please enter a valid email address'); + return; + } + if (!password.trim()) { + setError('Password is required'); + return; + } + + setLoadingEmail(true); + + try { + const client = getAuthClient(baseUrl); + const result = await client.signIn.email({ + email: email.trim(), + password, + rememberMe + }); + + if (result.error) { + const errorMessage = result.error.message || 'An unknown error occurred'; + if (errorMessage.toLowerCase().includes('invalid') || + errorMessage.toLowerCase().includes('credentials')) { + setError('Invalid email or password'); + } else { + setError(errorMessage); + } + } else { + router.push('/'); + } + } catch (err) { + console.error('Sign in error:', err); + setError('Unable to connect. Please try again.'); + } finally { + setLoadingEmail(false); + } + }; + + const handleGithubSignIn = async () => { + setLoadingGithub(true); + try { + const client = getAuthClient(baseUrl); + await client.signIn.social({ + provider: 'github', + callbackURL: '/' + }); + } finally { + setLoadingGithub(false); + } + }; + + const handleGuestSignIn = async () => { + setLoadingGuest(true); + setError(null); + try { + const client = getAuthClient(baseUrl); + await client.signIn.anonymous(); + router.push('/'); + } catch (e) { + console.error('Anonymous sign-in failed:', e); + setError('Unable to continue as guest. Please try again.'); + } finally { + setLoadingGuest(false); + } + }; + + if (!authEnabled) { + return null; + } + + return ( +
+ + + + +
+

+ {sessionExpired ? 'Session Expired' : 'Sign In'} +

+

+ {sessionExpired + ? 'Please sign in again to continue' + : justSignedOut + ? 'Sign in to continue' + : 'Enter your email below to login'} +

+ + {/* Alerts */} + {(sessionExpired || justSignedOut) && ( +
+

+ {sessionExpired + ? 'Your session has expired. Please sign in again.' + : "You've been signed out."} +

+
+ )} + + {error && ( +
+

{error}

+
+ )} + +
+ {/* Email */} +
+ + { setEmail(e.target.value); setError(null); }} + placeholder="me@example.com" + className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm + focus:outline-none focus:ring-2 focus:ring-accent" + /> +
+ + {/* Password */} +
+ + { setPassword(e.target.value); setError(null); }} + placeholder="Password" + className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm + focus:outline-none focus:ring-2 focus:ring-accent" + /> +
+ + {/* Remember Me */} + + + {/* Sign In Button */} + + + {/* GitHub */} + + + {/* Guest */} + +
+ + {/* Footer */} +
+

+ Don't have an account?{' '} + + Sign up + +

+

+ By signing in, you agree to our{' '} + +

+
+
+
+ ); +} + +export default function SignInPage() { + return ( + + +
+ }> + + + ); +} diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx new file mode 100644 index 0000000..ea3d974 --- /dev/null +++ b/src/app/signup/page.tsx @@ -0,0 +1,240 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Button, Input } from '@headlessui/react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { getAuthClient } from '@/lib/auth-client'; +import { useAuthConfig } from '@/contexts/AuthConfigContext'; +import { showPrivacyPopup } from '@/components/privacy-popup'; +import { LoadingSpinner } from '@/components/Spinner'; +import toast from 'react-hot-toast'; + +export default function SignUpPage() { + const router = useRouter(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [passwordConfirmation, setPasswordConfirmation] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const { authEnabled, baseUrl } = useAuthConfig(); + + // Check if auth is enabled, redirect home if not + useEffect(() => { + if (!authEnabled) { + router.push('/'); + } + }, [router, authEnabled]); + + const validateEmail = (email: string): boolean => { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); + }; + + const validatePassword = (password: string) => { + const checks = { + length: password.length >= 8, + uppercase: /[A-Z]/.test(password), + lowercase: /[a-z]/.test(password), + number: /\d/.test(password), + special: /[!@#$%^&*(),.?":{}|<>]/.test(password) + }; + const strength = Object.values(checks).filter(Boolean).length; + return { checks, strength }; + }; + + const handleSignUp = async () => { + setError(null); + + if (!email.trim() || !validateEmail(email)) { + setError('Please enter a valid email address'); + return; + } + const { strength } = validatePassword(password); + if (strength < 3) { + setError('Password is too weak'); + return; + } + if (password !== passwordConfirmation) { + setError('Passwords do not match'); + return; + } + + setLoading(true); + + try { + const client = getAuthClient(baseUrl); + const result = await client.signUp.email({ + email: email.trim(), + password, + name: email.trim().split('@')[0], // Use part of email as name + }); + + if (result.error) { + const errorMessage = result.error.message || 'An unknown error occurred'; + if (errorMessage.toLowerCase().includes('already exists')) { + setError('An account with this email already exists'); + } else { + setError(errorMessage); + } + } else { + // Auto sign in + const signInResult = await client.signIn.email({ email: email.trim(), password }); + if (signInResult.error) { + toast.success('Account created! Please sign in.'); + router.push('/signin'); + } else { + toast.success('Account created successfully!'); + router.push('/'); + } + } + } catch (err) { + console.error('Signup error:', err); + setError('Unable to connect. Please try again.'); + } finally { + setLoading(false); + } + }; + + if (!authEnabled) { + return null; + } + + const { checks, strength } = validatePassword(password); + const strengthLabels = ['Very Weak', 'Weak', 'Fair', 'Good', 'Strong']; + const strengthColors = ['bg-red-500', 'bg-orange-500', 'bg-yellow-500', 'bg-blue-500', 'bg-green-500']; + + return ( +
+
+

Sign Up

+

Create your account to get started

+ + {error && ( +
+

{error}

+
+ )} + +
+ {/* Email */} +
+ + setEmail(e.target.value)} + placeholder="me@example.com" + className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm + focus:outline-none focus:ring-2 focus:ring-accent" + /> +
+ + {/* Password */} +
+ +
+ setPassword(e.target.value)} + placeholder="Password" + className="w-full rounded-lg bg-background py-2 px-3 pr-10 text-foreground shadow-sm + focus:outline-none focus:ring-2 focus:ring-accent" + /> + +
+ + {/* Password Strength */} + {password && ( +
+
+ {[0, 1, 2, 3, 4].map((i) => ( +
+ ))} +
+

= 3 ? 'text-green-600' : 'text-red-600'}`}> + {strengthLabels[strength - 1] || 'Very Weak'} +

+
+ {Object.entries(checks).map(([key, passed]) => ( +
+ {passed ? '✓' : '○'} + + {key === 'length' && 'At least 8 characters'} + {key === 'uppercase' && 'Uppercase letter'} + {key === 'lowercase' && 'Lowercase letter'} + {key === 'number' && 'Number'} + {key === 'special' && 'Special character'} + +
+ ))} +
+
+ )} +
+ + {/* Confirm Password */} +
+ + setPasswordConfirmation(e.target.value)} + placeholder="Confirm Password" + className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm + focus:outline-none focus:ring-2 focus:ring-accent" + /> + {passwordConfirmation && password && ( +

+ {password === passwordConfirmation ? '✓ Passwords match' : '✗ Passwords do not match'} +

+ )} +
+ + {/* Sign Up Button */} + +
+ + {/* Footer */} +
+

+ Already have an account?{' '} + + Sign in + +

+

+ By creating an account, you agree to our{' '} + +

+
+
+
+ ); +} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index dc25e1f..da8a8a9 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -19,6 +19,7 @@ import { TabPanels, TabPanel, } from '@headlessui/react'; +import Link from 'next/link'; import { useTheme } from '@/contexts/ThemeContext'; import { useConfig } from '@/contexts/ConfigContext'; import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons'; @@ -31,6 +32,10 @@ import { THEMES } from '@/contexts/ThemeContext'; import { deleteServerDocuments } from '@/lib/client'; import { DocumentSelectionModal } from '@/components/DocumentSelectionModal'; import { BaseDocument } from '@/types/documents'; +import { getAuthClient } from '@/lib/auth-client'; +import { useAuthSession } from '@/hooks/useAuth'; +import { markSignedOut, clearSignedOut } from '@/lib/session-utils'; +import { useAuthConfig } from '@/contexts/AuthConfigContext'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; @@ -56,17 +61,17 @@ export function SettingsModal() { const [isImportingLibrary, setIsImportingLibrary] = useState(false); const [isSelectionModalOpen, setIsSelectionModalOpen] = useState(false); const [selectionModalProps, setSelectionModalProps] = useState<{ - title: string; - confirmLabel: string; - mode: 'library' | 'load' | 'save'; - defaultSelected: boolean; - initialFiles?: BaseDocument[]; - fetcher?: () => Promise; + title: string; + confirmLabel: string; + mode: 'library' | 'load' | 'save'; + defaultSelected: boolean; + initialFiles?: BaseDocument[]; + fetcher?: () => Promise; }>({ - title: '', - confirmLabel: '', - mode: 'library', - defaultSelected: false + title: '', + confirmLabel: '', + mode: 'library', + defaultSelected: false }); const [showProgress, setShowProgress] = useState(false); @@ -76,7 +81,10 @@ export function SettingsModal() { const selectedTheme = themes.find(t => t.id === theme) || themes[0]; const [showClearLocalConfirm, setShowClearLocalConfirm] = useState(false); const [showClearServerConfirm, setShowClearServerConfirm] = useState(false); + const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false); const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation(); + const { authEnabled, baseUrl: authBaseUrl } = useAuthConfig(); + const { data: session } = useAuthSession(); const ttsProviders = useMemo(() => [ { id: 'custom-openai', name: 'Custom OpenAI-Like' }, @@ -171,53 +179,53 @@ export function SettingsModal() { const pdfs = await getAllPdfDocuments(); const epubs = await getAllEpubDocuments(); const htmls = await getAllHtmlDocuments(); - + const allDocs: BaseDocument[] = [ - ...pdfs.map(d => ({ ...d, type: 'pdf' as const })), - ...epubs.map(d => ({ ...d, type: 'epub' as const })), - ...htmls.map(d => ({ ...d, type: 'html' as const })) + ...pdfs.map(d => ({ ...d, type: 'pdf' as const })), + ...epubs.map(d => ({ ...d, type: 'epub' as const })), + ...htmls.map(d => ({ ...d, type: 'html' as const })) ]; setSelectionModalProps({ - title: 'Save to Server', - confirmLabel: 'Save', - mode: 'save', - defaultSelected: true, - initialFiles: allDocs + title: 'Save to Server', + confirmLabel: 'Save', + mode: 'save', + defaultSelected: true, + initialFiles: allDocs }); setIsSelectionModalOpen(true); }; const handleLoad = async () => { setSelectionModalProps({ - title: 'Load from Server', - confirmLabel: 'Load', - mode: 'load', - defaultSelected: true, - fetcher: async () => { - const res = await fetch('/api/documents?list=true'); - if (!res.ok) throw new Error('Failed to list server documents'); - const data = await res.json(); - // Handle case where API might return error object - if (data.error) throw new Error(data.error); - return data.documents || []; - } + title: 'Load from Server', + confirmLabel: 'Load', + mode: 'load', + defaultSelected: true, + fetcher: async () => { + const res = await fetch('/api/documents?list=true'); + if (!res.ok) throw new Error('Failed to list server documents'); + const data = await res.json(); + // Handle case where API might return error object + if (data.error) throw new Error(data.error); + return data.documents || []; + } }); setIsSelectionModalOpen(true); }; const handleImportLibrary = async () => { setSelectionModalProps({ - title: 'Import from Library', - confirmLabel: 'Import', - mode: 'library', - defaultSelected: false, - fetcher: async () => { - const res = await fetch('/api/documents/library?limit=10000'); - if (!res.ok) throw new Error('Failed to list library documents'); - const data = await res.json(); - return data.documents || []; - } + title: 'Import from Library', + confirmLabel: 'Import', + mode: 'library', + defaultSelected: false, + fetcher: async () => { + const res = await fetch('/api/documents/library?limit=10000'); + if (!res.ok) throw new Error('Failed to list library documents'); + const data = await res.json(); + return data.documents || []; + } }); setIsSelectionModalOpen(true); }; @@ -225,9 +233,9 @@ export function SettingsModal() { const handleModalConfirm = async (selectedFiles: BaseDocument[]) => { const controller = new AbortController(); setAbortController(controller); - + const mode = selectionModalProps.mode; - + // Close modal? Maybe keep open until started? // Let's close it here, process starts. // Actually we keep it open if we want to show loading state INSIDE modal? @@ -236,57 +244,57 @@ export function SettingsModal() { setIsSelectionModalOpen(false); try { - setShowProgress(true); - setProgress(0); - - if (mode === 'save') { - setIsSyncing(true); - setOperationType('sync'); - setStatusMessage('Preparing documents...'); - await syncSelectedDocumentsToServer(selectedFiles, (progress, status) => { - if (controller.signal.aborted) return; - setProgress(progress); - if (status) setStatusMessage(status); - }, controller.signal); - } else if (mode === 'load') { - setIsLoading(true); - setOperationType('load'); - setStatusMessage('Downloading documents...'); - // Need ids - const ids = selectedFiles.map(f => f.id); - await loadSelectedDocumentsFromServer(ids, (progress, status) => { - if (controller.signal.aborted) return; - setProgress(progress); - if (status) setStatusMessage(status); - }, controller.signal); - if (!controller.signal.aborted) setStatusMessage('Documents loaded'); - } else if (mode === 'library') { - setIsImportingLibrary(true); - setOperationType('library'); - setStatusMessage('Importing selected documents...'); - await importSelectedDocuments(selectedFiles, (progress, status) => { - if (controller.signal.aborted) return; - setProgress(progress); - if (status) setStatusMessage(status); - }, controller.signal); - } + setShowProgress(true); + setProgress(0); + + if (mode === 'save') { + setIsSyncing(true); + setOperationType('sync'); + setStatusMessage('Preparing documents...'); + await syncSelectedDocumentsToServer(selectedFiles, (progress, status) => { + if (controller.signal.aborted) return; + setProgress(progress); + if (status) setStatusMessage(status); + }, controller.signal); + } else if (mode === 'load') { + setIsLoading(true); + setOperationType('load'); + setStatusMessage('Downloading documents...'); + // Need ids + const ids = selectedFiles.map(f => f.id); + await loadSelectedDocumentsFromServer(ids, (progress, status) => { + if (controller.signal.aborted) return; + setProgress(progress); + if (status) setStatusMessage(status); + }, controller.signal); + if (!controller.signal.aborted) setStatusMessage('Documents loaded'); + } else if (mode === 'library') { + setIsImportingLibrary(true); + setOperationType('library'); + setStatusMessage('Importing selected documents...'); + await importSelectedDocuments(selectedFiles, (progress, status) => { + if (controller.signal.aborted) return; + setProgress(progress); + if (status) setStatusMessage(status); + }, controller.signal); + } } catch (error) { - if (controller.signal.aborted) { - console.log(`${mode} operation cancelled`); - setStatusMessage('Operation cancelled'); - } else { - console.error(`${mode} failed:`, error); - setStatusMessage(`${mode} failed. Please try again.`); - } + if (controller.signal.aborted) { + console.log(`${mode} operation cancelled`); + setStatusMessage('Operation cancelled'); + } else { + console.error(`${mode} failed:`, error); + setStatusMessage(`${mode} failed. Please try again.`); + } } finally { - setIsSyncing(false); - setIsLoading(false); - setIsImportingLibrary(false); - setShowProgress(false); - setProgress(0); - setStatusMessage(''); - setAbortController(null); + setIsSyncing(false); + setIsLoading(false); + setIsImportingLibrary(false); + setShowProgress(false); + setProgress(0); + setStatusMessage(''); + setAbortController(null); } }; @@ -306,6 +314,32 @@ export function SettingsModal() { setShowClearServerConfirm(false); }; + + + const handleSignOut = async () => { + await markSignedOut(); + const client = getAuthClient(authBaseUrl); + await client.signOut(); + window.location.reload(); + }; + + const handleDeleteAccount = async () => { + try { + const res = await fetch('/api/account/delete', { method: 'DELETE' }); + if (!res.ok) throw new Error('Failed to delete account'); + + // Sign out locally + const client = getAuthClient(authBaseUrl); + await client.signOut(); + // Clear the "signed out" flag so AuthLoader triggers auto-anon-login + clearSignedOut(); + window.location.href = '/'; + } catch (error) { + console.error('Failed to delete account:', error); + } + setShowDeleteAccountConfirm(false); + }; + const handleInputChange = (type: 'apiKey' | 'baseUrl', value: string) => { if (type === 'apiKey') { setLocalApiKey(value === '' ? '' : value); @@ -330,8 +364,9 @@ export function SettingsModal() { const tabs = [ { name: 'API', icon: '🔑' }, - { name: 'Appearance', icon: '✨' }, - { name: 'Documents', icon: '📄' } + { name: 'Theme', icon: '✨' }, + { name: 'Docs', icon: '📄' }, + ...(authEnabled ? [{ name: 'User', icon: '👤' }] : []) ]; return ( @@ -441,8 +476,7 @@ export function SettingsModal() { - `relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${ - active ? 'bg-offbase text-accent' : 'text-foreground' + `relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground' }` } value={provider} @@ -516,8 +550,7 @@ export function SettingsModal() { - `relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${ - active ? 'bg-offbase text-accent' : 'text-foreground' + `relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground' }` } value={model} @@ -599,13 +632,14 @@ export function SettingsModal() { setModelValue('kokoro'); setCustomModelInput(''); setLocalTTSInstructions(''); + setLocalTTSInstructions(''); }} > Reset
+ + {authEnabled && ( + +
+
+

Current Session

+
+

Logged in as:

+

{session?.user?.name || 'Guest'}

+

{session?.user?.email}

+ {session?.user?.isAnonymous && ( +

Anonymous / Guest Account

+ )} +
+
+ +
+ {!session?.user?.isAnonymous ? ( + <> + + +
+

Danger Zone

+ +

+ This will permanently delete your account and data. You will be returned to a fresh guest session. +

+
+ + ) : ( +
+

+ You are using a temporary guest account. Sign up to save your progress permanently. +

+
+ + + + + + +
+
+ )} +
+
+
+ )} @@ -752,7 +854,7 @@ export function SettingsModal() { - + - - setShowDeleteAccountConfirm(false)} + onConfirm={handleDeleteAccount} + title="Delete Account" + message="Are you sure you want to delete your account? This action cannot be undone and all your data will be lost." + confirmText="Delete Account" + isDangerous={true} + /> + + - !isImportingLibrary && !isSyncing && !isLoading && setIsSelectionModalOpen(false)} onConfirm={handleModalConfirm} diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx index 1c4e0b2..3344e67 100644 --- a/src/components/Spinner.tsx +++ b/src/components/Spinner.tsx @@ -1,7 +1,11 @@ // Loading spinner component -export function LoadingSpinner() { +interface SpinnerProps { + className?: string; +} + +export function LoadingSpinner({ className }: SpinnerProps = {}) { return ( -
+
); diff --git a/src/components/auth/AuthLoader.tsx b/src/components/auth/AuthLoader.tsx new file mode 100644 index 0000000..80877db --- /dev/null +++ b/src/components/auth/AuthLoader.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { useEffect, useState, ReactNode } from 'react'; +import { useAuthConfig } from '@/contexts/AuthConfigContext'; +import { useAuthSession } from '@/hooks/useAuth'; +import { getAuthClient } from '@/lib/auth-client'; +import { wasSignedOut } from '@/lib/session-utils'; +import { LoadingSpinner } from '@/components/Spinner'; + +export function AuthLoader({ children }: { children: ReactNode }) { + const { authEnabled, baseUrl } = useAuthConfig(); + const { data: session, isPending } = useAuthSession(); + const [isAutoLoggingIn, setIsAutoLoggingIn] = useState(false); + const [isCheckingSignOut, setIsCheckingSignOut] = useState(true); + + useEffect(() => { + // Determine if we need to check sign-out status or proceed + const checkStatus = async () => { + // If auth is disabled, stop checking immediately + if (!authEnabled) { + setIsCheckingSignOut(false); + return; + } + + // If session is still loading, wait + if (isPending) return; + + // If we have a session, we are done checking + if (session) { + setIsCheckingSignOut(false); + return; + } + + // No session, check if explicitly signed out + const userWasSignedOut = await wasSignedOut(); + if (userWasSignedOut) { + setIsCheckingSignOut(false); // Render children unauthenticated + return; + } + + // Not signed out, start auto-login + setIsAutoLoggingIn(true); + setIsCheckingSignOut(false); // Stop checking sign-out, now in auto-login mode + + try { + const client = getAuthClient(baseUrl); + await client.signIn.anonymous(); + } catch (err) { + console.error('Auto-login failed', err); + } finally { + setIsAutoLoggingIn(false); + } + }; + + checkStatus(); + }, [session, isPending, authEnabled, baseUrl]); + + // Show loader if: + // 1. Auth client is initializing (isPending) AND auth is enabled + // 2. We are checking the sign-out status (Dexie) + // 3. We are actively auto-logging in + const isLoading = (isPending && authEnabled) || isCheckingSignOut || isAutoLoggingIn; + + if (isLoading) { + return ( +
+ +

+ {isAutoLoggingIn ? 'Logging in anonymously...' : 'Loading...'} +

+
+ ); + } + + return <>{children}; +} diff --git a/src/components/auth/SessionManager.tsx b/src/components/auth/SessionManager.tsx new file mode 100644 index 0000000..1d70d6d --- /dev/null +++ b/src/components/auth/SessionManager.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { useAuthConfig } from '@/contexts/AuthConfigContext'; +import { useAuthSession } from '@/hooks/useAuth'; +import { getAuthClient } from '@/lib/auth-client'; +import { wasSignedOut } from '@/lib/session-utils'; + +export function SessionManager() { + const { authEnabled, baseUrl } = useAuthConfig(); + const { data: session, isPending } = useAuthSession(); + const attemptRef = useRef(false); + + useEffect(() => { + // Only run if auth is enabled + if (!authEnabled) return; + + // Wait for session check to complete + if (isPending) return; + + // If we have a session, we're good + if (session) return; + + // Prevent multiple attempts + if (attemptRef.current) return; + + const checkAndSignIn = async () => { + attemptRef.current = true; + try { + // Check if user explicitly signed out + const signedOut = await wasSignedOut(); + + // If not explicitly signed out, sign in anonymously + if (!signedOut) { + console.log('No session found, signing in anonymously...'); + const client = getAuthClient(baseUrl); + await client.signIn.anonymous(); + } + } catch (error) { + console.error('Error in session manager:', error); + } + }; + + checkAndSignIn(); + }, [session, isPending, authEnabled, baseUrl]); + + return null; +} diff --git a/src/components/auth/UserMenu.tsx b/src/components/auth/UserMenu.tsx new file mode 100644 index 0000000..cfeec5d --- /dev/null +++ b/src/components/auth/UserMenu.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { Button } from '@headlessui/react'; +import Link from 'next/link'; +import { useAuthConfig } from '@/contexts/AuthConfigContext'; +import { useAuthSession } from '@/hooks/useAuth'; +import { getAuthClient } from '@/lib/auth-client'; +import { markSignedOut } from '@/lib/session-utils'; +import { useRouter } from 'next/navigation'; + +export function UserMenu() { + const { authEnabled, baseUrl } = useAuthConfig(); + const { data: session, isPending } = useAuthSession(); + const router = useRouter(); + + if (!authEnabled || isPending) return null; + + const handleSignOut = async () => { + await markSignedOut(); + const client = getAuthClient(baseUrl); + await client.signOut(); + router.refresh(); + }; + + if (!session || session.user.isAnonymous) { + return ( +
+ + + + + + +
+ ); + } + + return ( +
+
+ + {session.user.name || 'Guest'} + + + {session.user.email} + +
+ + +
+ ); +} diff --git a/src/components/privacy-popup.tsx b/src/components/privacy-popup.tsx new file mode 100644 index 0000000..9775544 --- /dev/null +++ b/src/components/privacy-popup.tsx @@ -0,0 +1,211 @@ +'use client'; + +import { Fragment, useState, useEffect, useCallback } from 'react'; +import { + Dialog, + DialogPanel, + DialogTitle, + Transition, + TransitionChild, + Button, +} from '@headlessui/react'; +import { updateAppConfig, getAppConfig } from '@/lib/dexie'; + +interface PrivacyPopupProps { + onAccept?: () => void; +} + +export function PrivacyPopup({ onAccept }: PrivacyPopupProps) { + const [isOpen, setIsOpen] = useState(false); + + const checkPrivacyAccepted = useCallback(async () => { + const config = await getAppConfig(); + if (!config?.privacyAccepted) { + setIsOpen(true); + } + }, []); + + useEffect(() => { + checkPrivacyAccepted(); + }, [checkPrivacyAccepted]); + + const handleAccept = async () => { + await updateAppConfig({ privacyAccepted: true }); + setIsOpen(false); + onAccept?.(); + }; + + return ( + + { }}> + +
+ + +
+
+ + + + Privacy & Data Usage + + +
+

Documents are uploaded to your local browser cache.

+

+ Each paragraph of the document you are viewing is sent to Deepinfra + for audio generation through a Vercel backend proxy, containing a + shared caching pool. +

+

The audio is streamed back to your browser and played in real-time.

+

+ Self-hosting is the recommended way to use this app for a truly secure experience. +

+

+ This site uses Vercel Analytics to collect anonymous usage data to help improve the service. +

+
+ +
+ +
+
+
+
+
+
+
+ ); +} + +/** + * Function to programmatically show the privacy popup + * This can be called from signin/signup components + */ +export function showPrivacyPopup(): void { + // Create a temporary container for the popup + const container = document.createElement('div'); + container.id = 'privacy-popup-container'; + document.body.appendChild(container); + + // Import React and render the popup + import('react-dom/client').then(({ createRoot }) => { + import('react').then((React) => { + const root = createRoot(container); + + const PopupWrapper = () => { + const [show, setShow] = useState(true); + + const handleClose = () => { + setShow(false); + setTimeout(() => { + root.unmount(); + container.remove(); + }, 300); + }; + + if (!show) return null; + + return ( + + + +
+ + +
+
+ + + + Privacy & Data Usage + + +
+

Documents are uploaded to your local browser cache.

+

+ Each paragraph of the document you are viewing is sent to Deepinfra + for audio generation through a Vercel backend proxy, containing a + shared caching pool. +

+

The audio is streamed back to your browser and played in real-time.

+

+ Self-hosting is the recommended way to use this app for a truly secure experience. +

+

+ This site uses Vercel Analytics to collect anonymous usage data. +

+
+ +
+ +
+
+
+
+
+
+
+ ); + }; + + root.render(React.createElement(PopupWrapper)); + }); + }); +} diff --git a/src/components/rate-limit-banner.tsx b/src/components/rate-limit-banner.tsx new file mode 100644 index 0000000..49a8a7c --- /dev/null +++ b/src/components/rate-limit-banner.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { useRateLimit, formatCharCount } from '@/components/rate-limit-provider'; +import { useAuthConfig } from '@/contexts/AuthConfigContext'; +import Link from 'next/link'; + +interface RateLimitBannerProps { + className?: string; +} + +export function RateLimitBanner({ className = '' }: RateLimitBannerProps) { + const { status, isAtLimit, timeUntilReset } = useRateLimit(); + const { authEnabled } = useAuthConfig(); + + // Don't show banner if auth is not enabled or if not at limit + if (!authEnabled || !status?.authEnabled || !isAtLimit) { + return null; + } + + const isAnonymous = status.userType === 'anonymous'; + + return ( +
+
+
+

+ Daily TTS limit reached +

+

+ {`You've used ${formatCharCount(status.currentCount)} of ${formatCharCount(status.limit)} characters today.`} + {' Resets in '}{timeUntilReset}. +

+
+ + {isAnonymous && ( + + Sign up for 4x more + + )} +
+
+ ); +} + +/** + * Compact version for inline display + */ +export function RateLimitIndicator({ className = '' }: RateLimitBannerProps) { + const { status, isAtLimit } = useRateLimit(); + const { authEnabled } = useAuthConfig(); + + // Don't show if auth is not enabled + if (!authEnabled || !status?.authEnabled) { + return null; + } + + const percentage = status.limit === Infinity + ? 0 + : Math.min(100, (status.currentCount / status.limit) * 100); + + const isWarning = percentage >= 80; + + if (isAtLimit) { + return ( + + Limit reached + + ); + } + + if (isWarning) { + return ( + + {formatCharCount(status.remainingChars)} chars left + + ); + } + + return null; +} diff --git a/src/components/rate-limit-provider.tsx b/src/components/rate-limit-provider.tsx new file mode 100644 index 0000000..6eb4581 --- /dev/null +++ b/src/components/rate-limit-provider.tsx @@ -0,0 +1,206 @@ +'use client'; + +import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'; +import { useAuthConfig } from '@/contexts/AuthConfigContext'; + +export interface RateLimitStatus { + allowed: boolean; + currentCount: number; + limit: number; + remainingChars: number; + resetTime: Date; + userType: 'anonymous' | 'authenticated' | 'unauthenticated'; + authEnabled: boolean; +} + +export interface RateLimitContextType { + status: RateLimitStatus | null; + loading: boolean; + error: string | null; + refresh: () => Promise; + isAtLimit: boolean; + timeUntilReset: string; + incrementCount: (charCount: number) => void; + onTTSStart: () => void; + onTTSComplete: () => void; +} + +const RateLimitContext = createContext(null); + +export function useRateLimit(): RateLimitContextType { + const context = useContext(RateLimitContext); + if (!context) { + throw new Error('useRateLimit must be used within a RateLimitProvider'); + } + return context; +} + +interface RateLimitProviderProps { + children: React.ReactNode; +} + +function calculateTimeUntilReset(resetTime: Date): string { + const now = new Date(); + const timeDiff = resetTime.getTime() - now.getTime(); + + if (timeDiff <= 0) { + return 'Soon'; + } + + const hours = Math.floor(timeDiff / (1000 * 60 * 60)); + const minutes = Math.floor((timeDiff % (1000 * 60 * 60)) / (1000 * 60)); + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } else { + return `${minutes}m`; + } +} + +function formatCharCount(count: number): string { + if (count >= 1_000_000) { + return `${(count / 1_000_000).toFixed(1)}M`; + } else if (count >= 1_000) { + return `${(count / 1_000).toFixed(0)}K`; + } + return count.toString(); +} + +export { formatCharCount }; + +export function RateLimitProvider({ children }: RateLimitProviderProps) { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const { authEnabled } = useAuthConfig(); + + // Track pending TTS operations to delay count updates + const pendingTTSRef = useRef(0); + const updateTimeoutRef = useRef(null); + + const fetchStatus = useCallback(async () => { + // Skip if auth is not enabled + if (!authEnabled) { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); + + setStatus({ + allowed: true, + currentCount: 0, + limit: Infinity, + remainingChars: Infinity, + resetTime: tomorrow, + userType: 'unauthenticated', + authEnabled: false + }); + setLoading(false); + return; + } + + try { + setLoading(true); + setError(null); + + const response = await fetch('/api/rate-limit/status'); + + if (!response.ok) { + throw new Error(`Failed to fetch rate limit status: ${response.status}`); + } + + const data = await response.json(); + + setStatus({ + ...data, + resetTime: new Date(data.resetTime) + }); + } catch (err) { + console.error('Error fetching rate limit status:', err); + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + }, [authEnabled]); + + useEffect(() => { + fetchStatus(); + }, [fetchStatus]); + + // Calculate time until reset + const timeUntilReset = status ? calculateTimeUntilReset(status.resetTime) : ''; + const isAtLimit = status ? status.remainingChars <= 0 : false; + + // Increment count locally (for immediate UI feedback) + const incrementCount = useCallback((charCount: number) => { + setStatus(prevStatus => { + if (!prevStatus) return prevStatus; + + const newCurrentCount = prevStatus.currentCount + charCount; + const newRemainingChars = Math.max(0, prevStatus.limit - newCurrentCount); + + return { + ...prevStatus, + currentCount: newCurrentCount, + remainingChars: newRemainingChars, + allowed: newRemainingChars > 0 + }; + }); + }, []); + + // Called when a TTS request starts + const onTTSStart = useCallback(() => { + pendingTTSRef.current += 1; + + // Clear any existing timeout + if (updateTimeoutRef.current) { + clearTimeout(updateTimeoutRef.current); + updateTimeoutRef.current = null; + } + }, []); + + // Called when a TTS request completes (success or error) + const onTTSComplete = useCallback(() => { + pendingTTSRef.current = Math.max(0, pendingTTSRef.current - 1); + + // Clear any existing timeout + if (updateTimeoutRef.current) { + clearTimeout(updateTimeoutRef.current); + updateTimeoutRef.current = null; + } + + // If no more pending requests, schedule an update + if (pendingTTSRef.current === 0) { + updateTimeoutRef.current = setTimeout(() => { + fetchStatus(); + updateTimeoutRef.current = null; + }, 1000); // Wait 1 second after completion to refresh + } + }, [fetchStatus]); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (updateTimeoutRef.current) { + clearTimeout(updateTimeoutRef.current); + } + }; + }, []); + + const contextValue: RateLimitContextType = { + status, + loading, + error, + refresh: fetchStatus, + isAtLimit, + timeUntilReset, + incrementCount, + onTTSStart, + onTTSComplete + }; + + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/src/contexts/AuthConfigContext.tsx b/src/contexts/AuthConfigContext.tsx new file mode 100644 index 0000000..f95dd22 --- /dev/null +++ b/src/contexts/AuthConfigContext.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { createContext, useContext, ReactNode } from 'react'; + +interface AuthConfig { + authEnabled: boolean; + baseUrl: string | null; +} + +const AuthConfigContext = createContext({ + authEnabled: false, + baseUrl: null, +}); + +export function AuthConfigProvider({ + children, + authEnabled, + baseUrl, +}: { + children: ReactNode; + authEnabled: boolean; + baseUrl: string | null; +}) { + return ( + + {children} + + ); +} + +export function useAuthConfig() { + return useContext(AuthConfigContext); +} diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts new file mode 100644 index 0000000..2f18287 --- /dev/null +++ b/src/hooks/useAuth.ts @@ -0,0 +1,36 @@ +'use client'; + +import { useMemo } from 'react'; +import { useAuthConfig } from '@/contexts/AuthConfigContext'; +import { getAuthClient } from '@/lib/auth-client'; + +/** + * Hook that provides auth client methods configured with the correct baseUrl from server. + * Use this hook in components that need to call auth methods (signIn, signUp, etc.) + */ +export function useAuth() { + const { baseUrl, authEnabled } = useAuthConfig(); + + const client = useMemo(() => { + return getAuthClient(baseUrl); + }, [baseUrl]); + + return { + authEnabled, + baseUrl, + signIn: client.signIn, + signUp: client.signUp, + signOut: client.signOut, + useSession: client.useSession, + getSession: client.getSession, + }; +} + +/** + * Hook for session that uses the correct baseUrl from context + */ +export function useAuthSession() { + const { baseUrl } = useAuthConfig(); + const client = useMemo(() => getAuthClient(baseUrl), [baseUrl]); + return client.useSession(); +} diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts new file mode 100644 index 0000000..cabc47a --- /dev/null +++ b/src/lib/auth-client.ts @@ -0,0 +1,35 @@ +import { createAuthClient } from "better-auth/react"; +import { anonymousClient } from "better-auth/client/plugins"; + +// Factory function to create auth client with specific baseUrl +function createAuthClientWithUrl(baseUrl: string) { + return createAuthClient({ + baseURL: baseUrl, + plugins: [anonymousClient()], + }); +} + +// Cache for auth client instances by baseUrl +const clientCache = new Map>(); + +export function getAuthClient(baseUrl: string | null) { + const effectiveUrl = baseUrl || "http://localhost:3003"; + + if (!clientCache.has(effectiveUrl)) { + clientCache.set(effectiveUrl, createAuthClientWithUrl(effectiveUrl)); + } + + return clientCache.get(effectiveUrl)!; +} + +// Default client for backwards compatibility (will use localhost in dev) +// Components should prefer useAuth() hook which gets baseUrl from context +export const authClient = getAuthClient(null); + +export const { + signIn, + signUp, + signOut, + useSession, + getSession +} = authClient; \ No newline at end of file diff --git a/src/lib/server/auth-config.ts b/src/lib/server/auth-config.ts new file mode 100644 index 0000000..2279e9e --- /dev/null +++ b/src/lib/server/auth-config.ts @@ -0,0 +1,17 @@ +/** + * Centralized auth configuration check. + * Auth is only enabled when BOTH BETTER_AUTH_SECRET and BETTER_AUTH_URL are set. + */ +export function isAuthEnabled(): boolean { + return !!(process.env.BETTER_AUTH_SECRET && process.env.BETTER_AUTH_URL); +} + +/** + * Get the auth base URL if auth is enabled, otherwise null. + */ +export function getAuthBaseUrl(): string | null { + if (!isAuthEnabled()) { + return null; + } + return process.env.BETTER_AUTH_URL || null; +} diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts new file mode 100644 index 0000000..fcb4414 --- /dev/null +++ b/src/lib/server/auth.ts @@ -0,0 +1,106 @@ +import { betterAuth } from "better-auth"; +import { nextCookies } from "better-auth/next-js"; +import { anonymous } from "better-auth/plugins"; +import { Pool } from "pg"; +import { rateLimiter } from "@/lib/server/rate-limiter"; +import { isAuthEnabled } from "@/lib/server/auth-config"; + +const getAuthDatabase = () => { + if (process.env.POSTGRES_URL) { + return new Pool({ + connectionString: process.env.POSTGRES_URL, + ssl: process.env.NODE_ENV === "production" ? { rejectUnauthorized: false } : false, + }); + } + + // Fallback to SQLite + // We need to dynamically import or require better-sqlite3 to avoid build issues if it's not used? + // Actually better-auth supports it directly, we just pass the instance. + /* eslint-disable @typescript-eslint/no-require-imports */ + const Database = require("better-sqlite3"); + const path = require("path"); + const fs = require("fs"); + /* eslint-enable @typescript-eslint/no-require-imports */ + + // Ensure directory exists + const dbPath = path.join(process.cwd(), 'docstore', 'sqlite3.db'); + const dir = path.dirname(dbPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + return new Database(dbPath); +}; + +const createAuth = () => betterAuth({ + database: getAuthDatabase(), + secret: process.env.BETTER_AUTH_SECRET!, + baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3003", + emailAndPassword: { + enabled: true, + requireEmailVerification: false, // Set to true in production + async sendResetPassword(data) { + // Send an email to the user with a link to reset their password + console.log("Password reset requested for:", data.user.email); + }, + }, + socialProviders: { + ...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET && { + github: { + clientId: process.env.GITHUB_CLIENT_ID, + clientSecret: process.env.GITHUB_CLIENT_SECRET, + }, + }), + }, + session: { + expiresIn: 60 * 60 * 24 * 7, // 7 days (reasonable for user experience) + updateAge: 60 * 60 * 1, // 1 hour (refresh more frequently) + cookieCache: { + maxAge: 60 * 60 * 24 * 7, // 7 days for cookie cache + }, + }, + advanced: { + database: { + generateId: () => { + // Generate user-friendly IDs similar to current system + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 8); + return `user-${timestamp}-${random}`; + }, + }, + }, + plugins: [ + nextCookies(), // Enable Next.js cookie handling + anonymous({ + onLinkAccount: async ({ anonymousUser, newUser }) => { + try { + // Log when anonymous user links to a real account + console.log("Anonymous user linked to account:", { + anonymousUserId: anonymousUser.user.id, + newUserId: newUser.user.id, + newUserEmail: newUser.user.email, + }); + + // Transfer rate limiting data (TTS char counts) from anonymous user to authenticated user + try { + await rateLimiter.transferAnonymousUsage(anonymousUser.user.id, newUser.user.id); + console.log(`Successfully transferred rate limit data from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`); + } catch (error) { + console.error("Error transferring rate limit data during account linking:", error); + // Don't throw here to prevent blocking the account linking process + } + } catch (error) { + console.error("Error in onLinkAccount callback:", error); + // Don't throw here to prevent blocking the account linking process + } + // Note: Anonymous user will be automatically deleted after this callback completes + }, + }), + ], +}); + +export const auth = isAuthEnabled() ? createAuth() : null; + +type AuthInstance = ReturnType; +export type Session = AuthInstance["$Infer"]["Session"]; +export type User = AuthInstance["$Infer"]["Session"]["user"]; \ No newline at end of file diff --git a/src/lib/server/db-adapter.ts b/src/lib/server/db-adapter.ts new file mode 100644 index 0000000..8b6d11f --- /dev/null +++ b/src/lib/server/db-adapter.ts @@ -0,0 +1,125 @@ +import { Pool, PoolClient } from "pg"; +import Database from "better-sqlite3"; +import fs from "fs"; +import path from "path"; + +/** + * Common interface for database operations to support both Postgres and SQLite + */ +export interface DBAdapter { + query(text: string, params?: unknown[]): Promise<{ rows: unknown[], rowCount?: number }>; + transaction(callback: (client: DBAdapter) => Promise): Promise; +} + +export class PostgresAdapter implements DBAdapter { + constructor(private pool: Pool) { } + + async query(text: string, params?: unknown[]) { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await this.pool.query(text, params as any[]); + return { + rows: result.rows, + rowCount: result.rowCount ?? undefined + }; + } catch (error) { + console.error("Postgres Query Error:", error); + throw error; + } + } + + async transaction(callback: (client: DBAdapter) => Promise): Promise { + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + const connectedAdapter = new PostgresConnectedAdapter(client); + const result = await callback(connectedAdapter); + await client.query("COMMIT"); + return result; + } catch (e) { + await client.query("ROLLBACK"); + throw e; + } finally { + client.release(); + } + } +} + +class PostgresConnectedAdapter implements DBAdapter { + constructor(private client: PoolClient) { } + + async query(text: string, params?: unknown[]) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await this.client.query(text, params as any[]); + return { + rows: result.rows, + rowCount: result.rowCount ?? undefined + }; + } + + async transaction(callback: (client: DBAdapter) => Promise): Promise { + // Nested transactions not explicitly supported, just pass through + return callback(this); + } +} + +export class SqliteAdapter implements DBAdapter { + private db: Database.Database; + + constructor(filePath: string) { + // Ensure directory exists + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + try { + fs.mkdirSync(dir, { recursive: true }); + console.log(`Created directory for SQLite: ${dir}`); + } catch (e) { + console.error(`Failed to create directory ${dir}:`, e); + } + } + + this.db = new Database(filePath); + // Enable WAL mode for better concurrency + this.db.pragma('journal_mode = WAL'); + } + + async query(text: string, params?: unknown[]) { + // simple heuristic to convert Postgres $n params to SQLite ? + // This assumes we aren't using $n inside string literals. + const convertedSql = text.replace(/\$\d+/g, "?"); + + try { + const stmt = this.db.prepare(convertedSql); + const safeParams = params || []; + + const lowerSql = convertedSql.trim().toLowerCase(); + + // If it's a SELECT or RETURNING, we use .all() or .get() + if (lowerSql.startsWith("select") || /returning\s+/.test(lowerSql)) { + const rows = stmt.all(...safeParams); + return { rows, rowCount: rows.length }; + } else { + // INSERT/UPDATE/DELETE with no RETURNING + const info = stmt.run(...safeParams); + return { rows: [], rowCount: info.changes }; + } + } catch (error) { + console.error("SQLite Query Error:", error); + throw error; + } + } + + async transaction(callback: (client: DBAdapter) => Promise): Promise { + // Manual transaction management + // better-sqlite3 is synchronous, but we simulate async interface + this.db.exec("BEGIN"); + try { + const result = await callback(this); + this.db.exec("COMMIT"); + return result; + } catch (e) { + this.db.exec("ROLLBACK"); + throw e; + } + } +} diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts new file mode 100644 index 0000000..d078edf --- /dev/null +++ b/src/lib/server/db.ts @@ -0,0 +1,45 @@ +import { Pool } from 'pg'; +import path from 'path'; +import { DBAdapter, PostgresAdapter, SqliteAdapter } from '@/lib/server/db-adapter'; +import { isAuthEnabled } from '@/lib/server/auth-config'; + +// Singleton instance +let dbInstance: DBAdapter | null = null; + +class NoOpAdapter implements DBAdapter { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async query(text: string, params?: unknown[]) { + return { rows: [], rowCount: 0 }; + } + + async transaction(callback: (client: DBAdapter) => Promise): Promise { + return callback(this); + } +} + +export function getDB(): DBAdapter { + if (dbInstance) return dbInstance; + + // Avoid creating database connection/file if auth is disabled + if (!isAuthEnabled()) { + dbInstance = new NoOpAdapter(); + return dbInstance; + } + + if (process.env.POSTGRES_URL) { + const pool = new Pool({ + connectionString: process.env.POSTGRES_URL, + ssl: process.env.NODE_ENV === "production" ? { rejectUnauthorized: false } : false, + }); + dbInstance = new PostgresAdapter(pool); + } else { + // Fallback to SQLite + const dbPath = path.join(process.cwd(), 'docstore', 'sqlite3.db'); + console.log(`Using SQLite database at ${dbPath}`); + dbInstance = new SqliteAdapter(dbPath); + } + + return dbInstance!; +} + +export const db = getDB(); diff --git a/src/lib/server/rate-limiter.ts b/src/lib/server/rate-limiter.ts new file mode 100644 index 0000000..4876cb8 --- /dev/null +++ b/src/lib/server/rate-limiter.ts @@ -0,0 +1,230 @@ +import { db } from '@/lib/server/db'; +import { isAuthEnabled } from '@/lib/server/auth-config'; + +// Rate limits configuration - character counts per day +export const RATE_LIMITS = { + ANONYMOUS: 250_000, // 250K characters per day for anonymous users + AUTHENTICATED: 1_000_000 // 1M characters per day for authenticated users +} as const; + +// Initialize rate limiting table +export async function initializeRateLimitTable() { + // Use transaction to ensure safe initialization + await db.transaction(async (client) => { + // Check if table exists first to avoid errors on some DBs + // Simple create table if not exists + await client.query(` + CREATE TABLE IF NOT EXISTS user_tts_chars ( + user_id VARCHAR(255) NOT NULL, + date DATE NOT NULL, + char_count BIGINT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, date) + ) + `); + + // Create index for faster queries + await client.query(` + CREATE INDEX IF NOT EXISTS idx_user_tts_chars_date ON user_tts_chars(date) + `); + }); +} + +export interface RateLimitResult { + allowed: boolean; + currentCount: number; + limit: number; + resetTime: Date; + remainingChars: number; +} + +interface DBCharCountRow { + char_count: string | number; +} + +export interface UserInfo { + id: string; + isAnonymous?: boolean; + isPro?: boolean; +} + +export class RateLimiter { + constructor() { } + + /** + * Check if a user can use TTS and increment their char count if allowed + * @param charCount - Number of characters to add + */ + async checkAndIncrementLimit(user: UserInfo, charCount: number): Promise { + // If auth is not enabled, always allow + if (!isAuthEnabled()) { + return { + allowed: true, + currentCount: 0, + limit: Infinity, + resetTime: this.getResetTime(), + remainingChars: Infinity + }; + } + + await initializeRateLimitTable(); + + return db.transaction(async (client) => { + const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format + const limit = user.isAnonymous ? RATE_LIMITS.ANONYMOUS : RATE_LIMITS.AUTHENTICATED; + + // Get or create today's record for this user + // Postgres supports RETURNING, but SQLite behavior can vary with ON CONFLICT + // We'll use a standard upsert pattern compatible with both via the adapter logic or simple queries + + // First, ensure record exists + // SQLite/Postgres compatible UPSERT + await client.query(` + INSERT INTO user_tts_chars (user_id, date, char_count) + VALUES ($1, $2, 0) + ON CONFLICT (user_id, date) + DO UPDATE SET updated_at = CURRENT_TIMESTAMP + `, [user.id, today]); + + // Get current count + const result = await client.query(` + SELECT char_count FROM user_tts_chars + WHERE user_id = $1 AND date = $2 + `, [user.id, today]); + + const currentCount = parseInt(((result.rows[0] as unknown) as DBCharCountRow)?.char_count?.toString() || '0', 10); + + // Check if adding these chars would exceed the limit + if (currentCount + charCount > limit) { + return { + allowed: false, + currentCount, + limit, + resetTime: this.getResetTime(), + remainingChars: Math.max(0, limit - currentCount) + }; + } + + // Increment the count + await client.query(` + UPDATE user_tts_chars + SET char_count = char_count + $3, updated_at = CURRENT_TIMESTAMP + WHERE user_id = $1 AND date = $2 + `, [user.id, today, charCount]); + + const newCount = currentCount + charCount; + + return { + allowed: true, + currentCount: newCount, + limit, + resetTime: this.getResetTime(), + remainingChars: Math.max(0, limit - newCount) + }; + }); + } + + /** + * Get current usage for a user without incrementing + */ + async getCurrentUsage(user: UserInfo): Promise { + // If auth is not enabled, return unlimited + if (!isAuthEnabled()) { + return { + allowed: true, + currentCount: 0, + limit: Infinity, + resetTime: this.getResetTime(), + remainingChars: Infinity + }; + } + + await initializeRateLimitTable(); + + const today = new Date().toISOString().split('T')[0]; + const limit = user.isAnonymous ? RATE_LIMITS.ANONYMOUS : RATE_LIMITS.AUTHENTICATED; + + const result = await db.query( + 'SELECT char_count FROM user_tts_chars WHERE user_id = $1 AND date = $2', + [user.id, today] + ); + + const currentCount = result.rows.length > 0 ? parseInt(((result.rows[0] as unknown) as DBCharCountRow).char_count.toString(), 10) : 0; + + return { + allowed: currentCount < limit, + currentCount, + limit, + resetTime: this.getResetTime(), + remainingChars: Math.max(0, limit - currentCount) + }; + } + + /** + * Transfer char counts when anonymous user creates an account + */ + async transferAnonymousUsage(anonymousUserId: string, authenticatedUserId: string): Promise { + if (!isAuthEnabled()) return; + + await initializeRateLimitTable(); + + await db.transaction(async (client) => { + const today = new Date().toISOString().split('T')[0]; + + // Get anonymous user's current count + const anonymousResult = await client.query( + 'SELECT char_count FROM user_tts_chars WHERE user_id = $1 AND date = $2', + [anonymousUserId, today] + ); + + if (anonymousResult.rows.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const anonymousCount = parseInt((anonymousResult.rows[0] as any).char_count, 10); + + // Update or create record for authenticated user + await client.query(` + INSERT INTO user_tts_chars (user_id, date, char_count) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, date) + DO UPDATE SET + char_count = CASE + WHEN user_tts_chars.char_count > $3 THEN user_tts_chars.char_count + ELSE $3 + END, + updated_at = CURRENT_TIMESTAMP + `, [authenticatedUserId, today, anonymousCount]); + + // Remove anonymous user's record + await client.query( + 'DELETE FROM user_tts_chars WHERE user_id = $1 AND date = $2', + [anonymousUserId, today] + ); + } + }); + } + + /** + * Clean up old records (optional maintenance) + */ + async cleanupOldRecords(daysToKeep: number = 30): Promise { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysToKeep); + const cutoffDateStr = cutoffDate.toISOString().split('T')[0]; + + await db.query( + 'DELETE FROM user_tts_chars WHERE date < $1', + [cutoffDateStr] + ); + } + + private getResetTime(): Date { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); // Start of next day + return tomorrow; + } +} + +// Export singleton instance +export const rateLimiter = new RateLimiter(); \ No newline at end of file diff --git a/src/lib/session-utils.ts b/src/lib/session-utils.ts new file mode 100644 index 0000000..4536fae --- /dev/null +++ b/src/lib/session-utils.ts @@ -0,0 +1,26 @@ +// Session utilities using Dexie for persistence +// Used by auth components for sign-out state tracking + +import { updateAppConfig, getAppConfig } from '@/lib/dexie'; + +/** + * Check if user was explicitly signed out (for showing appropriate message on signin page) + */ +export async function wasSignedOut(): Promise { + const config = await getAppConfig(); + return config?.signedOut ?? false; +} + +/** + * Clear the signed-out flag after displaying the message + */ +export async function clearSignedOut(): Promise { + await updateAppConfig({ signedOut: false }); +} + +/** + * Mark that the user explicitly signed out + */ +export async function markSignedOut(): Promise { + await updateAppConfig({ signedOut: true }); +} diff --git a/src/types/config.ts b/src/types/config.ts index 92b4af3..6a39db3 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -30,6 +30,8 @@ export interface AppConfigValues { epubWordHighlightEnabled: boolean; firstVisit: boolean; documentListState: DocumentListState; + signedOut: boolean; + privacyAccepted: boolean; } export const APP_CONFIG_DEFAULTS: AppConfigValues = { @@ -63,6 +65,8 @@ export const APP_CONFIG_DEFAULTS: AppConfigValues = { showHint: true, viewMode: 'grid', }, + signedOut: false, + privacyAccepted: false, }; export interface AppConfigRow extends AppConfigValues { diff --git a/tests/helpers.ts b/tests/helpers.ts index f200a7a..ab1e8bf 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -45,7 +45,7 @@ export async function uploadAndDisplay(page: Page, fileName: string) { await uploadFile(page, fileName); const lower = fileName.toLowerCase(); - + if (lower.endsWith('.docx')) { await expect(page.getByText('Converting DOCX to PDF...')).toBeVisible(); const pdfName = fileName.replace(/\.docx$/i, '.pdf'); @@ -94,7 +94,7 @@ export async function playTTSAndWaitForASecond(page: Page, fileName: string) { export async function pauseTTSAndVerify(page: Page) { // Click pause to stop playback await page.getByRole('button', { name: 'Pause' }).click(); - + // Check for play button to be visible await expect(page.getByRole('button', { name: 'Play' })).toBeVisible({ timeout: 10000 }); } @@ -118,6 +118,8 @@ export async function setupTest(page: Page) { // Click the "done" button to dismiss the welcome message await page.getByRole('button', { name: 'Save' }).click(); + + await page.getByRole('button', { name: 'I Understand' }).click(); } @@ -303,8 +305,8 @@ export async function expectMediaState(page: Page, state: 'playing' | 'paused') // Consider playing if not paused AND time has advanced at least a tiny amount if (!audio.paused && curr > 0 && curr > last) return true; } else { - // paused target - if (audio.paused) return true; + // paused target + if (audio.paused) return true; } } return false; From 4689f3676ffa67229932b853a7228888374558e6 Mon Sep 17 00:00:00 2001 From: Richard R Date: Sun, 25 Jan 2026 11:12:19 -0700 Subject: [PATCH 02/55] refactor(auth): consolidate authentication contexts and enhance rate limit UI integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streamline auth client factory and remove redundant session manager Introduce unified auto rate limit context for seamless status updates Implement problem details in TTS responses for improved error clarity Add rate limit pause controls and banners across document viewers Refine PDF scaling logic with height-aware resize handling Update privacy popup triggers and first-visit modal behavior BREAKING CHANGE: Daily character limits adjusted (anonymous: 250K → 50K, authenticated: 1M → 500K) --- src/app/api/rate-limit/status/route.ts | 30 +++--- src/app/api/tts/route.ts | 101 ++++++++++++++--- src/app/epub/[id]/page.tsx | 18 +++- src/app/html/[id]/page.tsx | 19 +++- src/app/page.tsx | 46 ++++---- src/app/pdf/[id]/page.tsx | 20 +++- src/app/providers.tsx | 50 ++++----- src/app/signin/page.tsx | 7 +- src/app/signup/page.tsx | 4 +- src/components/PDFViewer.tsx | 10 +- src/components/SettingsModal.tsx | 58 +++++++--- src/components/auth/AuthLoader.tsx | 14 +-- src/components/auth/SessionManager.tsx | 48 --------- src/components/auth/UserMenu.tsx | 11 +- .../player/RateLimitPauseButton.tsx | 25 +++++ src/components/privacy-popup.tsx | 5 +- src/components/rate-limit-banner.tsx | 37 +++---- src/contexts/AuthConfigContext.tsx | 33 ------ .../AutoRateLimitContext.tsx} | 78 +++++++++----- src/contexts/TTSContext.tsx | 90 ++++++++++++++-- src/hooks/pdf/usePDFResize.ts | 15 ++- src/hooks/useAuth.ts | 2 +- src/lib/auth-client.ts | 32 +++--- src/lib/client.ts | 55 +++++++++- src/lib/pdf.ts | 5 + src/lib/server/db-adapter.ts | 26 ++++- src/lib/server/rate-limiter.ts | 102 ++++++++++-------- src/types/client.ts | 8 ++ tests/helpers.ts | 32 +++++- 29 files changed, 641 insertions(+), 340 deletions(-) delete mode 100644 src/components/auth/SessionManager.tsx create mode 100644 src/components/player/RateLimitPauseButton.tsx delete mode 100644 src/contexts/AuthConfigContext.tsx rename src/{components/rate-limit-provider.tsx => contexts/AutoRateLimitContext.tsx} (65%) diff --git a/src/app/api/rate-limit/status/route.ts b/src/app/api/rate-limit/status/route.ts index 12e1a07..ce6307d 100644 --- a/src/app/api/rate-limit/status/route.ts +++ b/src/app/api/rate-limit/status/route.ts @@ -6,20 +6,27 @@ import { isAuthEnabled } from '@/lib/server/auth-config'; export const dynamic = 'force-dynamic'; +function getUtcResetTimeIso(): string { + const now = new Date(); + const tomorrow = new Date(now); + tomorrow.setUTCDate(now.getUTCDate() + 1); + tomorrow.setUTCHours(0, 0, 0, 0); + return tomorrow.toISOString(); +} + export async function GET() { try { // If auth is not enabled, return unlimited status if (!isAuthEnabled() || !auth) { - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); - tomorrow.setHours(0, 0, 0, 0); - + const resetTime = getUtcResetTimeIso(); return NextResponse.json({ allowed: true, currentCount: 0, - limit: Infinity, - remainingChars: Infinity, - resetTime: tomorrow.toISOString(), + // Avoid Infinity in JSON (serializes to null). This value is never shown + // because authEnabled=false, but we keep it finite to prevent surprises. + limit: Number.MAX_SAFE_INTEGER, + remainingChars: Number.MAX_SAFE_INTEGER, + resetTime, userType: 'unauthenticated', authEnabled: false }); @@ -32,22 +39,19 @@ export async function GET() { // No session means unauthenticated if (!session?.user) { - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); - tomorrow.setHours(0, 0, 0, 0); - + const resetTime = getUtcResetTimeIso(); return NextResponse.json({ allowed: true, currentCount: 0, limit: RATE_LIMITS.ANONYMOUS, remainingChars: RATE_LIMITS.ANONYMOUS, - resetTime: tomorrow.toISOString(), + resetTime, userType: 'unauthenticated', authEnabled: true }); } - const isAnonymous = !session.user.email || session.user.email === ''; + const isAnonymous = Boolean(session.user.isAnonymous); const result = await rateLimiter.getCurrentUsage({ id: session.user.id, diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index 33a0bdd..de750fd 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -35,6 +35,21 @@ type InflightEntry = { const inflightRequests = new Map(); +const PROBLEM_TYPES = { + dailyQuotaExceeded: 'https://openreader.app/problems/daily-quota-exceeded', + upstreamRateLimited: 'https://openreader.app/problems/upstream-rate-limited', +} as const; + +type ProblemDetails = { + type: string; + title: string; + status: number; + detail?: string; + instance?: string; + code?: string; + [key: string]: unknown; +}; + function sleep(ms: number) { return new Promise((res) => setTimeout(res, ms)); } @@ -100,7 +115,18 @@ function makeCacheKey(input: { return createHash('sha256').update(JSON.stringify(canonical)).digest('hex'); } +function formatLimitForHint(limit: number): string { + if (!Number.isFinite(limit) || limit <= 0) return String(limit); + if (limit >= 1_000_000) { + const m = limit / 1_000_000; + return `${m % 1 === 0 ? m.toFixed(0) : m.toFixed(1)}M`; + } + if (limit >= 1_000) return `${Math.round(limit / 1_000)}K`; + return String(limit); +} + export async function POST(req: NextRequest) { + let providerForError: string | null = null; try { // Parse body first to get text for rate limiting const body = (await req.json()) as TTSRequestPayload; @@ -127,7 +153,7 @@ export async function POST(req: NextRequest) { ); } - const isAnonymous = !session.user.email || session.user.email === ''; + const isAnonymous = Boolean(session.user.isAnonymous); const charCount = text.length; // Check rate limit @@ -137,21 +163,36 @@ export async function POST(req: NextRequest) { ); if (!rateLimitResult.allowed) { - return NextResponse.json( - { - code: 'RATE_LIMIT_EXCEEDED', - message: 'Daily character limit exceeded', - currentCount: rateLimitResult.currentCount, - limit: rateLimitResult.limit, - remainingChars: rateLimitResult.remainingChars, - resetTime: rateLimitResult.resetTime.toISOString(), - userType: isAnonymous ? 'anonymous' : 'authenticated', - upgradeHint: isAnonymous - ? `Sign up to increase your limit from ${(RATE_LIMITS.ANONYMOUS / 1000).toFixed(0)}K to ${(RATE_LIMITS.AUTHENTICATED / 1_000_000).toFixed(0)}M characters per day` - : undefined - }, - { status: 429 } + const resetTime = rateLimitResult.resetTime.toISOString(); + const retryAfterSeconds = Math.max( + 0, + Math.ceil((rateLimitResult.resetTime.getTime() - Date.now()) / 1000) ); + + const problem: ProblemDetails = { + type: PROBLEM_TYPES.dailyQuotaExceeded, + title: 'Daily quota exceeded', + status: 429, + detail: 'Daily character limit exceeded', + code: 'USER_DAILY_QUOTA_EXCEEDED', + currentCount: rateLimitResult.currentCount, + limit: rateLimitResult.limit, + remainingChars: rateLimitResult.remainingChars, + resetTime, + userType: isAnonymous ? 'anonymous' : 'authenticated', + upgradeHint: isAnonymous + ? `Sign up to increase your limit from ${formatLimitForHint(RATE_LIMITS.ANONYMOUS)} to ${formatLimitForHint(RATE_LIMITS.AUTHENTICATED)} characters per day` + : undefined, + instance: req.nextUrl.pathname, + }; + + return new NextResponse(JSON.stringify(problem), { + status: 429, + headers: { + 'Content-Type': 'application/problem+json', + 'Retry-After': String(retryAfterSeconds), + }, + }); } } @@ -159,6 +200,7 @@ export async function POST(req: NextRequest) { const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none'; const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE; const provider = req.headers.get('x-tts-provider') || 'openai'; + providerForError = provider; console.log('Received TTS request:', { provider, req_model, voice, speed, format, hasInstructions: Boolean(instructions) }); // Use default Kokoro model for Deepinfra if none specified, then fall back to a safe default const rawModel = provider === 'deepinfra' && !req_model ? 'hexgrad/Kokoro-82M' : req_model; @@ -315,6 +357,35 @@ export async function POST(req: NextRequest) { return new NextResponse(null, { status: 499 }); // Use 499 status for client closed request } + const upstreamStatus = (() => { + if (typeof error === 'object' && error !== null) { + const rec = error as Record; + if (typeof rec.status === 'number') return rec.status as number; + if (typeof rec.statusCode === 'number') return rec.statusCode as number; + } + return undefined; + })(); + + if (upstreamStatus === 429) { + const problem: ProblemDetails = { + type: PROBLEM_TYPES.upstreamRateLimited, + title: 'Upstream rate limited', + status: 429, + detail: 'The TTS provider is rate limiting requests. Please try again shortly.', + code: 'UPSTREAM_RATE_LIMIT', + provider: providerForError ?? undefined, + upstreamStatus, + instance: req.nextUrl.pathname, + }; + + return new NextResponse(JSON.stringify(problem), { + status: 429, + headers: { + 'Content-Type': 'application/problem+json', + }, + }); + } + console.warn('Error generating TTS:', error); const errorBody: TTSError = { code: 'TTS_GENERATION_FAILED', diff --git a/src/app/epub/[id]/page.tsx b/src/app/epub/[id]/page.tsx index 8cccb88..b36dd22 100644 --- a/src/app/epub/[id]/page.tsx +++ b/src/app/epub/[id]/page.tsx @@ -11,12 +11,15 @@ import { SettingsIcon } from '@/components/icons/Icons'; import { Header } from '@/components/Header'; import { useTTS } from "@/contexts/TTSContext"; import TTSPlayer from '@/components/player/TTSPlayer'; +import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton'; import { ZoomControl } from '@/components/ZoomControl'; import { AudiobookExportModal } from '@/components/AudiobookExportModal'; import { DownloadIcon } from '@/components/icons/Icons'; import type { TTSAudiobookChapter } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; import { resolveDocumentId } from '@/lib/dexie'; +import { RateLimitBanner } from '@/components/rate-limit-banner'; +import { useAutoRateLimit } from '@/contexts/AutoRateLimitContext'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; @@ -25,6 +28,7 @@ export default function EPUBPage() { const router = useRouter(); const { setCurrentDocument, currDocName, clearCurrDoc, createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB(); const { stop } = useTTS(); + const { isAtLimit } = useAutoRateLimit(); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSettingsOpen, setIsSettingsOpen] = useState(false); @@ -178,12 +182,13 @@ export default function EPUBPage() { } />
+ {isLoading ? (
) : ( -
+
)} @@ -198,7 +203,16 @@ export default function EPUBPage() { onRegenerateChapter={handleRegenerateChapter} /> )} - + {isAtLimit ? ( +
+
+ + +
+
+ ) : ( + + )} ); diff --git a/src/app/html/[id]/page.tsx b/src/app/html/[id]/page.tsx index a350b0f..6c98201 100644 --- a/src/app/html/[id]/page.tsx +++ b/src/app/html/[id]/page.tsx @@ -7,18 +7,22 @@ import { useHTML } from '@/contexts/HTMLContext'; import { DocumentSkeleton } from '@/components/DocumentSkeleton'; import { HTMLViewer } from '@/components/HTMLViewer'; import { DocumentSettings } from '@/components/DocumentSettings'; +import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton'; import { SettingsIcon } from '@/components/icons/Icons'; import { Header } from '@/components/Header'; import { useTTS } from "@/contexts/TTSContext"; import TTSPlayer from '@/components/player/TTSPlayer'; import { ZoomControl } from '@/components/ZoomControl'; import { resolveDocumentId } from '@/lib/dexie'; +import { RateLimitBanner } from '@/components/rate-limit-banner'; +import { useAutoRateLimit } from '@/contexts/AutoRateLimitContext'; export default function HTMLPage() { const { id } = useParams(); const router = useRouter(); const { setCurrentDocument, currDocName, clearCurrDoc } = useHTML(); const { stop } = useTTS(); + const { isAtLimit } = useAutoRateLimit(); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSettingsOpen, setIsSettingsOpen] = useState(false); @@ -58,6 +62,7 @@ export default function HTMLPage() { }, [loadDocument]); // Compute available height = viewport - (header height + tts bar height) + useEffect(() => { const compute = () => { const header = document.querySelector('[data-app-header]') as HTMLElement | null; @@ -86,7 +91,7 @@ export default function HTMLPage() {

{error}

{clearCurrDoc();}} + onClick={() => { clearCurrDoc(); }} className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" > @@ -140,12 +145,20 @@ export default function HTMLPage() {
) : ( -
+
)}
- + {isAtLimit ? ( +
+
+ +
+
+ ) : ( + + )} ); diff --git a/src/app/page.tsx b/src/app/page.tsx index 9d83fd5..3d85b7f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,31 +2,35 @@ import { HomeContent } from '@/components/HomeContent'; import { SettingsModal } from '@/components/SettingsModal'; import { UserMenu } from '@/components/auth/UserMenu'; import { AuthLoader } from '@/components/auth/AuthLoader'; +import { RateLimitBanner } from '@/components/rate-limit-banner'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; export default function Home() { return ( - -
- - -
-
-

OpenReader WebUI

-

- Open source document reader {isDev ? 'self-hosted server' : 'demo app'}. - Read & listen to PDF, EPUB, MD, and TXT documents with high quality text to speech voices. -

-
-
-
-
- -
-
-
+
+ + + <> + +
+
+

OpenReader WebUI

+

+ Open source document reader {isDev ? 'self-hosted server' : 'demo app'}. + Read & listen to PDF, EPUB, MD, and TXT documents with high quality text to speech voices. +

+
+
+
+
+ + +
+ +
+
); } diff --git a/src/app/pdf/[id]/page.tsx b/src/app/pdf/[id]/page.tsx index 8af59a4..588da24 100644 --- a/src/app/pdf/[id]/page.tsx +++ b/src/app/pdf/[id]/page.tsx @@ -15,14 +15,17 @@ import { AudiobookExportModal } from '@/components/AudiobookExportModal'; import type { TTSAudiobookChapter } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; import TTSPlayer from '@/components/player/TTSPlayer'; +import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton'; import { resolveDocumentId } from '@/lib/dexie'; +import { RateLimitBanner } from '@/components/rate-limit-banner'; +import { useAutoRateLimit } from '@/contexts/AutoRateLimitContext'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; // Dynamic import for client-side rendering only const PDFViewer = dynamic( () => import('@/components/PDFViewer').then((module) => module.PDFViewer), - { + { ssr: false, loading: () => } @@ -33,6 +36,7 @@ export default function PDFViewerPage() { const router = useRouter(); const { setCurrentDocument, currDocName, clearCurrDoc, currDocPage, currDocPages, createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF(); const { stop } = useTTS(); + const { isAtLimit } = useAutoRateLimit(); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); const [zoomLevel, setZoomLevel] = useState(100); @@ -114,7 +118,7 @@ export default function PDFViewerPage() {

{error}

{clearCurrDoc();}} + onClick={() => { clearCurrDoc(); }} className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" > @@ -167,6 +171,7 @@ export default function PDFViewerPage() { } />
+ {isLoading ? (
@@ -185,7 +190,16 @@ export default function PDFViewerPage() { onRegenerateChapter={handleRegenerateChapter} /> )} - + {isAtLimit ? ( +
+
+ + +
+
+ ) : ( + + )} ); diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 5d9016d..8e14dd4 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,5 +1,3 @@ -'use client'; - import { ReactNode } from 'react'; import { DocumentProvider } from '@/contexts/DocumentContext'; @@ -9,9 +7,8 @@ import { TTSProvider } from '@/contexts/TTSContext'; import { ThemeProvider } from '@/contexts/ThemeContext'; import { ConfigProvider } from '@/contexts/ConfigContext'; import { HTMLProvider } from '@/contexts/HTMLContext'; -import { RateLimitProvider } from '@/components/rate-limit-provider'; +import { AutoRateLimitProvider } from '@/contexts/AutoRateLimitContext'; import { PrivacyPopup } from '@/components/privacy-popup'; -import { AuthConfigProvider } from '@/contexts/AuthConfigContext'; interface ProvidersProps { children: ReactNode; @@ -20,33 +17,24 @@ interface ProvidersProps { } export function Providers({ children, authEnabled, authBaseUrl }: ProvidersProps) { - const content = ( - - - - - - - - {children} - - - - - - - - - ); - - // Wrap with RateLimitProvider only when auth is enabled - const wrappedContent = authEnabled ? ( - {content} - ) : content; - return ( - - {wrappedContent} - + + + + + + + + + {children} + + + + + + + + + ); } diff --git a/src/app/signin/page.tsx b/src/app/signin/page.tsx index 4b9637d..d7cf651 100644 --- a/src/app/signin/page.tsx +++ b/src/app/signin/page.tsx @@ -5,7 +5,7 @@ import { Button, Input } from '@headlessui/react'; import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { getAuthClient } from '@/lib/auth-client'; -import { useAuthConfig } from '@/contexts/AuthConfigContext'; +import { useAuthConfig, useAutoRateLimit } from '@/contexts/AutoRateLimitContext'; import { showPrivacyPopup } from '@/components/privacy-popup'; import { wasSignedOut, clearSignedOut } from '@/lib/session-utils'; import { GithubIcon } from '@/components/icons/Icons'; @@ -32,6 +32,7 @@ function SignInContent() { const [justSignedOut, setJustSignedOut] = useState(false); const [error, setError] = useState(null); const { authEnabled, baseUrl } = useAuthConfig(); + const { refresh: refreshRateLimit } = useAutoRateLimit(); const isAnyLoading = loadingEmail || loadingGithub || loadingGuest; @@ -87,6 +88,9 @@ function SignInContent() { setError(errorMessage); } } else { + // Immediately refresh rate-limit status so the banner clears without a full reload. + // This is especially important when an anonymous user upgrades to an account. + await refreshRateLimit(); router.push('/'); } } catch (err) { @@ -116,6 +120,7 @@ function SignInContent() { try { const client = getAuthClient(baseUrl); await client.signIn.anonymous(); + await refreshRateLimit(); router.push('/'); } catch (e) { console.error('Anonymous sign-in failed:', e); diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index ea3d974..c9cf414 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -5,7 +5,7 @@ import { Button, Input } from '@headlessui/react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { getAuthClient } from '@/lib/auth-client'; -import { useAuthConfig } from '@/contexts/AuthConfigContext'; +import { useAuthConfig, useAutoRateLimit } from '@/contexts/AutoRateLimitContext'; import { showPrivacyPopup } from '@/components/privacy-popup'; import { LoadingSpinner } from '@/components/Spinner'; import toast from 'react-hot-toast'; @@ -19,6 +19,7 @@ export default function SignUpPage() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const { authEnabled, baseUrl } = useAuthConfig(); + const { refresh: refreshRateLimit } = useAutoRateLimit(); // Check if auth is enabled, redirect home if not useEffect(() => { @@ -84,6 +85,7 @@ export default function SignUpPage() { toast.success('Account created! Please sign in.'); router.push('/signin'); } else { + await refreshRateLimit(); toast.success('Account created successfully!'); router.push('/'); } diff --git a/src/components/PDFViewer.tsx b/src/components/PDFViewer.tsx index 0a20958..c591d7d 100644 --- a/src/components/PDFViewer.tsx +++ b/src/components/PDFViewer.tsx @@ -23,7 +23,7 @@ interface PDFOnLinkClickArgs { export function PDFViewer({ zoomLevel }: PDFViewerProps) { const containerRef = useRef(null); const scaleRef = useRef(1); - const { containerWidth } = usePDFResize(containerRef); + const { containerWidth, containerHeight } = usePDFResize(containerRef); const sentenceHighlightSeqRef = useRef(0); const wordHighlightSeqRef = useRef(0); const sentenceHighlightTimeoutsRef = useRef[]>([]); @@ -55,7 +55,7 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { currDocPage, } = usePDF(); - const layoutKey = `${zoomLevel}:${containerWidth}:${viewType}:${currDocPage}`; + const layoutKey = `${zoomLevel}:${containerWidth}:${containerHeight}:${viewType}:${currDocPage}`; const clearSentenceHighlightTimeouts = useCallback(() => { for (const t of sentenceHighlightTimeoutsRef.current) clearTimeout(t); @@ -234,11 +234,11 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { // Modify scale calculation to be more efficient const calculateScale = useCallback((width = pageWidth, height = pageHeight): number => { const margin = viewType === 'dual' ? 48 : 24; // adjust margin based on view type - const containerHeight = (containerRef.current?.clientHeight ?? window.innerHeight); + const effectiveContainerHeight = containerHeight || (containerRef.current?.clientHeight ?? window.innerHeight); const targetWidth = viewType === 'dual' ? (containerWidth - margin) / 2 // divide by 2 for dual pages : containerWidth - margin; - const targetHeight = containerHeight - margin; + const targetHeight = effectiveContainerHeight - margin; if (viewType === 'scroll') { // For scroll mode, use a more comfortable width-based scale @@ -252,7 +252,7 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { const baseScale = Math.min(scaleByWidth, scaleByHeight); return baseScale * (zoomLevel / 100); - }, [containerWidth, zoomLevel, pageWidth, pageHeight, viewType]); + }, [containerWidth, containerHeight, zoomLevel, pageWidth, pageHeight, viewType]); // Add memoized scale to prevent unnecessary recalculations const currentScale = useCallback(() => { diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index da8a8a9..aa9b3a7 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -23,7 +23,7 @@ import Link from 'next/link'; import { useTheme } from '@/contexts/ThemeContext'; import { useConfig } from '@/contexts/ConfigContext'; import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons'; -import { syncSelectedDocumentsToServer, loadSelectedDocumentsFromServer, importSelectedDocuments, getFirstVisit, setFirstVisit, getAllPdfDocuments, getAllEpubDocuments, getAllHtmlDocuments } from '@/lib/dexie'; +import { syncSelectedDocumentsToServer, loadSelectedDocumentsFromServer, importSelectedDocuments, getAppConfig, getFirstVisit, setFirstVisit, getAllPdfDocuments, getAllEpubDocuments, getAllHtmlDocuments } from '@/lib/dexie'; import { useDocuments } from '@/contexts/DocumentContext'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ProgressPopup } from '@/components/ProgressPopup'; @@ -35,7 +35,8 @@ import { BaseDocument } from '@/types/documents'; import { getAuthClient } from '@/lib/auth-client'; import { useAuthSession } from '@/hooks/useAuth'; import { markSignedOut, clearSignedOut } from '@/lib/session-utils'; -import { useAuthConfig } from '@/contexts/AuthConfigContext'; +import { useAuthConfig, useAutoRateLimit } from '@/contexts/AutoRateLimitContext'; +import { useRouter } from 'next/navigation'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; @@ -84,7 +85,9 @@ export function SettingsModal() { const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false); const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation(); const { authEnabled, baseUrl: authBaseUrl } = useAuthConfig(); + const { refresh: refreshRateLimit } = useAutoRateLimit(); const { data: session } = useAuthSession(); + const router = useRouter(); const ttsProviders = useMemo(() => [ { id: 'custom-openai', name: 'Custom OpenAI-Like' }, @@ -146,9 +149,12 @@ export function SettingsModal() { [selectedModelId, supportsCustom, customModelInput] ); - // set firstVisit on initial load + // Open settings on first visit (stored in Dexie app config) const checkFirstVist = useCallback(async () => { - if (!isDev) return; + const appConfig = await getAppConfig(); + if (!appConfig?.privacyAccepted) { + return; + } const firstVisit = await getFirstVisit(); if (!firstVisit) { await setFirstVisit(true); @@ -165,6 +171,16 @@ export function SettingsModal() { setLocalTTSInstructions(ttsInstructions); }, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, checkFirstVist]); + useEffect(() => { + const onPrivacyAccepted = () => { + checkFirstVist(); + }; + window.addEventListener('openreader:privacyAccepted', onPrivacyAccepted); + return () => { + window.removeEventListener('openreader:privacyAccepted', onPrivacyAccepted); + }; + }, [checkFirstVist]); + // Detect if current model is custom (not in presets) and mirror it in the input field useEffect(() => { if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') { @@ -317,10 +333,14 @@ export function SettingsModal() { const handleSignOut = async () => { - await markSignedOut(); const client = getAuthClient(authBaseUrl); + // Sign out of the authenticated account, then immediately start a fresh anonymous session. + // This avoids landing in a "no session" state that can be misinterpreted as a "Guest" user. await client.signOut(); - window.location.reload(); + await clearSignedOut(); + await client.signIn.anonymous(); + await refreshRateLimit(); + router.refresh(); }; const handleDeleteAccount = async () => { @@ -787,16 +807,28 @@ export function SettingsModal() {

Current Session

Logged in as:

-

{session?.user?.name || 'Guest'}

-

{session?.user?.email}

- {session?.user?.isAnonymous && ( -

Anonymous / Guest Account

+ {session?.user ? ( + <> +

+ {session.user.isAnonymous + ? 'Anonymous' + : (session.user.name || session.user.email || 'Account')} +

+ {!session.user.isAnonymous && ( +

{session.user.email}

+ )} + {session.user.isAnonymous && ( +

Anonymous session

+ )} + + ) : ( +

Not signed in

)}
- {!session?.user?.isAnonymous ? ( + {session?.user && !session.user.isAnonymous ? ( <>
) : (

- You are using a temporary guest account. Sign up to save your progress permanently. + You are using an anonymous session. Sign up to save your progress permanently.

diff --git a/src/components/auth/AuthLoader.tsx b/src/components/auth/AuthLoader.tsx index 80877db..7685f35 100644 --- a/src/components/auth/AuthLoader.tsx +++ b/src/components/auth/AuthLoader.tsx @@ -1,14 +1,14 @@ 'use client'; import { useEffect, useState, ReactNode } from 'react'; -import { useAuthConfig } from '@/contexts/AuthConfigContext'; +import { useAuthConfig, useAutoRateLimit } from '@/contexts/AutoRateLimitContext'; import { useAuthSession } from '@/hooks/useAuth'; import { getAuthClient } from '@/lib/auth-client'; -import { wasSignedOut } from '@/lib/session-utils'; import { LoadingSpinner } from '@/components/Spinner'; export function AuthLoader({ children }: { children: ReactNode }) { const { authEnabled, baseUrl } = useAuthConfig(); + const { refresh: refreshRateLimit } = useAutoRateLimit(); const { data: session, isPending } = useAuthSession(); const [isAutoLoggingIn, setIsAutoLoggingIn] = useState(false); const [isCheckingSignOut, setIsCheckingSignOut] = useState(true); @@ -31,13 +31,6 @@ export function AuthLoader({ children }: { children: ReactNode }) { return; } - // No session, check if explicitly signed out - const userWasSignedOut = await wasSignedOut(); - if (userWasSignedOut) { - setIsCheckingSignOut(false); // Render children unauthenticated - return; - } - // Not signed out, start auto-login setIsAutoLoggingIn(true); setIsCheckingSignOut(false); // Stop checking sign-out, now in auto-login mode @@ -45,6 +38,7 @@ export function AuthLoader({ children }: { children: ReactNode }) { try { const client = getAuthClient(baseUrl); await client.signIn.anonymous(); + await refreshRateLimit(); } catch (err) { console.error('Auto-login failed', err); } finally { @@ -53,7 +47,7 @@ export function AuthLoader({ children }: { children: ReactNode }) { }; checkStatus(); - }, [session, isPending, authEnabled, baseUrl]); + }, [session, isPending, authEnabled, baseUrl, refreshRateLimit]); // Show loader if: // 1. Auth client is initializing (isPending) AND auth is enabled diff --git a/src/components/auth/SessionManager.tsx b/src/components/auth/SessionManager.tsx deleted file mode 100644 index 1d70d6d..0000000 --- a/src/components/auth/SessionManager.tsx +++ /dev/null @@ -1,48 +0,0 @@ -'use client'; - -import { useEffect, useRef } from 'react'; -import { useAuthConfig } from '@/contexts/AuthConfigContext'; -import { useAuthSession } from '@/hooks/useAuth'; -import { getAuthClient } from '@/lib/auth-client'; -import { wasSignedOut } from '@/lib/session-utils'; - -export function SessionManager() { - const { authEnabled, baseUrl } = useAuthConfig(); - const { data: session, isPending } = useAuthSession(); - const attemptRef = useRef(false); - - useEffect(() => { - // Only run if auth is enabled - if (!authEnabled) return; - - // Wait for session check to complete - if (isPending) return; - - // If we have a session, we're good - if (session) return; - - // Prevent multiple attempts - if (attemptRef.current) return; - - const checkAndSignIn = async () => { - attemptRef.current = true; - try { - // Check if user explicitly signed out - const signedOut = await wasSignedOut(); - - // If not explicitly signed out, sign in anonymously - if (!signedOut) { - console.log('No session found, signing in anonymously...'); - const client = getAuthClient(baseUrl); - await client.signIn.anonymous(); - } - } catch (error) { - console.error('Error in session manager:', error); - } - }; - - checkAndSignIn(); - }, [session, isPending, authEnabled, baseUrl]); - - return null; -} diff --git a/src/components/auth/UserMenu.tsx b/src/components/auth/UserMenu.tsx index cfeec5d..6c0c6b0 100644 --- a/src/components/auth/UserMenu.tsx +++ b/src/components/auth/UserMenu.tsx @@ -2,23 +2,26 @@ import { Button } from '@headlessui/react'; import Link from 'next/link'; -import { useAuthConfig } from '@/contexts/AuthConfigContext'; +import { useAuthConfig, useAutoRateLimit } from '@/contexts/AutoRateLimitContext'; import { useAuthSession } from '@/hooks/useAuth'; import { getAuthClient } from '@/lib/auth-client'; -import { markSignedOut } from '@/lib/session-utils'; +import { clearSignedOut } from '@/lib/session-utils'; import { useRouter } from 'next/navigation'; export function UserMenu() { const { authEnabled, baseUrl } = useAuthConfig(); + const { refresh: refreshRateLimit } = useAutoRateLimit(); const { data: session, isPending } = useAuthSession(); const router = useRouter(); if (!authEnabled || isPending) return null; const handleSignOut = async () => { - await markSignedOut(); const client = getAuthClient(baseUrl); await client.signOut(); + await clearSignedOut(); + await client.signIn.anonymous(); + await refreshRateLimit(); router.refresh(); }; @@ -43,7 +46,7 @@ export function UserMenu() {
- {session.user.name || 'Guest'} + {session.user.name || session.user.email || 'Account'} {session.user.email} diff --git a/src/components/player/RateLimitPauseButton.tsx b/src/components/player/RateLimitPauseButton.tsx new file mode 100644 index 0000000..3194ace --- /dev/null +++ b/src/components/player/RateLimitPauseButton.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { Button } from '@headlessui/react'; +import { PauseIcon } from '@/components/icons/Icons'; +import { useTTS } from '@/contexts/TTSContext'; + +export function RateLimitPauseButton() { + const { isPlaying, togglePlay } = useTTS(); + + // Only show while audio is actively playing. This avoids presenting a "play" affordance + // when the user is rate-limited. + if (!isPlaying) return null; + + return ( + + ); +} diff --git a/src/components/privacy-popup.tsx b/src/components/privacy-popup.tsx index 9775544..24e57cd 100644 --- a/src/components/privacy-popup.tsx +++ b/src/components/privacy-popup.tsx @@ -32,12 +32,15 @@ export function PrivacyPopup({ onAccept }: PrivacyPopupProps) { const handleAccept = async () => { await updateAppConfig({ privacyAccepted: true }); setIsOpen(false); + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event('openreader:privacyAccepted')); + } onAccept?.(); }; return ( - { }}> + { }}> -
-
-

- Daily TTS limit reached -

-

- {`You've used ${formatCharCount(status.currentCount)} of ${formatCharCount(status.limit)} characters today.`} +

+
+
+ + Daily TTS limit reached. + + + {`Used ${formatCharCount(status.currentCount)} / ${formatCharCount(status.limit)} characters.`} {' Resets in '}{timeUntilReset}. -

+
{isAnonymous && ( - Sign up for 4x more + Sign up for a higher limit )}
@@ -51,17 +49,16 @@ export function RateLimitBanner({ className = '' }: RateLimitBannerProps) { * Compact version for inline display */ export function RateLimitIndicator({ className = '' }: RateLimitBannerProps) { - const { status, isAtLimit } = useRateLimit(); - const { authEnabled } = useAuthConfig(); + const { status, isAtLimit, authEnabled } = useAutoRateLimit(); // Don't show if auth is not enabled if (!authEnabled || !status?.authEnabled) { return null; } - const percentage = status.limit === Infinity - ? 0 - : Math.min(100, (status.currentCount / status.limit) * 100); + const percentage = status.limit > 0 + ? Math.min(100, (status.currentCount / status.limit) * 100) + : 0; const isWarning = percentage >= 80; diff --git a/src/contexts/AuthConfigContext.tsx b/src/contexts/AuthConfigContext.tsx deleted file mode 100644 index f95dd22..0000000 --- a/src/contexts/AuthConfigContext.tsx +++ /dev/null @@ -1,33 +0,0 @@ -'use client'; - -import { createContext, useContext, ReactNode } from 'react'; - -interface AuthConfig { - authEnabled: boolean; - baseUrl: string | null; -} - -const AuthConfigContext = createContext({ - authEnabled: false, - baseUrl: null, -}); - -export function AuthConfigProvider({ - children, - authEnabled, - baseUrl, -}: { - children: ReactNode; - authEnabled: boolean; - baseUrl: string | null; -}) { - return ( - - {children} - - ); -} - -export function useAuthConfig() { - return useContext(AuthConfigContext); -} diff --git a/src/components/rate-limit-provider.tsx b/src/contexts/AutoRateLimitContext.tsx similarity index 65% rename from src/components/rate-limit-provider.tsx rename to src/contexts/AutoRateLimitContext.tsx index 6eb4581..1c871d2 100644 --- a/src/components/rate-limit-provider.tsx +++ b/src/contexts/AutoRateLimitContext.tsx @@ -1,7 +1,6 @@ 'use client'; -import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'; -import { useAuthConfig } from '@/contexts/AuthConfigContext'; +import React, { createContext, useContext, useState, useEffect, useCallback, useRef, ReactNode } from 'react'; export interface RateLimitStatus { allowed: boolean; @@ -13,7 +12,12 @@ export interface RateLimitStatus { authEnabled: boolean; } -export interface RateLimitContextType { +interface AutoRateLimitContextType { + // Auth Config + authEnabled: boolean; + authBaseUrl: string | null; + + // Rate Limit status: RateLimitStatus | null; loading: boolean; error: string | null; @@ -23,20 +27,27 @@ export interface RateLimitContextType { incrementCount: (charCount: number) => void; onTTSStart: () => void; onTTSComplete: () => void; + triggerRateLimit: () => void; } -const RateLimitContext = createContext(null); +const AutoRateLimitContext = createContext(null); -export function useRateLimit(): RateLimitContextType { - const context = useContext(RateLimitContext); +export function useAutoRateLimit(): AutoRateLimitContextType { + const context = useContext(AutoRateLimitContext); if (!context) { - throw new Error('useRateLimit must be used within a RateLimitProvider'); + throw new Error('useAutoRateLimit must be used within a AutoRateLimitProvider'); } return context; } -interface RateLimitProviderProps { - children: React.ReactNode; +// Re-export specific hooks for backward compatibility or convenience if needed +export function useAuthConfig() { + const { authEnabled, authBaseUrl } = useAutoRateLimit(); + return { authEnabled, baseUrl: authBaseUrl }; +} + +export function useRateLimit() { + return useAutoRateLimit(); } function calculateTimeUntilReset(resetTime: Date): string { @@ -57,22 +68,30 @@ function calculateTimeUntilReset(resetTime: Date): string { } } -function formatCharCount(count: number): string { +export function formatCharCount(count: number): string { if (count >= 1_000_000) { - return `${(count / 1_000_000).toFixed(1)}M`; + const m = count / 1_000_000; + // Show up to 1 decimal place, stripping trailing zeros (1.0 -> 1) + return `${parseFloat(m.toFixed(1))}M`; } else if (count >= 1_000) { - return `${(count / 1_000).toFixed(0)}K`; + const k = Math.round(count / 1_000); + // Handle edge case where rounding up reaches 1M (e.g., 999,999 -> 1000K -> 1M) + if (k >= 1_000) return '1M'; + return `${k}K`; } return count.toString(); } -export { formatCharCount }; +interface AutoRateLimitProviderProps { + children: ReactNode; + authEnabled: boolean; + authBaseUrl: string | null; +} -export function RateLimitProvider({ children }: RateLimitProviderProps) { +export function AutoRateLimitProvider({ children, authEnabled, authBaseUrl }: AutoRateLimitProviderProps) { const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const { authEnabled } = useAuthConfig(); // Track pending TTS operations to delay count updates const pendingTTSRef = useRef(0); @@ -81,15 +100,17 @@ export function RateLimitProvider({ children }: RateLimitProviderProps) { const fetchStatus = useCallback(async () => { // Skip if auth is not enabled if (!authEnabled) { - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); - tomorrow.setHours(0, 0, 0, 0); + const now = new Date(); + const tomorrow = new Date(now); + tomorrow.setUTCDate(now.getUTCDate() + 1); + tomorrow.setUTCHours(0, 0, 0, 0); setStatus({ allowed: true, currentCount: 0, - limit: Infinity, - remainingChars: Infinity, + // Avoid Infinity to prevent JSON/serialization edge cases elsewhere. + limit: Number.MAX_SAFE_INTEGER, + remainingChars: Number.MAX_SAFE_INTEGER, resetTime: tomorrow, userType: 'unauthenticated', authEnabled: false @@ -128,7 +149,9 @@ export function RateLimitProvider({ children }: RateLimitProviderProps) { // Calculate time until reset const timeUntilReset = status ? calculateTimeUntilReset(status.resetTime) : ''; - const isAtLimit = status ? status.remainingChars <= 0 : false; + // Only treat the user as "at limit" when they are truly out of characters. + // The server allows the final request that may cross the limit, then blocks subsequent ones. + const isAtLimit = status ? (status.remainingChars <= 0 || !status.allowed) : false; // Increment count locally (for immediate UI feedback) const incrementCount = useCallback((charCount: number) => { @@ -186,7 +209,9 @@ export function RateLimitProvider({ children }: RateLimitProviderProps) { }; }, []); - const contextValue: RateLimitContextType = { + const contextValue: AutoRateLimitContextType = { + authEnabled, + authBaseUrl, status, loading, error, @@ -195,12 +220,13 @@ export function RateLimitProvider({ children }: RateLimitProviderProps) { timeUntilReset, incrementCount, onTTSStart, - onTTSComplete + onTTSComplete, + triggerRateLimit: () => setStatus(prev => prev ? { ...prev, remainingChars: 0, allowed: false } : null) }; return ( - + {children} - + ); -} \ No newline at end of file +} diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 1644c77..719ee57 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -39,6 +39,7 @@ import { useBackgroundState } from '@/hooks/audio/useBackgroundState'; import { withRetry, generateTTS, alignAudio } from '@/lib/client'; import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksEPUB } from '@/lib/nlp'; import { isKokoroModel } from '@/utils/voice'; +import { useAutoRateLimit } from '@/contexts/AutoRateLimitContext'; import type { TTSLocation, TTSSmartMergeResult, @@ -298,6 +299,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const audioContext = useAudioContext(); const audioCache = useAudioCache(25); const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel); + const { onTTSStart, onTTSComplete, refresh: refreshRateLimit, triggerRateLimit } = useAutoRateLimit(); // Add ref for location change handler const locationChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null); @@ -849,12 +851,18 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement backoffFactor: 2 }; - const arrayBuffer = await withRetry( - async () => { - return await generateTTS(reqBody, reqHeaders, controller.signal); - }, - retryOptions - ); + onTTSStart(); + let arrayBuffer: TTSAudioBuffer; + try { + arrayBuffer = await withRetry( + async () => { + return await generateTTS(reqBody, reqHeaders, controller.signal); + }, + retryOptions + ); + } finally { + onTTSComplete(); + } // Remove the controller once the request is complete activeAbortControllers.current.delete(controller); @@ -867,6 +875,22 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement return arrayBuffer; } catch (error) { + const status = (() => { + if (typeof error === 'object' && error !== null && 'status' in error) { + const maybe = (error as { status?: unknown }).status; + return typeof maybe === 'number' ? maybe : undefined; + } + return undefined; + })(); + + const code = (() => { + if (typeof error === 'object' && error !== null && 'code' in error) { + const maybe = (error as { code?: unknown }).code; + return typeof maybe === 'string' ? maybe : undefined; + } + return undefined; + })(); + // Check if this was an abort error if (error instanceof Error && error.name === 'AbortError') { console.log('TTS request aborted:', sentence.substring(0, 20)); @@ -874,6 +898,23 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } setIsPlaying(false); + + // Handle daily quota exceeded (429 + Problem Details code) + if (status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED') { + toast.error('Daily TTS limit reached.', { + id: 'tts-limit-error', + style: { + background: 'var(--background)', + color: 'var(--accent)', + }, + duration: 5000, + }); + triggerRateLimit(); + refreshRateLimit().catch(console.error); + // Do NOT re-throw, just return undefined to stop playback gracefully + return undefined; + } + toast.error('Failed to generate audio. Server not responding.', { id: 'tts-api-error', style: { @@ -897,7 +938,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement pdfHighlightEnabled, pdfWordHighlightEnabled, epubHighlightEnabled, - epubWordHighlightEnabled + epubWordHighlightEnabled, + triggerRateLimit, + refreshRateLimit, + onTTSComplete, + onTTSStart ]); /** @@ -929,7 +974,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const processPromise = (async () => { try { const audioBuffer = await getAudio(sentence); - if (!audioBuffer) throw new Error('No audio data generated'); + if (!audioBuffer) { + // If quota or other handled error returns undefined, ensure we don't throw "No audio data" + // Just return empty string to signal graceful failure/skip + return ''; + } // Convert to base64 data URI const bytes = new Uint8Array(audioBuffer); @@ -974,7 +1023,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement // Get the processed audio data URI directly from processSentence const audioDataUri = await processSentence(sentence); if (!audioDataUri) { - throw new Error('No audio data generated'); + // Graceful exit for rate limit or skipped sentence + console.log('Skipping playback for sentence (no audio generated)'); + return null; } // Force unload any previous Howl instance to free up resources @@ -1201,9 +1252,26 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement ); if (!audioCache.has(nextKey) && !preloadRequests.current.has(nextSentence)) { - // Start preloading but don't wait for it to complete + // Start preloading but don't wait for it to complete processSentence(nextSentence, true).catch(error => { - console.error('Error preloading next sentence:', error); + const status = (() => { + if (typeof error === 'object' && error !== null && 'status' in error) { + const maybe = (error as { status?: unknown }).status; + return typeof maybe === 'number' ? maybe : undefined; + } + return undefined; + })(); + const code = (() => { + if (typeof error === 'object' && error !== null && 'code' in error) { + const maybe = (error as { code?: unknown }).code; + return typeof maybe === 'string' ? maybe : undefined; + } + return undefined; + })(); + // Ignore quota errors during preload + if (!(status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED')) { + console.error('Error preloading next sentence:', error); + } }); } } diff --git a/src/hooks/pdf/usePDFResize.ts b/src/hooks/pdf/usePDFResize.ts index d495d4c..8bfa9ad 100644 --- a/src/hooks/pdf/usePDFResize.ts +++ b/src/hooks/pdf/usePDFResize.ts @@ -3,6 +3,7 @@ import { debounce } from '@/lib/pdf'; interface UsePDFResizeResult { containerWidth: number; + containerHeight: number; setContainerWidth: (width: number) => void; } @@ -10,6 +11,7 @@ export function usePDFResize( containerRef: RefObject ): UsePDFResizeResult { const [containerWidth, setContainerWidth] = useState(0); + const [containerHeight, setContainerHeight] = useState(0); useEffect(() => { if (!containerRef.current) return; @@ -18,11 +20,16 @@ export function usePDFResize( setContainerWidth(Number(width)); }, 150); + const debouncedResizeHeight = debounce((height: unknown) => { + setContainerHeight(Number(height)); + }, 150); + const observer = new ResizeObserver(entries => { const width = entries[0]?.contentRect.width; - if (width) { - debouncedResize(width); - } + const height = entries[0]?.contentRect.height; + + if (width) debouncedResize(width); + if (height) debouncedResizeHeight(height); }); observer.observe(containerRef.current); @@ -31,5 +38,5 @@ export function usePDFResize( }; }, [containerRef]); - return { containerWidth, setContainerWidth }; + return { containerWidth, containerHeight, setContainerWidth }; } \ No newline at end of file diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index 2f18287..3eae80e 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -1,7 +1,7 @@ 'use client'; import { useMemo } from 'react'; -import { useAuthConfig } from '@/contexts/AuthConfigContext'; +import { useAuthConfig } from '@/contexts/AutoRateLimitContext'; import { getAuthClient } from '@/lib/auth-client'; /** diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index cabc47a..5fc62ed 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -12,24 +12,22 @@ function createAuthClientWithUrl(baseUrl: string) { // Cache for auth client instances by baseUrl const clientCache = new Map>(); +/** + * Factory function to get auth client with specific baseUrl. + * In most cases, you should use the useAuth() hook instead of calling this directly. + * @param baseUrl - The auth server base URL. If null, will throw an error. + */ export function getAuthClient(baseUrl: string | null) { - const effectiveUrl = baseUrl || "http://localhost:3003"; - - if (!clientCache.has(effectiveUrl)) { - clientCache.set(effectiveUrl, createAuthClientWithUrl(effectiveUrl)); + if (!baseUrl) { + throw new Error( + 'Cannot create auth client without baseUrl. ' + + 'Use the useAuth() hook in components to get the properly configured client.' + ); } - return clientCache.get(effectiveUrl)!; -} + if (!clientCache.has(baseUrl)) { + clientCache.set(baseUrl, createAuthClientWithUrl(baseUrl)); + } -// Default client for backwards compatibility (will use localhost in dev) -// Components should prefer useAuth() hook which gets baseUrl from context -export const authClient = getAuthClient(null); - -export const { - signIn, - signUp, - signOut, - useSession, - getSession -} = authClient; \ No newline at end of file + return clientCache.get(baseUrl)!; +} \ No newline at end of file diff --git a/src/lib/client.ts b/src/lib/client.ts index 4481f36..032cdc1 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -2,6 +2,7 @@ import type { TTSRequestPayload, TTSRequestHeaders, TTSRetryOptions, + TTSRequestError, AudiobookStatusResponse, CreateChapterPayload, VoicesResponse, @@ -28,7 +29,7 @@ export const withRetry = async ( } = options; let lastError: Error | null = null; - + for (let attempt = 0; attempt < maxRetries; attempt++) { try { return await operation(); @@ -41,6 +42,26 @@ export const withRetry = async ( break; } + // Do not retry on payment required / rate limit exceeded (user quota) + const status = (() => { + if (typeof error === 'object' && error !== null && 'status' in error) { + const maybe = (error as { status?: unknown }).status; + return typeof maybe === 'number' ? maybe : undefined; + } + return undefined; + })(); + const code = (() => { + if (typeof error === 'object' && error !== null && 'code' in error) { + const maybe = (error as { code?: unknown }).code; + return typeof maybe === 'string' ? maybe : undefined; + } + return undefined; + })(); + // Do not retry on user quota exceeded (server tells us when to retry via resetTime/Retry-After) + if (status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED') { + break; + } + if (attempt === maxRetries - 1) { break; } @@ -49,7 +70,7 @@ export const withRetry = async ( initialDelay * Math.pow(backoffFactor, attempt), maxDelay ); - + console.log(`Retry attempt ${attempt + 1}/${maxRetries} failed. Retrying in ${delay}ms...`); await new Promise(resolve => setTimeout(resolve, delay)); } @@ -165,7 +186,7 @@ export const getVoices = async (headers: HeadersInit): Promise = const response = await fetch('/api/tts/voices', { headers, }); - + if (!response.ok) throw new Error('Failed to fetch voices'); return await response.json(); }; @@ -183,7 +204,33 @@ export const generateTTS = async ( }); if (!response.ok) { - throw new Error(`TTS processing failed with status ${response.status}`); + let problem: unknown = undefined; + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('application/problem+json') || contentType.includes('application/json')) { + try { + problem = await response.json(); + } catch { + // ignore JSON parse errors + } + } + + const err = new Error(`TTS processing failed with status ${response.status}`) as TTSRequestError; + err.status = response.status; + + if (typeof problem === 'object' && problem !== null) { + const rec = problem as Record; + if (typeof rec.code === 'string') err.code = rec.code; + if (typeof rec.type === 'string') err.type = rec.type; + if (typeof rec.title === 'string') err.title = rec.title; + if (typeof rec.detail === 'string') err.detail = rec.detail; + } + + // Avoid noisy logs for expected user quota failures + if (!(err.status === 429 && err.code === 'USER_DAILY_QUOTA_EXCEEDED')) { + console.error(`TTS request failed: ${response.status}`, err.code ? { code: err.code } : undefined); + } + + throw err; } const buffer = await response.arrayBuffer(); diff --git a/src/lib/pdf.ts b/src/lib/pdf.ts index 6f157cc..571e469 100644 --- a/src/lib/pdf.ts +++ b/src/lib/pdf.ts @@ -304,6 +304,8 @@ export async function extractTextFromPDF( } // Highlighting functions +let highlightPatternSeq = 0; + export function clearHighlights() { const textNodes = document.querySelectorAll('.react-pdf__Page__textContent span'); textNodes.forEach((node) => { @@ -343,6 +345,7 @@ export function highlightPattern( pattern: string, containerRef: React.RefObject ) { + const seq = ++highlightPatternSeq; clearHighlights(); if (!pattern?.trim()) return; @@ -531,6 +534,7 @@ export function highlightPattern( // Fire-and-forget async worker call; UI thread returns immediately runHighlightTokenMatch(cleanPattern, tokenTexts) .then((result) => { + if (seq !== highlightPatternSeq) return; if (!result || result.bestStart === -1) { // No worker result or no good match; nothing to highlight applyHighlightFromTokens(null); @@ -544,6 +548,7 @@ export function highlightPattern( } }) .catch((error) => { + if (seq !== highlightPatternSeq) return; console.error( 'Error in PDF highlight worker; no highlights applied:', error diff --git a/src/lib/server/db-adapter.ts b/src/lib/server/db-adapter.ts index 8b6d11f..579b66d 100644 --- a/src/lib/server/db-adapter.ts +++ b/src/lib/server/db-adapter.ts @@ -84,13 +84,31 @@ export class SqliteAdapter implements DBAdapter { } async query(text: string, params?: unknown[]) { - // simple heuristic to convert Postgres $n params to SQLite ? - // This assumes we aren't using $n inside string literals. - const convertedSql = text.replace(/\$\d+/g, "?"); + // Convert Postgres $n params to SQLite placeholders. + // We separately reorder params to match placeholder order. + const convertedSql = text.replace(/\$(\d+)/g, "?"); + + // Reorder params array to match the order they appear in the SQL + // SQLite expects params in the order they appear, not by their $n number + const orderedParams: unknown[] = []; + if (params && params.length > 0) { + // Extract parameter numbers in order of appearance + const paramNumbers: number[] = []; + const regex = /\$(\d+)/g; + let match; + while ((match = regex.exec(text)) !== null) { + paramNumbers.push(parseInt(match[1], 10)); + } + + // Map to actual values + for (const num of paramNumbers) { + orderedParams.push(params[num - 1]); // $1 maps to params[0] + } + } try { const stmt = this.db.prepare(convertedSql); - const safeParams = params || []; + const safeParams = orderedParams.length > 0 ? orderedParams : []; const lowerSql = convertedSql.trim().toLowerCase(); diff --git a/src/lib/server/rate-limiter.ts b/src/lib/server/rate-limiter.ts index 4876cb8..00acd4c 100644 --- a/src/lib/server/rate-limiter.ts +++ b/src/lib/server/rate-limiter.ts @@ -3,32 +3,44 @@ import { isAuthEnabled } from '@/lib/server/auth-config'; // Rate limits configuration - character counts per day export const RATE_LIMITS = { - ANONYMOUS: 250_000, // 250K characters per day for anonymous users - AUTHENTICATED: 1_000_000 // 1M characters per day for authenticated users + ANONYMOUS: 50_000, // 50K characters per day for anonymous users + AUTHENTICATED: 500_000 // 500K characters per day for authenticated users } as const; -// Initialize rate limiting table -export async function initializeRateLimitTable() { - // Use transaction to ensure safe initialization - await db.transaction(async (client) => { - // Check if table exists first to avoid errors on some DBs - // Simple create table if not exists - await client.query(` - CREATE TABLE IF NOT EXISTS user_tts_chars ( - user_id VARCHAR(255) NOT NULL, - date DATE NOT NULL, - char_count BIGINT DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (user_id, date) - ) - `); +// Singleton flag to ensure we only initialize the table once per process +let tableInitialized: Promise | null = null; - // Create index for faster queries - await client.query(` - CREATE INDEX IF NOT EXISTS idx_user_tts_chars_date ON user_tts_chars(date) - `); - }); +// Initialize rate limiting table (cached, runs only once per process) +export async function initializeRateLimitTable() { + if (tableInitialized) { + return tableInitialized; + } + + tableInitialized = (async () => { + // Use transaction to ensure safe initialization + await db.transaction(async (client) => { + // Check if table exists first to avoid errors on some DBs + // Simple create table if not exists + await client.query(` + CREATE TABLE IF NOT EXISTS user_tts_chars ( + user_id VARCHAR(255) NOT NULL, + date DATE NOT NULL, + char_count BIGINT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, date) + ) + `); + + // Create index for faster queries + await client.query(` + CREATE INDEX IF NOT EXISTS idx_user_tts_chars_date ON user_tts_chars(date) + `); + }); + console.log('Rate limit table initialized'); + })(); + + return tableInitialized; } export interface RateLimitResult { @@ -62,9 +74,9 @@ export class RateLimiter { return { allowed: true, currentCount: 0, - limit: Infinity, + limit: Number.MAX_SAFE_INTEGER, resetTime: this.getResetTime(), - remainingChars: Infinity + remainingChars: Number.MAX_SAFE_INTEGER }; } @@ -87,16 +99,24 @@ export class RateLimiter { DO UPDATE SET updated_at = CURRENT_TIMESTAMP `, [user.id, today]); - // Get current count + // Allow the request that crosses the limit, but block any requests once the user is + // already at/over the limit. Do this atomically to avoid concurrent over-limit requests. + const updateResult = await client.query(` + UPDATE user_tts_chars + SET char_count = char_count + $3, updated_at = CURRENT_TIMESTAMP + WHERE user_id = $1 AND date = $2 AND char_count < $4 + `, [user.id, today, charCount, limit]); + + // Get current count after the attempted update (works for both success and failure) const result = await client.query(` - SELECT char_count FROM user_tts_chars + SELECT char_count FROM user_tts_chars WHERE user_id = $1 AND date = $2 `, [user.id, today]); const currentCount = parseInt(((result.rows[0] as unknown) as DBCharCountRow)?.char_count?.toString() || '0', 10); + const updated = (updateResult.rowCount ?? 0) > 0; - // Check if adding these chars would exceed the limit - if (currentCount + charCount > limit) { + if (!updated) { return { allowed: false, currentCount, @@ -106,21 +126,12 @@ export class RateLimiter { }; } - // Increment the count - await client.query(` - UPDATE user_tts_chars - SET char_count = char_count + $3, updated_at = CURRENT_TIMESTAMP - WHERE user_id = $1 AND date = $2 - `, [user.id, today, charCount]); - - const newCount = currentCount + charCount; - return { allowed: true, - currentCount: newCount, + currentCount, limit, resetTime: this.getResetTime(), - remainingChars: Math.max(0, limit - newCount) + remainingChars: Math.max(0, limit - currentCount) }; }); } @@ -134,9 +145,9 @@ export class RateLimiter { return { allowed: true, currentCount: 0, - limit: Infinity, + limit: Number.MAX_SAFE_INTEGER, resetTime: this.getResetTime(), - remainingChars: Infinity + remainingChars: Number.MAX_SAFE_INTEGER }; } @@ -219,9 +230,10 @@ export class RateLimiter { } private getResetTime(): Date { - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); - tomorrow.setHours(0, 0, 0, 0); // Start of next day + const now = new Date(); + const tomorrow = new Date(now); + tomorrow.setUTCDate(now.getUTCDate() + 1); + tomorrow.setUTCHours(0, 0, 0, 0); // Start of next day in UTC return tomorrow; } } diff --git a/src/types/client.ts b/src/types/client.ts index 32a3b36..7c741ec 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -31,6 +31,14 @@ export interface TTSRetryOptions { backoffFactor?: number; } +export interface TTSRequestError extends Error { + status?: number; + code?: string; + type?: string; + title?: string; + detail?: string; +} + // --- Audiobook API Types --- export interface AudiobookStatusResponse { diff --git a/tests/helpers.ts b/tests/helpers.ts index ab1e8bf..a7c0039 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -34,8 +34,21 @@ function escapeRegExp(input: string) { * Upload a sample epub or pdf */ export async function uploadFile(page: Page, filePath: string) { - await page.waitForSelector('input[type=file]', { timeout: 10000 }); - await page.setInputFiles('input[type=file]', `${DIR}${filePath}`); + const input = page.locator('input[type=file]').first(); + await expect(input).toBeVisible({ timeout: 10000 }); + await expect(input).toBeEnabled({ timeout: 10000 }); + + await input.setInputFiles(`${DIR}${filePath}`); + + // Wait for the uploader to finish processing. The input is disabled while + // uploading/converting via react-dropzone's `disabled` prop. + // Tolerate extremely fast operations where the disabled state may be missed. + try { + await expect(input).toBeDisabled({ timeout: 2000 }); + } catch { + // ignore + } + await expect(input).toBeEnabled({ timeout: 15000 }); } /** @@ -110,6 +123,19 @@ export async function setupTest(page: Page) { await page.goto('/'); await page.waitForLoadState('networkidle'); + // Privacy modal should come first in onboarding. + // Be tolerant if it's already accepted (e.g., reused context). + const privacyBtn = page.getByRole('button', { name: 'I Understand' }); + try { + await expect(privacyBtn).toBeVisible({ timeout: 5000 }); + await privacyBtn.click(); + } catch { + // ignore + } + + // Settings modal should appear after privacy acceptance on first visit. + await expect(page.getByRole('button', { name: 'Save' })).toBeVisible({ timeout: 10000 }); + // If running in CI, select the "Custom OpenAI-Like" model and "Deepinfra" provider if (process.env.CI) { await page.getByRole('button', { name: 'Custom OpenAI-Like' }).click(); @@ -118,8 +144,6 @@ export async function setupTest(page: Page) { // Click the "done" button to dismiss the welcome message await page.getByRole('button', { name: 'Save' }).click(); - - await page.getByRole('button', { name: 'I Understand' }).click(); } From 50c3829f9849eb2928dcb1d6de06b39ef6718abb Mon Sep 17 00:00:00 2001 From: Richard R Date: Sun, 25 Jan 2026 14:38:31 -0700 Subject: [PATCH 03/55] update(auth): enhance rate limiting with device/IP backstops and improve UI - Add device ID and IP-based rate limiting to prevent abuse - Refactor UI components for better header menus and settings - Update privacy popup with detailed data usage info - Improve PDF handling and caching to prevent react-pdf warnings - Update README with new Docker instructions and environment variables --- README.md | 118 +++++++------ public/file.svg | 1 - public/globe.svg | 1 - public/next.svg | 1 - public/vercel.svg | 1 - public/window.svg | 1 - src/app/api/rate-limit/status/route.ts | 31 +++- src/app/api/tts/route.ts | 58 ++++++- src/app/epub/[id]/page.tsx | 36 ++-- src/app/html/[id]/page.tsx | 25 +-- src/app/page.tsx | 30 ++-- src/app/pdf/[id]/page.tsx | 34 ++-- src/app/providers.tsx | 2 +- src/app/signin/page.tsx | 2 +- src/app/signup/page.tsx | 2 +- src/components/DocumentHeaderMenu.tsx | 149 +++++++++++++++++ src/components/Footer.tsx | 88 ++-------- src/components/Header.tsx | 2 +- src/components/PDFViewer.tsx | 21 ++- src/components/SettingsModal.tsx | 68 ++++---- src/components/auth/UserMenu.tsx | 24 +-- src/components/icons/Icons.tsx | 20 ++- src/components/privacy-popup.tsx | 155 ++++++++++++++---- src/contexts/PDFContext.tsx | 88 +++++++--- src/contexts/TTSContext.tsx | 11 +- src/lib/pdf.ts | 16 +- src/lib/server/device-id.ts | 41 +++++ src/lib/server/rate-limiter.ts | 218 ++++++++++++++++++------- src/lib/server/request-ip.ts | 28 ++++ template.env | 25 ++- 30 files changed, 907 insertions(+), 390 deletions(-) delete mode 100644 public/file.svg delete mode 100644 public/globe.svg delete mode 100644 public/next.svg delete mode 100644 public/vercel.svg delete mode 100644 public/window.svg create mode 100644 src/components/DocumentHeaderMenu.tsx create mode 100644 src/lib/server/device-id.ts create mode 100644 src/lib/server/request-ip.ts diff --git a/README.md b/README.md index fa51d1f..ed6dc30 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ OpenReader WebUI is an open source text to speech document reader web app built > **Note:** If you have good hardware, you can run [Kokoro-FastAPI with Docker locally](#🗣️-local-kokoro-fastapi-quick-start-cpu-or-gpu) (see below). ### 1. 🐳 Start the Docker container: + Minimal (no persistence, auth disabled unless you set auth env vars): ```bash docker run --name openreader-webui \ --restart unless-stopped \ @@ -47,20 +48,60 @@ OpenReader WebUI is an open source text to speech document reader web app built ghcr.io/richardr1126/openreader-webui:latest ``` - (Optionally): Set the TTS `API_BASE` URL and/or `API_KEY` to be default for all devices + Fully featured (persistent storage + server library import + KokoroFastAPI in Docker + optional auth): ```bash docker run --name openreader-webui \ --restart unless-stopped \ - -e API_KEY=none \ - -e API_BASE=http://host.docker.internal:8880/v1 \ -p 3003:3003 \ + -v openreader_docstore:/app/docstore \ + -v /path/to/your/library:/app/docstore/library:ro \ + -e API_BASE=http://host.docker.internal:8880/v1 \ + -e API_KEY=none \ + -e BETTER_AUTH_URL=http://localhost:3003 \ + -e BETTER_AUTH_SECRET= \ ghcr.io/richardr1126/openreader-webui:latest ``` + You can remove the `/app/docstore/library` mount if you don't need server library import. + You can remove both `BETTER_AUTH_*` env vars to keep auth disabled. + + > **Notes:** + > - `API_BASE` should point to your TTS API server's base URL (if running Kokoro-FastAPI locally in Docker, use `http://host.docker.internal:8880/v1`). + > - `BETTER_AUTH_URL` should be your externally-facing URL for this app (for example `https://reader.example.com` or `http://localhost:3003`). + > - To enable auth, set **both** `BETTER_AUTH_URL` and `BETTER_AUTH_SECRET` generated with `openssl rand -base64 32`. + > - If you set `POSTGRES_URL`, the container will not auto-run migrations for you; run `npx @better-auth/cli migrate -y` against your Postgres database. + +
+ Docker environment variables (Click to expand) + + | Variable | Purpose | Example / Notes | + | --- | --- | --- | + | `API_BASE` | Default TTS API base URL (server-side) | `http://host.docker.internal:8880/v1` | + | `API_KEY` | Default TTS API key | `none` or your provider key | + | `BETTER_AUTH_URL` | Enables auth when set with `BETTER_AUTH_SECRET` | External URL for this app, e.g. `http://localhost:3003` or `https://reader.example.com` | + | `BETTER_AUTH_SECRET` | Enables auth when set with `BETTER_AUTH_URL` | Generate with `openssl rand -base64 32` | + | `POSTGRES_URL` | Use Postgres for auth storage instead of SQLite | If set, run migrations manually (see note above) | + | `GITHUB_CLIENT_ID` | Optional GitHub OAuth sign-in | Requires `GITHUB_CLIENT_SECRET` | + | `GITHUB_CLIENT_SECRET` | Optional GitHub OAuth sign-in | Requires `GITHUB_CLIENT_ID` | + +
+ +
+ Docker volume mounts (Click to expand) + + | Mount | Type | Recommended | Purpose | Example | + | --- | --- | --- | --- | --- | + | `/app/docstore` | Docker named volume | Yes | Persists server-side storage (documents, audiobook exports, settings, SQLite DB if used) | `-v openreader_docstore:/app/docstore` | + | `/app/docstore/library` | Bind mount (host folder) | Optional + `:ro` | Exposes an existing folder of documents for **Server Library Import** | `-v /path/to/your/library:/app/docstore/library:ro` | + + To import from the mounted library: **Settings → Documents → Server Library Import** + + > **Note:** Every file in the mounted library is imported into the client browser's storage. Keep the library reasonably sized to avoid performance issues. + +
+ Visit [http://localhost:3003](http://localhost:3003) to run the app and set your settings. - > **Note:** Requesting audio from the TTS API happens on the Next.js server not the client. So the base URL for the TTS API should be accessible and relative to the Next.js server. If it is in a Docker you may need to use `host.docker.internal` to access the host machine, instead of `localhost`. - ### 2. ⚙️ Configure the app settings in the UI: - Set the TTS Provider and Model in the Settings modal - Set the TTS API Base URL and API Key if needed (more secure to set in env vars) @@ -73,60 +114,6 @@ docker rm openreader-webui && \ docker pull ghcr.io/richardr1126/openreader-webui:latest ``` -### 📦 Volume mounts and Library import - -By default (no volume mounts), OpenReader will store its server-side files inside the container filesystem (which is lost if you remove the container). - -
- - -**Persist server-side storage (`/app/docstore`)** - - - -Run the container with the volume mounted: -```bash -docker run --name openreader-webui \ - --restart unless-stopped \ - -p 3003:3003 \ - -v openreader_docstore:/app/docstore \ - ghcr.io/richardr1126/openreader-webui:latest -``` -This will create a Docker named volume `openreader_docstore` to persist all server-side files, including: - -- **Documents:** Stored under `/app/docstore/documents_v1` -- **Audiobook exports:** Stored under `/app/docstore/audiobooks_v1` - - Per-audiobook settings: `/app/docstore/audiobooks_v1/-audiobook/audiobook.meta.json` - - Chapters: `0001__.m4b` or `0001__<title>.mp3` (no per-chapter `.meta.json` files) -- **Settings** - -This ensures that your documents, exported audiobooks, and server-side settings are retained even if the container is removed or recreated. - -</details> - -<details open> -<summary> - -**Mount an external library folder (read-only recommended)** - -</summary> - -```bash -docker run --name openreader-webui \ - --restart unless-stopped \ - -p 3003:3003 \ - -v openreader_docstore:/app/docstore \ - -v /path/to/your/library:/app/docstore/library:ro \ - ghcr.io/richardr1126/openreader-webui:latest -``` -Separate from the main docstore volume, this mounts an external folder into the container at `/app/docstore/library` (read-only recommended) so OpenReader can use an existing library of documents. - -To import from the mounted library: **Settings → Documents → Server Library Import** - -> **Note:** Every file in the mounted volume is imported to the client browser's storage. Please ensure that the mounted library is not too large to avoid performance issues. - -</details> - ### 🗣️ Local Kokoro-FastAPI Quick-start (CPU or GPU) You can run the Kokoro TTS API server directly with Docker. **We are not responsible for issues with [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI).** For best performance, use an NVIDIA GPU (for GPU version) or Apple Silicon (for CPU version). @@ -239,12 +226,23 @@ Optionally required for different features: cp template.env .env # Edit .env with your configuration settings ``` + Auth is recommended for contributors and is enabled when **both** values are set: + + - Set `BETTER_AUTH_URL` to your local URL (default: `http://localhost:3003`) + - Generate a `BETTER_AUTH_SECRET` and paste it into `.env`: + ```bash + openssl rand -base64 32 + ``` + + > Note: To disable auth, remove either `BETTER_AUTH_URL` or `BETTER_AUTH_SECRET`. + > > Note: The base URL for the TTS API should be accessible and relative to the Next.js server -4. Run SQLite creation: +4. Run auth DB migrations (required when auth is enabled): ```bash npx @better-auth/cli migrate -y ``` + > Note: If you set `POSTGRES_URL` in `.env`, migrations will target Postgres instead of local SQLite. 5. Start the development server: diff --git a/public/file.svg b/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/public/file.svg +++ /dev/null @@ -1 +0,0 @@ -<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg> \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ -<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg> \ No newline at end of file diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg> \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ -<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg> \ No newline at end of file diff --git a/public/window.svg b/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/public/window.svg +++ /dev/null @@ -1 +0,0 @@ -<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg> \ No newline at end of file diff --git a/src/app/api/rate-limit/status/route.ts b/src/app/api/rate-limit/status/route.ts index ce6307d..8bd1c04 100644 --- a/src/app/api/rate-limit/status/route.ts +++ b/src/app/api/rate-limit/status/route.ts @@ -1,8 +1,10 @@ -import { NextResponse } from 'next/server'; +import { NextResponse, type NextRequest } from 'next/server'; import { auth } from '@/lib/server/auth'; import { rateLimiter, RATE_LIMITS } from '@/lib/server/rate-limiter'; import { headers } from 'next/headers'; import { isAuthEnabled } from '@/lib/server/auth-config'; +import { getClientIp } from '@/lib/server/request-ip'; +import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/device-id'; export const dynamic = 'force-dynamic'; @@ -14,7 +16,7 @@ function getUtcResetTimeIso(): string { return tomorrow.toISOString(); } -export async function GET() { +export async function GET(req: NextRequest) { try { // If auth is not enabled, return unlimited status if (!isAuthEnabled() || !auth) { @@ -53,12 +55,21 @@ export async function GET() { const isAnonymous = Boolean(session.user.isAnonymous); - const result = await rateLimiter.getCurrentUsage({ - id: session.user.id, - isAnonymous - }); + const ip = getClientIp(req); + const device = isAnonymous ? getOrCreateDeviceId(req) : null; - return NextResponse.json({ + const result = await rateLimiter.getCurrentUsage( + { + id: session.user.id, + isAnonymous, + }, + { + deviceId: device?.deviceId ?? null, + ip, + } + ); + + const response = NextResponse.json({ allowed: result.allowed, currentCount: result.currentCount, limit: result.limit, @@ -67,6 +78,12 @@ export async function GET() { userType: isAnonymous ? 'anonymous' : 'authenticated', authEnabled: true }); + + if (device?.didCreate) { + setDeviceIdCookie(response, device.deviceId); + } + + return response; } catch (error) { console.error('Error getting rate limit status:', error); return NextResponse.json( diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index de750fd..d71ea94 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -10,6 +10,8 @@ import { headers } from 'next/headers'; import { auth } from '@/lib/server/auth'; import { rateLimiter, RATE_LIMITS } from '@/lib/server/rate-limiter'; import { isAuthEnabled } from '@/lib/server/auth-config'; +import { getClientIp } from '@/lib/server/request-ip'; +import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/device-id'; type CustomVoice = string; type ExtendedSpeechParams = Omit<SpeechCreateParams, 'voice'> & { @@ -141,6 +143,9 @@ export async function POST(req: NextRequest) { } // Auth and rate limiting check (only when auth is enabled) + let didCreateDeviceIdCookie = false; + let deviceIdToSet: string | null = null; + if (isAuthEnabled() && auth) { const session = await auth.api.getSession({ headers: await headers() @@ -156,10 +161,21 @@ export async function POST(req: NextRequest) { const isAnonymous = Boolean(session.user.isAnonymous); const charCount = text.length; + const ip = getClientIp(req); + const device = isAnonymous ? getOrCreateDeviceId(req) : null; + if (device?.didCreate) { + didCreateDeviceIdCookie = true; + deviceIdToSet = device.deviceId; + } + // Check rate limit const rateLimitResult = await rateLimiter.checkAndIncrementLimit( { id: session.user.id, isAnonymous }, - charCount + charCount, + { + deviceId: device?.deviceId ?? null, + ip, + } ); if (!rateLimitResult.allowed) { @@ -186,13 +202,19 @@ export async function POST(req: NextRequest) { instance: req.nextUrl.pathname, }; - return new NextResponse(JSON.stringify(problem), { + const response = new NextResponse(JSON.stringify(problem), { status: 429, headers: { 'Content-Type': 'application/problem+json', 'Retry-After': String(retryAfterSeconds), }, }); + + if (didCreateDeviceIdCookie && deviceIdToSet) { + setDeviceIdCookie(response, deviceIdToSet); + } + + return response; } } @@ -254,7 +276,7 @@ export async function POST(req: NextRequest) { const cachedBuffer = ttsAudioCache.get(cacheKey); if (cachedBuffer) { if (ifNoneMatch && (ifNoneMatch.includes(cacheKey) || ifNoneMatch.includes(etag))) { - return new NextResponse(null, { + const response = new NextResponse(null, { status: 304, headers: { 'ETag': etag, @@ -262,9 +284,15 @@ export async function POST(req: NextRequest) { 'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url' } }); + + if (didCreateDeviceIdCookie && deviceIdToSet) { + setDeviceIdCookie(response, deviceIdToSet); + } + + return response; } console.log('TTS cache HIT for key:', cacheKey.slice(0, 8)); - return new NextResponse(cachedBuffer, { + const response = new NextResponse(cachedBuffer, { headers: { 'Content-Type': contentType, 'X-Cache': 'HIT', @@ -274,6 +302,12 @@ export async function POST(req: NextRequest) { 'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url' } }); + + if (didCreateDeviceIdCookie && deviceIdToSet) { + setDeviceIdCookie(response, deviceIdToSet); + } + + return response; } // De-duplicate identical in-flight requests @@ -292,7 +326,7 @@ export async function POST(req: NextRequest) { try { const buffer = await existing.promise; - return new NextResponse(buffer, { + const response = new NextResponse(buffer, { headers: { 'Content-Type': contentType, 'X-Cache': 'INFLIGHT', @@ -302,6 +336,12 @@ export async function POST(req: NextRequest) { 'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url' } }); + + if (didCreateDeviceIdCookie && deviceIdToSet) { + setDeviceIdCookie(response, deviceIdToSet); + } + + return response; } finally { try { req.signal.removeEventListener('abort', onAbort); } catch { } } @@ -340,7 +380,7 @@ export async function POST(req: NextRequest) { try { req.signal.removeEventListener('abort', onAbort); } catch { } } - return new NextResponse(buffer, { + const response = new NextResponse(buffer, { headers: { 'Content-Type': contentType, 'X-Cache': 'MISS', @@ -350,6 +390,12 @@ export async function POST(req: NextRequest) { 'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url' } }); + + if (didCreateDeviceIdCookie && deviceIdToSet) { + setDeviceIdCookie(response, deviceIdToSet); + } + + return response; } catch (error) { // Check if this was an abort error if (error instanceof Error && error.name === 'AbortError') { diff --git a/src/app/epub/[id]/page.tsx b/src/app/epub/[id]/page.tsx index b36dd22..c3a260e 100644 --- a/src/app/epub/[id]/page.tsx +++ b/src/app/epub/[id]/page.tsx @@ -12,14 +12,14 @@ import { Header } from '@/components/Header'; import { useTTS } from "@/contexts/TTSContext"; import TTSPlayer from '@/components/player/TTSPlayer'; import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton'; -import { ZoomControl } from '@/components/ZoomControl'; +import { DocumentHeaderMenu } from '@/components/DocumentHeaderMenu'; import { AudiobookExportModal } from '@/components/AudiobookExportModal'; -import { DownloadIcon } from '@/components/icons/Icons'; import type { TTSAudiobookChapter } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; import { resolveDocumentId } from '@/lib/dexie'; import { RateLimitBanner } from '@/components/rate-limit-banner'; import { useAutoRateLimit } from '@/contexts/AutoRateLimitContext'; +import { UserMenu } from '@/components/auth/UserMenu'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; @@ -154,30 +154,16 @@ export default function EPUBPage() { title={isLoading ? 'Loading…' : (currDocName || '')} right={ <div className="flex items-center gap-3"> - <ZoomControl - value={padPct} - onIncrease={() => setPadPct(p => Math.min(p + 10, 100))} // Increase = less padding - onDecrease={() => setPadPct(p => Math.max(p - 10, 0))} // Decrease = add padding - min={0} - max={100} + <DocumentHeaderMenu + zoomLevel={padPct} + onZoomIncrease={() => setPadPct(p => Math.min(p + 10, 100))} + onZoomDecrease={() => setPadPct(p => Math.max(p - 10, 0))} + onOpenSettings={() => setIsSettingsOpen(true)} + onOpenAudiobook={() => setIsAudiobookModalOpen(true)} + isDev={isDev} + minZoom={0} + maxZoom={100} /> - {isDev && ( - <button - onClick={() => setIsAudiobookModalOpen(true)} - className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" - aria-label="Open audiobook export" - title="Export Audiobook" - > - <DownloadIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" /> - </button> - )} - <button - onClick={() => setIsSettingsOpen(true)} - className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" - aria-label="Open settings" - > - <SettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:rotate-45 hover:text-accent" /> - </button> </div> } /> diff --git a/src/app/html/[id]/page.tsx b/src/app/html/[id]/page.tsx index 6c98201..46db7b6 100644 --- a/src/app/html/[id]/page.tsx +++ b/src/app/html/[id]/page.tsx @@ -8,12 +8,11 @@ import { DocumentSkeleton } from '@/components/DocumentSkeleton'; import { HTMLViewer } from '@/components/HTMLViewer'; import { DocumentSettings } from '@/components/DocumentSettings'; import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton'; -import { SettingsIcon } from '@/components/icons/Icons'; import { Header } from '@/components/Header'; import { useTTS } from "@/contexts/TTSContext"; import TTSPlayer from '@/components/player/TTSPlayer'; -import { ZoomControl } from '@/components/ZoomControl'; import { resolveDocumentId } from '@/lib/dexie'; +import { DocumentHeaderMenu } from '@/components/DocumentHeaderMenu'; import { RateLimitBanner } from '@/components/rate-limit-banner'; import { useAutoRateLimit } from '@/contexts/AutoRateLimitContext'; @@ -62,7 +61,6 @@ export default function HTMLPage() { }, [loadDocument]); // Compute available height = viewport - (header height + tts bar height) - <RateLimitPauseButton /> useEffect(() => { const compute = () => { const header = document.querySelector('[data-app-header]') as HTMLElement | null; @@ -122,20 +120,14 @@ export default function HTMLPage() { title={isLoading ? 'Loading…' : (currDocName || '')} right={ <div className="flex items-center gap-3"> - <ZoomControl - value={padPct} - onIncrease={() => setPadPct(p => Math.min(p + 10, 100))} // Increase = less padding - onDecrease={() => setPadPct(p => Math.max(p - 10, 0))} // Decrease = add padding - min={0} - max={100} + <DocumentHeaderMenu + zoomLevel={padPct} + onZoomIncrease={() => setPadPct(p => Math.min(p + 10, 100))} + onZoomDecrease={() => setPadPct(p => Math.max(p - 10, 0))} + onOpenSettings={() => setIsSettingsOpen(true)} + minZoom={0} + maxZoom={100} /> - <button - onClick={() => setIsSettingsOpen(true)} - className="inline-flex items-center h-8 px-2.5 rounded-md border border-offbase bg-base text-foreground text-xs md:text-sm hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" - aria-label="Open settings" - > - <SettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:rotate-45 hover:text-accent" /> - </button> </div> } /> @@ -153,6 +145,7 @@ export default function HTMLPage() { {isAtLimit ? ( <div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar> <div className="px-2 md:px-3 pt-1 pb-1.5 flex items-center justify-center gap-1 min-h-10"> + <RateLimitPauseButton /> <RateLimitBanner /> </div> </div> diff --git a/src/app/page.tsx b/src/app/page.tsx index 3d85b7f..3cd8a6c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,3 +1,4 @@ +import { Header } from '@/components/Header'; import { HomeContent } from '@/components/HomeContent'; import { SettingsModal } from '@/components/SettingsModal'; import { UserMenu } from '@/components/auth/UserMenu'; @@ -9,23 +10,26 @@ const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.N export default function Home() { return ( <div className="flex flex-col h-full w-full"> - <SettingsModal /> + <Header + title={ + <div className="flex items-center gap-2"> + {/* eslint-disable-next-line @next/next/no-img-element */} + <img src="/icon.svg" alt="" className="w-5 h-5" aria-hidden="true" /> + <h1 className="text-xs sm:text-sm font-bold truncate text-foreground tracking-tight">OpenReader</h1> + </div> + } + right={ + <div className="flex items-center gap-2"> + <SettingsModal /> + <UserMenu /> + </div> + } + /> <AuthLoader> <> - <UserMenu /> - <section className="px-4 pt-6 pb-4 md:pt-10 md:pb-6"> - <div className="max-w-7xl mx-auto"> - <h1 className="text-2xl md:text-3xl font-bold tracking-tight mb-2 text-foreground">OpenReader WebUI</h1> - <p className="text-sm leading-relaxed max-w-[77ch] text-foreground"> - Open source document reader {isDev ? 'self-hosted server' : 'demo app'}. - <span className="block font-medium">Read & listen to PDF, EPUB, MD, and TXT documents with high quality text to speech voices.</span> - </p> - </div> - </section> - <section className="flex-1 px-4 pb-8 overflow-auto"> + <section className="flex-1 px-4 pb-8 pt-4 overflow-auto"> <div className="max-w-7xl mx-auto"> <RateLimitBanner className="mb-6" /> - <div className="prism-divider mb-4 sm:mb-6" aria-hidden="true" /> <HomeContent /> </div> </section> diff --git a/src/app/pdf/[id]/page.tsx b/src/app/pdf/[id]/page.tsx index 588da24..e628c58 100644 --- a/src/app/pdf/[id]/page.tsx +++ b/src/app/pdf/[id]/page.tsx @@ -8,9 +8,8 @@ import { useCallback, useEffect, useState } from 'react'; import { DocumentSkeleton } from '@/components/DocumentSkeleton'; import { useTTS } from '@/contexts/TTSContext'; import { DocumentSettings } from '@/components/DocumentSettings'; -import { SettingsIcon, DownloadIcon } from '@/components/icons/Icons'; +import { DocumentHeaderMenu } from '@/components/DocumentHeaderMenu'; import { Header } from '@/components/Header'; -import { ZoomControl } from '@/components/ZoomControl'; import { AudiobookExportModal } from '@/components/AudiobookExportModal'; import type { TTSAudiobookChapter } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; @@ -19,6 +18,7 @@ import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton'; import { resolveDocumentId } from '@/lib/dexie'; import { RateLimitBanner } from '@/components/rate-limit-banner'; import { useAutoRateLimit } from '@/contexts/AutoRateLimitContext'; +import { UserMenu } from '@/components/auth/UserMenu'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; @@ -60,7 +60,7 @@ export default function PDFViewerPage() { router.replace(`/pdf/${resolved}`); return; } - setCurrentDocument(resolved); + await setCurrentDocument(resolved); } catch (err) { console.error('Error loading document:', err); setError('Failed to load document'); @@ -149,24 +149,16 @@ export default function PDFViewerPage() { title={isLoading ? 'Loading…' : (currDocName || '')} right={ <div className="flex items-center gap-2"> - <ZoomControl value={zoomLevel} onIncrease={handleZoomIn} onDecrease={handleZoomOut} /> - {isDev && ( - <button - onClick={() => setIsAudiobookModalOpen(true)} - className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" - aria-label="Open audiobook export" - title="Export Audiobook" - > - <DownloadIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" /> - </button> - )} - <button - onClick={() => setIsSettingsOpen(true)} - className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" - aria-label="Open settings" - > - <SettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:rotate-45 hover:text-accent" /> - </button> + <DocumentHeaderMenu + zoomLevel={zoomLevel} + onZoomIncrease={handleZoomIn} + onZoomDecrease={handleZoomOut} + onOpenSettings={() => setIsSettingsOpen(true)} + onOpenAudiobook={() => setIsAudiobookModalOpen(true)} + isDev={isDev} + minZoom={50} + maxZoom={300} + /> </div> } /> diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 8e14dd4..c6a26ed 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -27,7 +27,7 @@ export function Providers({ children, authEnabled, authBaseUrl }: ProvidersProps <EPUBProvider> <HTMLProvider> {children} - <PrivacyPopup /> + <PrivacyPopup authEnabled={authEnabled} /> </HTMLProvider> </EPUBProvider> </PDFProvider> diff --git a/src/app/signin/page.tsx b/src/app/signin/page.tsx index d7cf651..187d02d 100644 --- a/src/app/signin/page.tsx +++ b/src/app/signin/page.tsx @@ -266,7 +266,7 @@ function SignInContent() { <p className="text-xs text-muted"> By signing in, you agree to our{' '} <button - onClick={() => showPrivacyPopup()} + onClick={() => showPrivacyPopup({ authEnabled })} className="underline hover:text-foreground" > Privacy Policy diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index c9cf414..3f1e5f3 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -229,7 +229,7 @@ export default function SignUpPage() { <p className="text-xs text-muted"> By creating an account, you agree to our{' '} <button - onClick={() => showPrivacyPopup()} + onClick={() => showPrivacyPopup({ authEnabled })} className="underline hover:text-foreground" > Privacy Policy diff --git a/src/components/DocumentHeaderMenu.tsx b/src/components/DocumentHeaderMenu.tsx new file mode 100644 index 0000000..bff9593 --- /dev/null +++ b/src/components/DocumentHeaderMenu.tsx @@ -0,0 +1,149 @@ +'use client'; + +import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'; +import { Fragment } from 'react'; +import { DotsVerticalIcon, FileSettingsIcon, DownloadIcon } from '@/components/icons/Icons'; +import { ZoomControl } from '@/components/ZoomControl'; +import { UserMenu } from '@/components/auth/UserMenu'; + +interface DocumentHeaderMenuProps { + zoomLevel: number; + onZoomIncrease: () => void; + onZoomDecrease: () => void; + onOpenSettings: () => void; + onOpenAudiobook?: () => void; + isDev?: boolean; + minZoom?: number; + maxZoom?: number; +} + +export function DocumentHeaderMenu({ + zoomLevel, + onZoomIncrease, + onZoomDecrease, + onOpenSettings, + onOpenAudiobook, + isDev, + minZoom = 0, + maxZoom = 100 +}: DocumentHeaderMenuProps) { + + // --- Desktop View --- + const DesktopView = ( + <div className="hidden sm:flex items-center gap-2"> + <ZoomControl + value={zoomLevel} + onIncrease={onZoomIncrease} + onDecrease={onZoomDecrease} + min={minZoom} + max={maxZoom} + /> + {isDev && onOpenAudiobook && ( + <button + onClick={onOpenAudiobook} + className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" + aria-label="Open audiobook export" + title="Export Audiobook" + > + <DownloadIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" /> + </button> + )} + <button + onClick={onOpenSettings} + className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" + aria-label="Open settings" + > + <FileSettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" /> + </button> + <UserMenu /> + </div> + ); + + // --- Mobile View --- + const MobileView = ( + <div className="sm:hidden flex items-center"> + <Menu as="div" className="relative inline-block text-left"> + <MenuButton + className="inline-flex items-center justify-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent" + title="Menu" + > + <DotsVerticalIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" /> + </MenuButton> + <Transition + as={Fragment} + enter="transition ease-out duration-100" + enterFrom="transform opacity-0 scale-95" + enterTo="transform opacity-100 scale-100" + leave="transition ease-in duration-75" + leaveFrom="transform opacity-100 scale-100" + leaveTo="transform opacity-0 scale-95" + > + <MenuItems className="absolute right-0 mt-2 min-w-max origin-top-right divide-y divide-offbase rounded-md bg-base shadow-lg ring-1 ring-black/5 focus:outline-none z-50"> + {/* Zoom Controls Section */} + <div className="px-4 py-3"> + <p className="text-xs font-medium text-muted mb-2">Zoom / Padding</p> + <div className="flex justify-center"> + <ZoomControl + value={zoomLevel} + onIncrease={() => { + // We wrap in a handler to stop propagation if needed, + // but ZoomControl buttons handle their own clicks. + // However, Menu might close on click? + // Headless UI Menu closes on click inside MenuItem, but these are just buttons in a div. + // It should NOT close unless we click a MenuItem. + onZoomIncrease(); + }} + onDecrease={onZoomDecrease} + min={minZoom} + max={maxZoom} + /> + </div> + </div> + + {/* Actions Section */} + <div className="p-1"> + {isDev && onOpenAudiobook && ( + <MenuItem> + {({ active }) => ( + <button + onClick={onOpenAudiobook} + className={`${active ? 'bg-offbase text-accent' : 'text-foreground' + } group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`} + > + <DownloadIcon className="h-4 w-4" /> + Export Audiobook + </button> + )} + </MenuItem> + )} + <MenuItem> + {({ active }) => ( + <button + onClick={onOpenSettings} + className={`${active ? 'bg-offbase text-accent' : 'text-foreground' + } group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`} + > + <FileSettingsIcon className="h-4 w-4" /> + Settings + </button> + )} + </MenuItem> + </div> + + {/* Auth Section */} + <div className="p-2 border-t border-offbase flex justify-center"> + <UserMenu /> + </div> + </MenuItems> + </Transition> + </Menu> + </div> + ); + + return ( + <> + {DesktopView} + {MobileView} + </> + ); +} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 904d843..b1ab6d1 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,34 +1,33 @@ -import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react' +'use client' + import { GithubIcon } from '@/components/icons/Icons' -import { CodeBlock } from '@/components/CodeBlock' +import { showPrivacyPopup } from '@/components/privacy-popup' +import { useAuthConfig } from '@/contexts/AutoRateLimitContext' export function Footer() { + const { authEnabled } = useAuthConfig(); + return ( <footer className="m-8 mb-2 text-sm text-muted"> <div className="flex flex-col items-center space-y-4"> <div className="flex flex-wrap sm:flex-nowrap items-center justify-center text-center sm:space-x-3"> <a - href="https://github.com/richardr1126/OpenReader-WebUI" + href="https://github.com/richardr1126/OpenReader-WebUI#readme" target="_blank" rel="noopener noreferrer" - className="hover:text-foreground transition-colors" + className="inline-flex items-center gap-2 font-bold hover:text-foreground transition-colors" > <GithubIcon className="w-5 h-5" /> + <span>Self host</span> </a> <span className='w-full sm:w-fit'>•</span> - <Popover className="flex"> - <PopoverButton className="font-bold hover:text-foreground transition-colors outline-none flex items-center gap-1"> - Privacy info - </PopoverButton> - <PopoverPanel anchor="top" className="bg-base p-4 rounded-lg shadow-xl border border-offbase z-50"> - <p className='max-w-xs'>Documents are uploaded to your local browser cache.</p> - <p className='mt-3 max-w-xs'>Each paragraph of the document you are viewing is sent to Deepinfra for audio generation through a Vercel backend proxy, containing a shared caching pool.</p> - <p className='mt-3 max-w-xs'>The audio is streamed back to your browser and played in real-time.</p> - <p className='mt-3 max-w-xs font-bold'><em>Self-hosting is the recommended way to use this app for a truly secure experience.</em></p> - {/* Vercel analytics disclaimer */} - <p className='mt-3 max-w-xs'>This site uses Vercel Analytics to collect anonymous usage data to help improve the service.</p> - </PopoverPanel> - </Popover> + <button + type="button" + onClick={() => showPrivacyPopup({ authEnabled })} + className="font-bold hover:text-foreground transition-colors outline-none" + > + Privacy + </button> <span className='w-full sm:w-fit'>•</span> <span> Powered by{' '} @@ -51,61 +50,6 @@ export function Footer() { </a> </span> </div> - <div className='font-medium text-center inline-flex truncate items-center justify-center gap-1'> - <span>This is a demo app (</span> - <Popover className="relative"> - <PopoverButton className="font-bold hover:text-foreground transition-colors outline-none inline"> - self-host - </PopoverButton> - <PopoverPanel anchor="top" className="bg-base p-6 rounded-xl shadow-2xl border border-offbase w-[90vw] max-w-3xl z-50 backdrop-blur-md flex flex-col gap-4"> - <div className="space-y-4 font-medium"> - <h3 className="text-lg font-bold text-foreground">Self-Hosting Instructions</h3> - - <div> - <p className="mb-2 font-medium"> - 1. Start the <a href="https://github.com/remsky/Kokoro-FastAPI" target="_blank" rel="noopener noreferrer" className="text-muted hover:text-foreground underline decoration-dotted underline-offset-4">Kokoro-FastAPI</a> container - </p> - <CodeBlock> - { - `docker run -d \\ - --name kokoro-tts \\ - --restart unless-stopped \\ - -p 8880:8880 \\ - -e ONNX_NUM_THREADS=8 \\ - -e ONNX_INTER_OP_THREADS=4 \\ - -e ONNX_EXECUTION_MODE=parallel \\ - -e ONNX_OPTIMIZATION_LEVEL=all \\ - -e ONNX_MEMORY_PATTERN=true \\ - -e ONNX_ARENA_EXTEND_STRATEGY=kNextPowerOfTwo \\ - -e API_LOG_LEVEL=DEBUG \\ - ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.4` - } - </CodeBlock> - </div> - - <div> - <p className="mb-2 text-foreground font-medium">2. Start OpenReader WebUI container</p> - <CodeBlock> -{ -`docker run --name openreader-webui --rm \\ --e API_BASE=http://kokoro-tts:8880/v1 \\ --p 3003:3003 \\ --v openreader_docstore:/app/docstore \\ --v /path/to/your/library:/app/docstore/library:ro \\ -ghcr.io/richardr1126/openreader-webui:latest` -} - </CodeBlock> - </div> - - <p> - Visit <a href="http://localhost:3003" target="_blank" rel="noopener noreferrer" className="text-muted hover:text-foreground transition-colors underline decoration-dotted underline-offset-4">http://localhost:3003</a> to run the app and set your settings. - {' '}See the <a href="https://github.com/richardr1126/OpenReader-WebUI#readme" target="_blank" rel="noopener noreferrer" className="text-muted hover:text-foreground transition-colors underline decoration-dotted underline-offset-4">README</a> for more details. - </p> - </div> - </PopoverPanel> - </Popover> - <span> for full functionality)</span> - </div> </div> </footer> ) diff --git a/src/components/Header.tsx b/src/components/Header.tsx index cf55de5..dfd8367 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -15,7 +15,7 @@ export function Header({ <div className="flex items-center gap-2 min-w-0 flex-1"> {left} {typeof title === 'string' ? ( - <h1 className="text-xs sm:text-sm font-semibold truncate text-foreground tracking-tight">{title}</h1> + <h1 className="text-xs md:text-sm font-semibold truncate text-foreground tracking-tight">{title}</h1> ) : ( title )} diff --git a/src/components/PDFViewer.tsx b/src/components/PDFViewer.tsx index c591d7d..b21081f 100644 --- a/src/components/PDFViewer.tsx +++ b/src/components/PDFViewer.tsx @@ -49,12 +49,30 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { clearWordHighlights, highlightWordIndex, onDocumentLoadSuccess, + currDocId, currDocData, currDocPages, currDocText, currDocPage, } = usePDF(); + // IMPORTANT: + // - pdf.js may transfer/detach ArrayBuffers when sending them to its worker, so we must clone. + // - react-pdf warns if `file` changes by reference but is deep-equal to the previous value. + // Cache a stable `{ data: Uint8Array }` by `currDocId` so reloading the same PDF with + // identical bytes doesn't create a new `file` object and trigger the warning. + const fileCacheRef = useRef<Map<string, { data: Uint8Array }>>(new Map()); + + if (currDocId && currDocData && !fileCacheRef.current.has(currDocId)) { + try { + fileCacheRef.current.set(currDocId, { data: new Uint8Array(currDocData.slice(0)) }); + } catch (e) { + console.error('Failed to prepare PDF data for viewer:', e); + } + } + + const documentFile = currDocId ? fileCacheRef.current.get(currDocId) : undefined; + const layoutKey = `${zoomLevel}:${containerWidth}:${containerHeight}:${viewType}:${currDocPage}`; const clearSentenceHighlightTimeouts = useCallback(() => { @@ -266,9 +284,10 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { return ( <div ref={containerRef} className="flex flex-col items-center overflow-auto w-full px-6 h-full"> <Document + key={currDocId || 'pdf'} loading={<DocumentSkeleton />} noData={<DocumentSkeleton />} - file={currDocData} + file={documentFile} onLoadSuccess={(pdf) => { onDocumentLoadSuccess(pdf); }} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index aa9b3a7..be29f97 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -37,6 +37,7 @@ import { useAuthSession } from '@/hooks/useAuth'; import { markSignedOut, clearSignedOut } from '@/lib/session-utils'; import { useAuthConfig, useAutoRateLimit } from '@/contexts/AutoRateLimitContext'; import { useRouter } from 'next/navigation'; +import { showPrivacyPopup } from '@/components/privacy-popup'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; @@ -45,7 +46,7 @@ const themes = THEMES.map(id => ({ name: id.charAt(0).toUpperCase() + id.slice(1) })); -export function SettingsModal() { +export function SettingsModal({ className = '' }: { className?: string }) { const [isOpen, setIsOpen] = useState(false); const { theme, setTheme } = useTheme(); @@ -393,11 +394,11 @@ export function SettingsModal() { <> <Button onClick={() => setIsOpen(true)} - className="rounded-full p-2 text-foreground hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent absolute top-2 right-2 sm:top-4 sm:right-4" + className={`inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent ${className}`} aria-label="Settings" tabIndex={0} > - <SettingsIcon className="w-4 h-4 sm:w-5 sm:h-5 transform transition-transform duration-200 ease-in-out hover:rotate-45" /> + <SettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:rotate-45 hover:text-accent" /> </Button> <Transition appear show={isOpen} as={Fragment}> @@ -426,12 +427,21 @@ export function SettingsModal() { leaveTo="opacity-0 scale-95" > <DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all"> - <DialogTitle - as="h3" - className="text-lg font-semibold leading-6 text-foreground mb-4" - > - Settings - </DialogTitle> + <div className="flex items-center justify-between gap-3 mb-4"> + <DialogTitle + as="h3" + className="text-lg font-semibold leading-6 text-foreground" + > + Settings + </DialogTitle> + + <Button + onClick={() => showPrivacyPopup({ authEnabled })} + className="text-sm font-medium text-muted hover:text-accent" + > + Privacy + </Button> + </div> <TabGroup> <TabList className="flex flex-col sm:flex-col-none sm:flex-row gap-1 rounded-xl bg-background p-1 mb-4"> @@ -520,6 +530,24 @@ export function SettingsModal() { </Listbox> </div> + {(localTTSProvider === 'custom-openai' || !localBaseUrl || localBaseUrl === '') && ( + <div className="space-y-1"> + <label className="block text-sm font-medium text-foreground"> + API Base URL + {localBaseUrl && <span className="ml-2 text-xs text-accent">(Overriding env)</span>} + </label> + <div className="flex gap-2"> + <Input + type="text" + value={localBaseUrl} + onChange={(e) => handleInputChange('baseUrl', e.target.value)} + placeholder="Using environment variable" + className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" + /> + </div> + </div> + )} + <div className="space-y-1"> <label className="block text-sm font-medium text-foreground"> API Key @@ -620,24 +648,6 @@ export function SettingsModal() { </div> )} - {(localTTSProvider === 'custom-openai' || !localBaseUrl || localBaseUrl === '') && ( - <div className="space-y-1"> - <label className="block text-sm font-medium text-foreground"> - API Base URL - {localBaseUrl && <span className="ml-2 text-xs text-accent">(Overriding env)</span>} - </label> - <div className="flex gap-2"> - <Input - type="text" - value={localBaseUrl} - onChange={(e) => handleInputChange('baseUrl', e.target.value)} - placeholder="Using environment variable" - className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" - /> - </div> - </div> - )} - <div className="pt-4 flex justify-end gap-2"> <Button type="button" @@ -863,12 +873,12 @@ export function SettingsModal() { </p> <div className="grid grid-cols-2 gap-3"> <Link href="/signin" className="w-full"> - <Button className="w-full justify-center rounded-lg bg-background border border-offbase px-3 py-2 text-sm font-medium text-foreground hover:bg-offbase"> + <Button className="w-full justify-center rounded-lg bg-background border border-offbase px-3 py-2 text-sm font-medium text-foreground hover:bg-offbase focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-base transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"> Log In </Button> </Link> <Link href="/signup" className="w-full"> - <Button className="w-full justify-center rounded-lg bg-accent px-3 py-2 text-sm font-medium text-background hover:bg-secondary-accent"> + <Button className="w-full justify-center rounded-lg bg-accent px-3 py-2 text-sm font-medium text-background hover:bg-secondary-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-base transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"> Sign Up </Button> </Link> diff --git a/src/components/auth/UserMenu.tsx b/src/components/auth/UserMenu.tsx index 6c0c6b0..77a7ff5 100644 --- a/src/components/auth/UserMenu.tsx +++ b/src/components/auth/UserMenu.tsx @@ -8,7 +8,7 @@ import { getAuthClient } from '@/lib/auth-client'; import { clearSignedOut } from '@/lib/session-utils'; import { useRouter } from 'next/navigation'; -export function UserMenu() { +export function UserMenu({ className = '' }: { className?: string }) { const { authEnabled, baseUrl } = useAuthConfig(); const { refresh: refreshRateLimit } = useAutoRateLimit(); const { data: session, isPending } = useAuthSession(); @@ -27,14 +27,14 @@ export function UserMenu() { if (!session || session.user.isAnonymous) { return ( - <div className="absolute top-2 right-14 sm:top-4 sm:right-16 flex gap-2"> + <div className={`flex gap-2 ${className}`}> <Link href="/signin"> - <Button className="rounded-lg bg-accent/10 px-3 py-1.5 text-sm font-medium text-accent hover:bg-accent/20"> - {session?.user.isAnonymous ? 'Log In ' : 'Sign In'} + <Button className="inline-flex items-center rounded-md bg-base border border-offbase px-2 py-1 text-xs font-medium text-foreground hover:bg-offbase focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 transform transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent"> + {session?.user.isAnonymous ? 'Log In' : 'Sign In'} </Button> </Link> - <Link href="/signup" className="hidden sm:block"> - <Button className="rounded-lg bg-accent px-3 py-1.5 text-sm font-medium text-background hover:bg-secondary-accent"> + <Link href="/signup"> + <Button className="inline-flex items-center rounded-md bg-accent px-2 py-1 text-xs font-medium text-background hover:bg-secondary-accent focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 transform transition-all duration-200 ease-in-out hover:scale-[1.09]"> Sign Up </Button> </Link> @@ -43,22 +43,22 @@ export function UserMenu() { } return ( - <div className="absolute top-2 right-14 sm:top-4 sm:right-16 flex items-center gap-3"> - <div className="hidden sm:flex flex-col items-end"> - <span className="text-xs font-medium text-foreground"> + <div className={`flex items-center gap-2 ${className}`}> + <div className="hidden sm:flex flex-col items-end mr-1"> + <span className="text-xs font-medium text-foreground leading-none mb-0.5"> {session.user.name || session.user.email || 'Account'} </span> - <span className="text-[10px] text-muted truncate max-w-[120px]"> + <span className="text-[10px] text-muted truncate max-w-[120px] leading-none"> {session.user.email} </span> </div> <Button onClick={handleSignOut} - className="p-2 text-foreground/70 hover:text-red-500 transition-colors" + className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-red-500" title="Sign Out" > - <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path> <polyline points="16 17 21 12 16 7"></polyline> <line x1="21" y1="12" x2="9" y2="12"></line> diff --git a/src/components/icons/Icons.tsx b/src/components/icons/Icons.tsx index f731f81..9e0c5b1 100644 --- a/src/components/icons/Icons.tsx +++ b/src/components/icons/Icons.tsx @@ -233,7 +233,7 @@ export function PDFIcon(props: React.SVGProps<SVGSVGElement>) { strokeWidth='0.25' {...props} > - <path d="M25.6686 26.0962C25.1812 26.2401 24.4656 26.2563 23.6984 26.145C22.875 26.0256 22.0351 25.7739 21.2096 25.403C22.6817 25.1888 23.8237 25.2548 24.8005 25.6009C25.0319 25.6829 25.412 25.9021 25.6686 26.0962ZM17.4552 24.7459C17.3953 24.7622 17.3363 24.7776 17.2776 24.7939C16.8815 24.9017 16.4961 25.0069 16.1247 25.1005L15.6239 25.2275C14.6165 25.4824 13.5865 25.7428 12.5692 26.0529C12.9558 25.1206 13.315 24.178 13.6667 23.2564C13.9271 22.5742 14.193 21.8773 14.468 21.1894C14.6075 21.4198 14.7531 21.6503 14.9046 21.8814C15.5948 22.9326 16.4624 23.9045 17.4552 24.7459ZM14.8927 14.2326C14.958 15.383 14.7098 16.4897 14.3457 17.5514C13.8972 16.2386 13.6882 14.7889 14.2489 13.6185C14.3927 13.3185 14.5105 13.1581 14.5869 13.0744C14.7049 13.2566 14.8601 13.6642 14.8927 14.2326ZM9.63347 28.8054C9.38148 29.2562 9.12426 29.6782 8.86063 30.0767C8.22442 31.0355 7.18393 32.0621 6.64941 32.0621C6.59681 32.0621 6.53316 32.0536 6.44015 31.9554C6.38028 31.8926 6.37069 31.8476 6.37359 31.7862C6.39161 31.4337 6.85867 30.8059 7.53527 30.2238C8.14939 29.6957 8.84352 29.2262 9.63347 28.8054ZM27.3706 26.1461C27.2889 24.9719 25.3123 24.2186 25.2928 24.2116C24.5287 23.9407 23.6986 23.8091 22.7552 23.8091C21.7453 23.8091 20.6565 23.9552 19.2582 24.2819C18.014 23.3999 16.9392 22.2957 16.1362 21.0733C15.7816 20.5332 15.4628 19.9941 15.1849 19.4675C15.8633 17.8454 16.4742 16.1013 16.3632 14.1479C16.2737 12.5816 15.5674 11.5295 14.6069 11.5295C13.948 11.5295 13.3807 12.0175 12.9194 12.9813C12.0965 14.6987 12.3128 16.8962 13.562 19.5184C13.1121 20.5751 12.6941 21.6706 12.2895 22.7311C11.7861 24.0498 11.2674 25.4103 10.6828 26.7045C9.04334 27.3532 7.69648 28.1399 6.57402 29.1057C5.8387 29.7373 4.95223 30.7028 4.90163 31.7107C4.87693 32.1854 5.03969 32.6207 5.37044 32.9695C5.72183 33.3398 6.16329 33.5348 6.6487 33.5354C8.25189 33.5354 9.79489 31.3327 10.0876 30.8909C10.6767 30.0029 11.2281 29.0124 11.7684 27.8699C13.1292 27.3781 14.5794 27.011 15.985 26.6562L16.4884 26.5283C16.8668 26.4321 17.2601 26.3257 17.6635 26.2153C18.0904 26.0999 18.5296 25.9802 18.976 25.8665C20.4193 26.7844 21.9714 27.3831 23.4851 27.6028C24.7601 27.7883 25.8924 27.6807 26.6589 27.2811C27.3486 26.9219 27.3866 26.3676 27.3706 26.1461ZM30.4755 36.2428C30.4755 38.3932 28.5802 38.5258 28.1978 38.5301H3.74486C1.60224 38.5301 1.47322 36.6218 1.46913 36.2428L1.46884 3.75642C1.46884 1.6039 3.36763 1.4734 3.74457 1.46908H20.263L20.2718 1.4778V7.92396C20.2718 9.21763 21.0539 11.6669 24.0158 11.6669H30.4203L30.4753 11.7218L30.4755 36.2428ZM28.9572 10.1976H24.0169C21.8749 10.1976 21.7453 8.29969 21.7424 7.92417V2.95307L28.9572 10.1976ZM31.9447 36.2428V11.1157L21.7424 0.871022V0.823357H21.6936L20.8742 0H3.74491C2.44954 0 0 0.785336 0 3.75711V36.2435C0 37.5427 0.782956 40 3.74491 40H28.2001C29.4952 39.9997 31.9447 39.2143 31.9447 36.2428Z"/> + <path d="M25.6686 26.0962C25.1812 26.2401 24.4656 26.2563 23.6984 26.145C22.875 26.0256 22.0351 25.7739 21.2096 25.403C22.6817 25.1888 23.8237 25.2548 24.8005 25.6009C25.0319 25.6829 25.412 25.9021 25.6686 26.0962ZM17.4552 24.7459C17.3953 24.7622 17.3363 24.7776 17.2776 24.7939C16.8815 24.9017 16.4961 25.0069 16.1247 25.1005L15.6239 25.2275C14.6165 25.4824 13.5865 25.7428 12.5692 26.0529C12.9558 25.1206 13.315 24.178 13.6667 23.2564C13.9271 22.5742 14.193 21.8773 14.468 21.1894C14.6075 21.4198 14.7531 21.6503 14.9046 21.8814C15.5948 22.9326 16.4624 23.9045 17.4552 24.7459ZM14.8927 14.2326C14.958 15.383 14.7098 16.4897 14.3457 17.5514C13.8972 16.2386 13.6882 14.7889 14.2489 13.6185C14.3927 13.3185 14.5105 13.1581 14.5869 13.0744C14.7049 13.2566 14.8601 13.6642 14.8927 14.2326ZM9.63347 28.8054C9.38148 29.2562 9.12426 29.6782 8.86063 30.0767C8.22442 31.0355 7.18393 32.0621 6.64941 32.0621C6.59681 32.0621 6.53316 32.0536 6.44015 31.9554C6.38028 31.8926 6.37069 31.8476 6.37359 31.7862C6.39161 31.4337 6.85867 30.8059 7.53527 30.2238C8.14939 29.6957 8.84352 29.2262 9.63347 28.8054ZM27.3706 26.1461C27.2889 24.9719 25.3123 24.2186 25.2928 24.2116C24.5287 23.9407 23.6986 23.8091 22.7552 23.8091C21.7453 23.8091 20.6565 23.9552 19.2582 24.2819C18.014 23.3999 16.9392 22.2957 16.1362 21.0733C15.7816 20.5332 15.4628 19.9941 15.1849 19.4675C15.8633 17.8454 16.4742 16.1013 16.3632 14.1479C16.2737 12.5816 15.5674 11.5295 14.6069 11.5295C13.948 11.5295 13.3807 12.0175 12.9194 12.9813C12.0965 14.6987 12.3128 16.8962 13.562 19.5184C13.1121 20.5751 12.6941 21.6706 12.2895 22.7311C11.7861 24.0498 11.2674 25.4103 10.6828 26.7045C9.04334 27.3532 7.69648 28.1399 6.57402 29.1057C5.8387 29.7373 4.95223 30.7028 4.90163 31.7107C4.87693 32.1854 5.03969 32.6207 5.37044 32.9695C5.72183 33.3398 6.16329 33.5348 6.6487 33.5354C8.25189 33.5354 9.79489 31.3327 10.0876 30.8909C10.6767 30.0029 11.2281 29.0124 11.7684 27.8699C13.1292 27.3781 14.5794 27.011 15.985 26.6562L16.4884 26.5283C16.8668 26.4321 17.2601 26.3257 17.6635 26.2153C18.0904 26.0999 18.5296 25.9802 18.976 25.8665C20.4193 26.7844 21.9714 27.3831 23.4851 27.6028C24.7601 27.7883 25.8924 27.6807 26.6589 27.2811C27.3486 26.9219 27.3866 26.3676 27.3706 26.1461ZM30.4755 36.2428C30.4755 38.3932 28.5802 38.5258 28.1978 38.5301H3.74486C1.60224 38.5301 1.47322 36.6218 1.46913 36.2428L1.46884 3.75642C1.46884 1.6039 3.36763 1.4734 3.74457 1.46908H20.263L20.2718 1.4778V7.92396C20.2718 9.21763 21.0539 11.6669 24.0158 11.6669H30.4203L30.4753 11.7218L30.4755 36.2428ZM28.9572 10.1976H24.0169C21.8749 10.1976 21.7453 8.29969 21.7424 7.92417V2.95307L28.9572 10.1976ZM31.9447 36.2428V11.1157L21.7424 0.871022V0.823357H21.6936L20.8742 0H3.74491C2.44954 0 0 0.785336 0 3.75711V36.2435C0 37.5427 0.782956 40 3.74491 40H28.2001C29.4952 39.9997 31.9447 39.2143 31.9447 36.2428Z" /> </svg> ); } @@ -510,3 +510,21 @@ export function GridIcon(props: React.SVGProps<SVGSVGElement>) { </svg> ); } +export function FileSettingsIcon(props: React.SVGProps<SVGSVGElement>) { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 36 36" + fill="currentColor" + className={props.className} + width={props.width || "1.5em"} + height={props.height || "1.5em"} + {...props} + > + <title>file-settings-solid + + + + + ); +} diff --git a/src/components/privacy-popup.tsx b/src/components/privacy-popup.tsx index 24e57cd..8e73f9d 100644 --- a/src/components/privacy-popup.tsx +++ b/src/components/privacy-popup.tsx @@ -11,12 +11,123 @@ import { } from '@headlessui/react'; import { updateAppConfig, getAppConfig } from '@/lib/dexie'; +const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; + interface PrivacyPopupProps { onAccept?: () => void; + authEnabled?: boolean; } -export function PrivacyPopup({ onAccept }: PrivacyPopupProps) { +function PrivacyPopupBody({ + origin, + authEnabled, +}: { + origin: string; + authEnabled: boolean; +}) { + if (!isDev) { + return ( +
+
+
Service operator visibility
+
+ This OpenReader instance is hosted at {origin || 'this server'}. The operator + of this service can access data that reaches the service. +
+
+ +
+
Stored in your browser (IndexedDB)
+
    +
  • Uploaded documents (local library)
  • +
  • Reading progress (last location)
  • +
  • App settings (voice/speed/provider/base URL)
  • +
  • Privacy notice acceptance
  • +
+
+ +
+
Sent to this service
+
    +
  • Text for audio generation and associated metadata
  • +
  • Standard request metadata (e.g. IP address, user agent)
  • +
  • Text is forwarded to a TTS provider (Deepinfra) to generate audio
  • +
  • Some generated audio may be cached server-side to reduce cost/latency
  • +
+
+ +
+
Stored on this service
+
    + {authEnabled ? ( +
  • Auth users data and IP rate limiting data are stored in the service database
  • + ) : ( +
  • Authentication is disabled, so no user/session database is used
  • + )} +
+
+ +
+ This site uses Vercel Analytics to collect anonymous usage data. For maximum privacy, use self-hosted mode. +
+
+ ); + } + + return ( +
+
+
Server owner visibility
+
+ This OpenReader instance is hosted at {origin || 'this server'}. The operator + of this server can access data that reaches the server. +
+
+ +
+
Stored in your browser (IndexedDB)
+
    +
  • Uploaded documents (local library)
  • +
  • Reading progress (last location)
  • +
  • App settings (voice/speed/provider/base URL)
  • +
  • Privacy notice acceptance
  • +
+
+ +
+
Sent to this server
+
    +
  • Text for audio generation and associated metadata
  • +
  • DOCX document upload and conversion only
  • +
  • Your IP address and device ID cookie used for rate limiting
  • +
  • (Optionally) Generated audio for word-by-word timestamps
  • +
  • (Optionally) Your TTS API key so the server can call your TTS provider
  • +
+
+ +
+
Stored on this server
+
    +
  • Documents synced between your browser and this server
  • +
  • Generated audiobooks
  • + {authEnabled ? ( +
  • Auth users data and IP rate limiting data are stored in the server's database
  • + ) : ( +
  • Authentication is disabled on this server, so no server-side user/session database is used
  • + )} +
+
+ +
+ Tip: If you are behind a reverse proxy, the proxy operator may also have access to request logs. +
+
+ ); +} + +export function PrivacyPopup({ onAccept, authEnabled = false }: PrivacyPopupProps) { const [isOpen, setIsOpen] = useState(false); + const [origin, setOrigin] = useState(''); const checkPrivacyAccepted = useCallback(async () => { const config = await getAppConfig(); @@ -29,6 +140,11 @@ export function PrivacyPopup({ onAccept }: PrivacyPopupProps) { checkPrivacyAccepted(); }, [checkPrivacyAccepted]); + useEffect(() => { + if (typeof window === 'undefined') return; + setOrigin(window.location.origin); + }, []); + const handleAccept = async () => { await updateAppConfig({ privacyAccepted: true }); setIsOpen(false); @@ -72,21 +188,7 @@ export function PrivacyPopup({ onAccept }: PrivacyPopupProps) { Privacy & Data Usage -
-

Documents are uploaded to your local browser cache.

-

- Each paragraph of the document you are viewing is sent to Deepinfra - for audio generation through a Vercel backend proxy, containing a - shared caching pool. -

-

The audio is streamed back to your browser and played in real-time.

-

- Self-hosting is the recommended way to use this app for a truly secure experience. -

-

- This site uses Vercel Analytics to collect anonymous usage data to help improve the service. -

-
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/components/auth/UserMenu.tsx b/src/components/auth/UserMenu.tsx index 77a7ff5..b6607a5 100644 --- a/src/components/auth/UserMenu.tsx +++ b/src/components/auth/UserMenu.tsx @@ -43,19 +43,14 @@ export function UserMenu({ className = '' }: { className?: string }) { } return ( -
-
- - {session.user.name || session.user.email || 'Account'} - - - {session.user.email} - -
+
+ + {session.user.email || 'Account'} + {/* GitHub */} @@ -241,17 +225,17 @@ function SignInContent() { )} - {/* Guest */} + {/* Anonymous */}
@@ -266,7 +250,7 @@ function SignInContent() {

By signing in, you agree to our{' '}

@@ -847,7 +843,7 @@ export function SettingsModal({ className = '' }: { className?: string }) { focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 transform transition-transform duration-200 ease-in-out hover:scale-[1.02]" > - Sign Out + Disconnect account
@@ -874,12 +870,12 @@ export function SettingsModal({ className = '' }: { className?: string }) {
diff --git a/src/components/auth/AuthLoader.tsx b/src/components/auth/AuthLoader.tsx index 24d5137..3aca979 100644 --- a/src/components/auth/AuthLoader.tsx +++ b/src/components/auth/AuthLoader.tsx @@ -1,8 +1,8 @@ 'use client'; -import { useEffect, useState, ReactNode } from 'react'; +import { useEffect, useRef, useState, ReactNode } from 'react'; import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; -import { useAuthSession } from '@/hooks/useAuth'; +import { useAuthSession } from '@/hooks/useAuthSession'; import { getAuthClient } from '@/lib/auth-client'; import { LoadingSpinner } from '@/components/Spinner'; @@ -11,29 +11,35 @@ export function AuthLoader({ children }: { children: ReactNode }) { const { refresh: refreshRateLimit } = useAuthRateLimit(); const { data: session, isPending } = useAuthSession(); const [isAutoLoggingIn, setIsAutoLoggingIn] = useState(false); - const [isCheckingSignOut, setIsCheckingSignOut] = useState(true); + const [bootstrapError, setBootstrapError] = useState(null); + const [retryNonce, setRetryNonce] = useState(0); + const attemptedForNullSessionRef = useRef(false); + + // If the auth base URL changes, re-run the bootstrap logic. + useEffect(() => { + attemptedForNullSessionRef.current = false; + setBootstrapError(null); + }, [authEnabled, baseUrl]); useEffect(() => { - // Determine if we need to check sign-out status or proceed + // This app does not have a real "signed out" state when auth is enabled. + // If we ever observe "no session", we immediately start an anonymous session. const checkStatus = async () => { - // If auth is disabled, stop checking immediately - if (!authEnabled) { - setIsCheckingSignOut(false); - return; - } - - // If session is still loading, wait + if (!authEnabled) return; if (isPending) return; - // If we have a session, we are done checking if (session) { - setIsCheckingSignOut(false); + attemptedForNullSessionRef.current = false; + setBootstrapError(null); return; } - // Not signed out, start auto-login + // Avoid double-calling anonymous sign-in (e.g. React strict mode). + if (attemptedForNullSessionRef.current) return; + attemptedForNullSessionRef.current = true; + setIsAutoLoggingIn(true); - setIsCheckingSignOut(false); // Stop checking sign-out, now in auto-login mode + setBootstrapError(null); try { const client = getAuthClient(baseUrl); @@ -41,27 +47,44 @@ export function AuthLoader({ children }: { children: ReactNode }) { await refreshRateLimit(); } catch (err) { console.error('Auto-login failed', err); + setBootstrapError('Unable to start an anonymous session.'); } finally { setIsAutoLoggingIn(false); } }; checkStatus(); - }, [session, isPending, authEnabled, baseUrl, refreshRateLimit]); + }, [session, isPending, authEnabled, baseUrl, refreshRateLimit, retryNonce]); // Show loader if: // 1. Auth client is initializing (isPending) AND auth is enabled - // 2. We are checking the sign-out status (Dexie) - // 3. We are actively auto-logging in - const isLoading = (isPending && authEnabled) || isCheckingSignOut || isAutoLoggingIn; + // 2. We are actively creating an anonymous session + const isLoading = authEnabled && (isPending || isAutoLoggingIn || !session); if (isLoading) { return (
-

- {isAutoLoggingIn ? 'Logging in anonymously...' : 'Loading...'} -

+ {bootstrapError ? ( +
+

{bootstrapError}

+ +
+ ) : ( +

+ {isAutoLoggingIn ? 'Starting anonymous session...' : 'Loading...'} +

+ )}
); } diff --git a/src/components/ClaimDataModal.tsx b/src/components/auth/ClaimDataModal.tsx similarity index 98% rename from src/components/ClaimDataModal.tsx rename to src/components/auth/ClaimDataModal.tsx index fb42cf2..0539ca6 100644 --- a/src/components/ClaimDataModal.tsx +++ b/src/components/auth/ClaimDataModal.tsx @@ -9,7 +9,7 @@ import { TransitionChild, Button, } from '@headlessui/react'; -import { useAuthSession } from '@/hooks/useAuth'; +import { useAuthSession } from '@/hooks/useAuthSession'; import { useRouter } from 'next/navigation'; export default function ClaimDataModal() { diff --git a/src/components/rate-limit-banner.tsx b/src/components/auth/RateLimitBanner.tsx similarity index 100% rename from src/components/rate-limit-banner.tsx rename to src/components/auth/RateLimitBanner.tsx diff --git a/src/components/auth/UserMenu.tsx b/src/components/auth/UserMenu.tsx index d6de035..f37fdaa 100644 --- a/src/components/auth/UserMenu.tsx +++ b/src/components/auth/UserMenu.tsx @@ -2,26 +2,24 @@ import { Button } from '@headlessui/react'; import Link from 'next/link'; -import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; -import { useAuthSession } from '@/hooks/useAuth'; +import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; +import { useAuthSession } from '@/hooks/useAuthSession'; import { getAuthClient } from '@/lib/auth-client'; -import { clearSignedOut } from '@/lib/session-utils'; import { useRouter } from 'next/navigation'; export function UserMenu({ className = '' }: { className?: string }) { const { authEnabled, baseUrl } = useAuthConfig(); - const { refresh: refreshRateLimit } = useAuthRateLimit(); const { data: session, isPending } = useAuthSession(); const router = useRouter(); if (!authEnabled || isPending) return null; - const handleSignOut = async () => { + const handleDisconnectAccount = async () => { const client = getAuthClient(baseUrl); + // "Sign out" here means: end the email/social session and immediately + // start a fresh anonymous session. The app should never be left without a session. await client.signOut(); - await clearSignedOut(); - await client.signIn.anonymous(); - await refreshRateLimit(); + // AuthLoader will create the next anonymous session. router.refresh(); }; @@ -30,12 +28,12 @@ export function UserMenu({ className = '' }: { className?: string }) {
@@ -49,9 +47,9 @@ export function UserMenu({ className = '' }: { className?: string }) {
); } - diff --git a/src/components/EPUBViewer.tsx b/src/components/views/EPUBViewer.tsx similarity index 100% rename from src/components/EPUBViewer.tsx rename to src/components/views/EPUBViewer.tsx diff --git a/src/components/HTMLViewer.tsx b/src/components/views/HTMLViewer.tsx similarity index 100% rename from src/components/HTMLViewer.tsx rename to src/components/views/HTMLViewer.tsx diff --git a/src/components/PDFViewer.tsx b/src/components/views/PDFViewer.tsx similarity index 100% rename from src/components/PDFViewer.tsx rename to src/components/views/PDFViewer.tsx diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 2006e4e..fa42b22 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -38,7 +38,7 @@ import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/dexie'; import { useBackgroundState } from '@/hooks/audio/useBackgroundState'; import { withRetry, generateTTS, alignAudio } from '@/lib/client'; import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksEPUB } from '@/lib/nlp'; -import { isKokoroModel } from '@/utils/voice'; +import { isKokoroModel } from '@/lib/kokoro'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import type { TTSLocation, diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuthSession.ts similarity index 100% rename from src/hooks/useAuth.ts rename to src/hooks/useAuthSession.ts diff --git a/src/utils/voice.ts b/src/lib/kokoro.ts similarity index 92% rename from src/utils/voice.ts rename to src/lib/kokoro.ts index ba1852d..e8f419c 100644 --- a/src/utils/voice.ts +++ b/src/lib/kokoro.ts @@ -1,8 +1,7 @@ /** - * Voice Utilities - * - * This module provides utilities for handling voice selection and management, - * particularly for Kokoro multi-voice syntax. + * Kokoro Utilities + * + * Utilities for handling Kokoro multi-voice syntax. */ /** diff --git a/src/lib/session-utils.ts b/src/lib/session-utils.ts deleted file mode 100644 index 4536fae..0000000 --- a/src/lib/session-utils.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Session utilities using Dexie for persistence -// Used by auth components for sign-out state tracking - -import { updateAppConfig, getAppConfig } from '@/lib/dexie'; - -/** - * Check if user was explicitly signed out (for showing appropriate message on signin page) - */ -export async function wasSignedOut(): Promise { - const config = await getAppConfig(); - return config?.signedOut ?? false; -} - -/** - * Clear the signed-out flag after displaying the message - */ -export async function clearSignedOut(): Promise { - await updateAppConfig({ signedOut: false }); -} - -/** - * Mark that the user explicitly signed out - */ -export async function markSignedOut(): Promise { - await updateAppConfig({ signedOut: true }); -} diff --git a/src/types/config.ts b/src/types/config.ts index 6a39db3..2491302 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -30,7 +30,6 @@ export interface AppConfigValues { epubWordHighlightEnabled: boolean; firstVisit: boolean; documentListState: DocumentListState; - signedOut: boolean; privacyAccepted: boolean; } @@ -65,7 +64,6 @@ export const APP_CONFIG_DEFAULTS: AppConfigValues = { showHint: true, viewMode: 'grid', }, - signedOut: false, privacyAccepted: false, }; diff --git a/tests/helpers.ts b/tests/helpers.ts index c29b54c..3121469 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -166,7 +166,7 @@ export async function setupTest(page: Page) { // If auth is enabled, establish an anonymous session BEFORE navigation. // This keeps each test self-contained (no shared storageState) while ensuring // server routes that require auth don't intermittently 401 during app startup. - await ensureAnonymousSession(page); + // await ensureAnonymousSession(page); // Navigate to the home page before each test await page.goto('/'); From c24710b2cade9b48984f483e6e701dcddaa0dfb2 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 3 Feb 2026 12:17:06 -0700 Subject: [PATCH 13/55] refactor(audiobooks): implement user-specific storage with composite primary keys - Change audiobooks and audiobookChapters tables to use composite PK (id, userId) - Migrate audiobooks from flat storage to user-specific directories under audiobooks_users - Add support for claiming unclaimed audiobooks on account creation - Improve auth rate limiting with retry logic and DISABLE_AUTH_RATE_LIMIT for tests - Fix iOS/Safari audio playback with unlock mechanism and playback rate watchdog - Update API routes to handle user-scoped audiobook access with fallback to unclaimed - Transfer audiobooks when linking anonymous accounts to real accounts - Remove foreign key constraint from audiobookChapters to support composite PK - Add cascade delete to account and session foreign keys --- ...itary_nemesis.sql => 0000_lucky_zarek.sql} | 21 +- drizzle/postgres/0001_futuristic_harpoon.sql | 6 - drizzle/postgres/meta/0000_snapshot.json | 48 +- drizzle/postgres/meta/0001_snapshot.json | 592 ----------------- drizzle/postgres/meta/_journal.json | 13 +- ...ard_mandarin.sql => 0000_steep_shaman.sql} | 17 +- drizzle/sqlite/0001_violet_mantis.sql | 38 -- drizzle/sqlite/meta/0000_snapshot.json | 46 +- drizzle/sqlite/meta/0001_snapshot.json | 621 ------------------ drizzle/sqlite/meta/_journal.json | 13 +- playwright.config.ts | 3 +- src/app/api/audiobook/chapter/route.ts | 96 ++- src/app/api/audiobook/route.ts | 112 ++-- src/app/api/audiobook/status/route.ts | 65 +- src/components/auth/AuthLoader.tsx | 152 ++++- src/contexts/TTSContext.tsx | 207 +++++- src/db/schema_postgres.ts | 18 +- src/db/schema_sqlite.ts | 18 +- src/hooks/useAuthSession.ts | 15 +- src/lib/server/auth.ts | 48 +- src/lib/server/claim-data.ts | 236 +++++-- src/lib/server/docstore.ts | 97 +++ src/lib/server/rate-limiter.ts | 91 +-- tests/export.spec.ts | 615 ++++++++++------- tests/helpers.ts | 44 -- 25 files changed, 1369 insertions(+), 1863 deletions(-) rename drizzle/postgres/{0000_military_nemesis.sql => 0000_lucky_zarek.sql} (83%) delete mode 100644 drizzle/postgres/0001_futuristic_harpoon.sql delete mode 100644 drizzle/postgres/meta/0001_snapshot.json rename drizzle/sqlite/{0000_hard_mandarin.sql => 0000_steep_shaman.sql} (91%) delete mode 100644 drizzle/sqlite/0001_violet_mantis.sql delete mode 100644 drizzle/sqlite/meta/0001_snapshot.json diff --git a/drizzle/postgres/0000_military_nemesis.sql b/drizzle/postgres/0000_lucky_zarek.sql similarity index 83% rename from drizzle/postgres/0000_military_nemesis.sql rename to drizzle/postgres/0000_lucky_zarek.sql index f40f596..2c24b6d 100644 --- a/drizzle/postgres/0000_military_nemesis.sql +++ b/drizzle/postgres/0000_lucky_zarek.sql @@ -15,25 +15,27 @@ CREATE TABLE "account" ( ); --> statement-breakpoint CREATE TABLE "audiobook_chapters" ( - "id" text PRIMARY KEY NOT NULL, + "id" text NOT NULL, "book_id" text NOT NULL, - "user_id" text, + "user_id" text NOT NULL, "chapter_index" integer NOT NULL, "title" text NOT NULL, "duration" real DEFAULT 0, "file_path" text NOT NULL, - "format" text NOT NULL + "format" text NOT NULL, + CONSTRAINT "audiobook_chapters_id_user_id_pk" PRIMARY KEY("id","user_id") ); --> statement-breakpoint CREATE TABLE "audiobooks" ( - "id" text PRIMARY KEY NOT NULL, - "user_id" text, + "id" text NOT NULL, + "user_id" text NOT NULL, "title" text NOT NULL, "author" text, "description" text, "cover_path" text, "duration" real DEFAULT 0, - "created_at" timestamp DEFAULT now() + "created_at" timestamp DEFAULT now(), + CONSTRAINT "audiobooks_id_user_id_pk" PRIMARY KEY("id","user_id") ); --> statement-breakpoint CREATE TABLE "documents" ( @@ -88,7 +90,6 @@ CREATE TABLE "verification" ( "updated_at" timestamp ); --> statement-breakpoint -ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "audiobook_chapters" ADD CONSTRAINT "audiobook_chapters_book_id_audiobooks_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."audiobooks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -CREATE INDEX "idx_user_tts_chars_date" ON "user_tts_chars" USING btree ("date"); +ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_user_tts_chars_date" ON "user_tts_chars" USING btree ("date"); \ No newline at end of file diff --git a/drizzle/postgres/0001_futuristic_harpoon.sql b/drizzle/postgres/0001_futuristic_harpoon.sql deleted file mode 100644 index d6354ee..0000000 --- a/drizzle/postgres/0001_futuristic_harpoon.sql +++ /dev/null @@ -1,6 +0,0 @@ -ALTER TABLE "account" DROP CONSTRAINT "account_user_id_user_id_fk"; ---> statement-breakpoint -ALTER TABLE "session" DROP CONSTRAINT "session_user_id_user_id_fk"; ---> statement-breakpoint -ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; diff --git a/drizzle/postgres/meta/0000_snapshot.json b/drizzle/postgres/meta/0000_snapshot.json index 90b979e..04b9ece 100644 --- a/drizzle/postgres/meta/0000_snapshot.json +++ b/drizzle/postgres/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "5ac6552c-824b-4b79-8cb6-f0ec3d121031", + "id": "63a00f5b-69ff-4d95-a48a-9e84cc331445", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -99,7 +99,7 @@ "columnsTo": [ "id" ], - "onDelete": "no action", + "onDelete": "cascade", "onUpdate": "no action" } }, @@ -116,7 +116,7 @@ "id": { "name": "id", "type": "text", - "primaryKey": true, + "primaryKey": false, "notNull": true }, "book_id": { @@ -129,7 +129,7 @@ "name": "user_id", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, "chapter_index": { "name": "chapter_index", @@ -164,22 +164,16 @@ } }, "indexes": {}, - "foreignKeys": { - "audiobook_chapters_book_id_audiobooks_id_fk": { - "name": "audiobook_chapters_book_id_audiobooks_id_fk", - "tableFrom": "audiobook_chapters", - "tableTo": "audiobooks", - "columnsFrom": [ - "book_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" + "foreignKeys": {}, + "compositePrimaryKeys": { + "audiobook_chapters_id_user_id_pk": { + "name": "audiobook_chapters_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] } }, - "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, @@ -192,14 +186,14 @@ "id": { "name": "id", "type": "text", - "primaryKey": true, + "primaryKey": false, "notNull": true }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, "title": { "name": "title", @@ -242,7 +236,15 @@ }, "indexes": {}, "foreignKeys": {}, - "compositePrimaryKeys": {}, + "compositePrimaryKeys": { + "audiobooks_id_user_id_pk": { + "name": "audiobooks_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, @@ -383,7 +385,7 @@ "columnsTo": [ "id" ], - "onDelete": "no action", + "onDelete": "cascade", "onUpdate": "no action" } }, @@ -589,4 +591,4 @@ "schemas": {}, "tables": {} } -} +} \ No newline at end of file diff --git a/drizzle/postgres/meta/0001_snapshot.json b/drizzle/postgres/meta/0001_snapshot.json deleted file mode 100644 index adf5b27..0000000 --- a/drizzle/postgres/meta/0001_snapshot.json +++ /dev/null @@ -1,592 +0,0 @@ -{ - "id": "62e82369-d624-4cc1-a482-7ea5fbc7275b", - "prevId": "5ac6552c-824b-4b79-8cb6-f0ec3d121031", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.account": { - "name": "account", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "account_id": { - "name": "account_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "access_token": { - "name": "access_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "refresh_token": { - "name": "refresh_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "id_token": { - "name": "id_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "access_token_expires_at": { - "name": "access_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "refresh_token_expires_at": { - "name": "refresh_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "account_user_id_user_id_fk": { - "name": "account_user_id_user_id_fk", - "tableFrom": "account", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.audiobook_chapters": { - "name": "audiobook_chapters", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "book_id": { - "name": "book_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "chapter_index": { - "name": "chapter_index", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "duration": { - "name": "duration", - "type": "real", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "file_path": { - "name": "file_path", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "format": { - "name": "format", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "audiobook_chapters_book_id_audiobooks_id_fk": { - "name": "audiobook_chapters_book_id_audiobooks_id_fk", - "tableFrom": "audiobook_chapters", - "tableTo": "audiobooks", - "columnsFrom": [ - "book_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.audiobooks": { - "name": "audiobooks", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "author": { - "name": "author", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "cover_path": { - "name": "cover_path", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "duration": { - "name": "duration", - "type": "real", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.documents": { - "name": "documents", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "size": { - "name": "size", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "last_modified": { - "name": "last_modified", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "file_path": { - "name": "file_path", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "documents_id_user_id_pk": { - "name": "documents_id_user_id_pk", - "columns": [ - "id", - "user_id" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.session": { - "name": "session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "ip_address": { - "name": "ip_address", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_agent": { - "name": "user_agent", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "session_user_id_user_id_fk": { - "name": "session_user_id_user_id_fk", - "tableFrom": "session", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "session_token_unique": { - "name": "session_token_unique", - "nullsNotDistinct": false, - "columns": [ - "token" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user": { - "name": "user", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email_verified": { - "name": "email_verified", - "type": "boolean", - "primaryKey": false, - "notNull": true - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "is_anonymous": { - "name": "is_anonymous", - "type": "boolean", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "user_email_unique": { - "name": "user_email_unique", - "nullsNotDistinct": false, - "columns": [ - "email" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user_tts_chars": { - "name": "user_tts_chars", - "schema": "", - "columns": { - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "date": { - "name": "date", - "type": "date", - "primaryKey": false, - "notNull": true - }, - "char_count": { - "name": "char_count", - "type": "bigint", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "indexes": { - "idx_user_tts_chars_date": { - "name": "idx_user_tts_chars_date", - "columns": [ - { - "expression": "date", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "user_tts_chars_user_id_date_pk": { - "name": "user_tts_chars_user_id_date_pk", - "columns": [ - "user_id", - "date" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.verification": { - "name": "verification", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "identifier": { - "name": "identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/drizzle/postgres/meta/_journal.json b/drizzle/postgres/meta/_journal.json index 7b605fa..7b112b7 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -5,16 +5,9 @@ { "idx": 0, "version": "7", - "when": 1769393893675, - "tag": "0000_military_nemesis", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1769404444945, - "tag": "0001_futuristic_harpoon", + "when": 1769641576464, + "tag": "0000_lucky_zarek", "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/drizzle/sqlite/0000_hard_mandarin.sql b/drizzle/sqlite/0000_steep_shaman.sql similarity index 91% rename from drizzle/sqlite/0000_hard_mandarin.sql rename to drizzle/sqlite/0000_steep_shaman.sql index fbea2df..190de61 100644 --- a/drizzle/sqlite/0000_hard_mandarin.sql +++ b/drizzle/sqlite/0000_steep_shaman.sql @@ -12,30 +12,31 @@ CREATE TABLE `account` ( `password` text, `created_at` integer NOT NULL, `updated_at` integer NOT NULL, - FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade ); --> statement-breakpoint CREATE TABLE `audiobook_chapters` ( - `id` text PRIMARY KEY NOT NULL, + `id` text NOT NULL, `book_id` text NOT NULL, - `user_id` text, + `user_id` text NOT NULL, `chapter_index` integer NOT NULL, `title` text NOT NULL, `duration` real DEFAULT 0, `file_path` text NOT NULL, `format` text NOT NULL, - FOREIGN KEY (`book_id`) REFERENCES `audiobooks`(`id`) ON UPDATE no action ON DELETE cascade + PRIMARY KEY(`id`, `user_id`) ); --> statement-breakpoint CREATE TABLE `audiobooks` ( - `id` text PRIMARY KEY NOT NULL, - `user_id` text, + `id` text NOT NULL, + `user_id` text NOT NULL, `title` text NOT NULL, `author` text, `description` text, `cover_path` text, `duration` real DEFAULT 0, - `created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000) + `created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000), + PRIMARY KEY(`id`, `user_id`) ); --> statement-breakpoint CREATE TABLE `documents` ( @@ -59,7 +60,7 @@ CREATE TABLE `session` ( `ip_address` text, `user_agent` text, `user_id` text NOT NULL, - FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE no action + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade ); --> statement-breakpoint CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint diff --git a/drizzle/sqlite/0001_violet_mantis.sql b/drizzle/sqlite/0001_violet_mantis.sql deleted file mode 100644 index 5051a39..0000000 --- a/drizzle/sqlite/0001_violet_mantis.sql +++ /dev/null @@ -1,38 +0,0 @@ -PRAGMA foreign_keys=OFF;--> statement-breakpoint -CREATE TABLE `__new_account` ( - `id` text PRIMARY KEY NOT NULL, - `account_id` text NOT NULL, - `provider_id` text NOT NULL, - `user_id` text NOT NULL, - `access_token` text, - `refresh_token` text, - `id_token` text, - `access_token_expires_at` integer, - `refresh_token_expires_at` integer, - `scope` text, - `password` text, - `created_at` integer NOT NULL, - `updated_at` integer NOT NULL, - FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -INSERT INTO `__new_account`("id", "account_id", "provider_id", "user_id", "access_token", "refresh_token", "id_token", "access_token_expires_at", "refresh_token_expires_at", "scope", "password", "created_at", "updated_at") SELECT "id", "account_id", "provider_id", "user_id", "access_token", "refresh_token", "id_token", "access_token_expires_at", "refresh_token_expires_at", "scope", "password", "created_at", "updated_at" FROM `account`;--> statement-breakpoint -DROP TABLE `account`;--> statement-breakpoint -ALTER TABLE `__new_account` RENAME TO `account`;--> statement-breakpoint -PRAGMA foreign_keys=ON;--> statement-breakpoint -CREATE TABLE `__new_session` ( - `id` text PRIMARY KEY NOT NULL, - `expires_at` integer NOT NULL, - `token` text NOT NULL, - `created_at` integer NOT NULL, - `updated_at` integer NOT NULL, - `ip_address` text, - `user_agent` text, - `user_id` text NOT NULL, - FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -INSERT INTO `__new_session`("id", "expires_at", "token", "created_at", "updated_at", "ip_address", "user_agent", "user_id") SELECT "id", "expires_at", "token", "created_at", "updated_at", "ip_address", "user_agent", "user_id" FROM `session`;--> statement-breakpoint -DROP TABLE `session`;--> statement-breakpoint -ALTER TABLE `__new_session` RENAME TO `session`;--> statement-breakpoint -CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`); diff --git a/drizzle/sqlite/meta/0000_snapshot.json b/drizzle/sqlite/meta/0000_snapshot.json index a159691..ab33249 100644 --- a/drizzle/sqlite/meta/0000_snapshot.json +++ b/drizzle/sqlite/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "f5c06478-a702-4809-ab9b-3a63f11b8b0c", + "id": "af57dff3-a6ec-418e-9fa1-8197d6839e48", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "account": { @@ -111,7 +111,7 @@ "columnsTo": [ "id" ], - "onDelete": "no action", + "onDelete": "cascade", "onUpdate": "no action" } }, @@ -125,7 +125,7 @@ "id": { "name": "id", "type": "text", - "primaryKey": true, + "primaryKey": false, "notNull": true, "autoincrement": false }, @@ -140,7 +140,7 @@ "name": "user_id", "type": "text", "primaryKey": false, - "notNull": false, + "notNull": true, "autoincrement": false }, "chapter_index": { @@ -181,22 +181,16 @@ } }, "indexes": {}, - "foreignKeys": { - "audiobook_chapters_book_id_audiobooks_id_fk": { - "name": "audiobook_chapters_book_id_audiobooks_id_fk", - "tableFrom": "audiobook_chapters", - "tableTo": "audiobooks", - "columnsFrom": [ - "book_id" + "foreignKeys": {}, + "compositePrimaryKeys": { + "audiobook_chapters_id_user_id_pk": { + "columns": [ + "id", + "user_id" ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" + "name": "audiobook_chapters_id_user_id_pk" } }, - "compositePrimaryKeys": {}, "uniqueConstraints": {}, "checkConstraints": {} }, @@ -206,7 +200,7 @@ "id": { "name": "id", "type": "text", - "primaryKey": true, + "primaryKey": false, "notNull": true, "autoincrement": false }, @@ -214,7 +208,7 @@ "name": "user_id", "type": "text", "primaryKey": false, - "notNull": false, + "notNull": true, "autoincrement": false }, "title": { @@ -264,7 +258,15 @@ }, "indexes": {}, "foreignKeys": {}, - "compositePrimaryKeys": {}, + "compositePrimaryKeys": { + "audiobooks_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "audiobooks_id_user_id_pk" + } + }, "uniqueConstraints": {}, "checkConstraints": {} }, @@ -423,7 +425,7 @@ "columnsTo": [ "id" ], - "onDelete": "no action", + "onDelete": "cascade", "onUpdate": "no action" } }, @@ -618,4 +620,4 @@ "internal": { "indexes": {} } -} +} \ No newline at end of file diff --git a/drizzle/sqlite/meta/0001_snapshot.json b/drizzle/sqlite/meta/0001_snapshot.json deleted file mode 100644 index bb004f5..0000000 --- a/drizzle/sqlite/meta/0001_snapshot.json +++ /dev/null @@ -1,621 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "8d8679bc-1bba-44ce-9f87-271c1eafe068", - "prevId": "f5c06478-a702-4809-ab9b-3a63f11b8b0c", - "tables": { - "account": { - "name": "account", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "account_id": { - "name": "account_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "access_token": { - "name": "access_token", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "refresh_token": { - "name": "refresh_token", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "id_token": { - "name": "id_token", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "access_token_expires_at": { - "name": "access_token_expires_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "refresh_token_expires_at": { - "name": "refresh_token_expires_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "account_user_id_user_id_fk": { - "name": "account_user_id_user_id_fk", - "tableFrom": "account", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "audiobook_chapters": { - "name": "audiobook_chapters", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "book_id": { - "name": "book_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "chapter_index": { - "name": "chapter_index", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "duration": { - "name": "duration", - "type": "real", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": 0 - }, - "file_path": { - "name": "file_path", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "format": { - "name": "format", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "audiobook_chapters_book_id_audiobooks_id_fk": { - "name": "audiobook_chapters_book_id_audiobooks_id_fk", - "tableFrom": "audiobook_chapters", - "tableTo": "audiobooks", - "columnsFrom": [ - "book_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "audiobooks": { - "name": "audiobooks", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "author": { - "name": "author", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "cover_path": { - "name": "cover_path", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "duration": { - "name": "duration", - "type": "real", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": "(cast(strftime('%s','now') as int) * 1000)" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "documents": { - "name": "documents", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "size": { - "name": "size", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "last_modified": { - "name": "last_modified", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "file_path": { - "name": "file_path", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": "(cast(strftime('%s','now') as int) * 1000)" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "documents_id_user_id_pk": { - "columns": [ - "id", - "user_id" - ], - "name": "documents_id_user_id_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "session": { - "name": "session", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "ip_address": { - "name": "ip_address", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "user_agent": { - "name": "user_agent", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "session_token_unique": { - "name": "session_token_unique", - "columns": [ - "token" - ], - "isUnique": true - } - }, - "foreignKeys": { - "session_user_id_user_id_fk": { - "name": "session_user_id_user_id_fk", - "tableFrom": "session", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "user": { - "name": "user", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "email_verified": { - "name": "email_verified", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "is_anonymous": { - "name": "is_anonymous", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "user_email_unique": { - "name": "user_email_unique", - "columns": [ - "email" - ], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "user_tts_chars": { - "name": "user_tts_chars", - "columns": { - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "date": { - "name": "date", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "char_count": { - "name": "char_count", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": "(cast(strftime('%s','now') as int) * 1000)" - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": "(cast(strftime('%s','now') as int) * 1000)" - } - }, - "indexes": { - "idx_user_tts_chars_date": { - "name": "idx_user_tts_chars_date", - "columns": [ - "date" - ], - "isUnique": false - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": { - "user_tts_chars_user_id_date_pk": { - "columns": [ - "user_id", - "date" - ], - "name": "user_tts_chars_user_id_date_pk" - } - }, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "verification": { - "name": "verification", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "identifier": { - "name": "identifier", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} diff --git a/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json index 5f8b89b..cb28033 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -5,16 +5,9 @@ { "idx": 0, "version": "6", - "when": 1769395269830, - "tag": "0000_hard_mandarin", - "breakpoints": true - }, - { - "idx": 1, - "version": "6", - "when": 1769535439728, - "tag": "0001_violet_mantis", + "when": 1769641566451, + "tag": "0000_steep_shaman", "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts index 877a6a7..c36eec1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -26,7 +26,8 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { - command: 'npm run build && npm run start', + // Disable auth rate limiting for tests to support parallel workers creating sessions + command: 'npm run build && DISABLE_AUTH_RATE_LIMIT=true npm run start', url: 'http://localhost:3003', reuseExistingServer: !process.env.CI, timeout: 120 * 1000, diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index 8f09cd8..609d352 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -1,21 +1,43 @@ import { NextRequest, NextResponse } from 'next/server'; import { createReadStream, existsSync } from 'fs'; import { readdir, unlink } from 'fs/promises'; -import { join } from 'path'; -import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore'; +import { join, resolve } from 'path'; +import { AUDIOBOOKS_V1_DIR, getUserAudiobookDir, UNCLAIMED_USER_ID, isAudiobooksV1Ready } from '@/lib/server/docstore'; import { findStoredChapterByIndex } from '@/lib/server/audiobook'; import { db } from '@/db'; -import { audiobookChapters } from '@/db/schema'; -import { and, eq } from 'drizzle-orm'; -import { requireAuthContext, requireAudiobookOwned } from '@/lib/server/auth'; +import { audiobooks, audiobookChapters } from '@/db/schema'; +import { and, eq, or } from 'drizzle-orm'; +import { requireAuthContext } from '@/lib/server/auth'; export const dynamic = 'force-dynamic'; -function getAudiobooksRootDir(request: NextRequest): string { +/** + * Get the base audiobooks directory, accounting for test namespaces. + * When auth is disabled, returns AUDIOBOOKS_V1_DIR. + * When auth is enabled, returns the user-specific directory. + */ +function getAudiobooksRootDir(request: NextRequest, userId: string | null, authEnabled: boolean): string { const raw = request.headers.get('x-openreader-test-namespace')?.trim(); - if (!raw) return AUDIOBOOKS_V1_DIR; - const safe = raw.replace(/[^a-zA-Z0-9._-]/g, ''); - return safe ? join(AUDIOBOOKS_V1_DIR, safe) : AUDIOBOOKS_V1_DIR; + + const getTestNamespacePath = (baseDir: string): string => { + if (!raw) return baseDir; + const safe = raw.replace(/[^a-zA-Z0-9._-]/g, ''); + if (!safe || safe === '.' || safe === '..' || safe.includes('..')) { + return baseDir; + } + const resolved = resolve(baseDir, safe); + if (!resolved.startsWith(resolve(baseDir) + '/')) { + return baseDir; + } + return resolved; + }; + + if (!authEnabled || !userId) { + return getTestNamespacePath(AUDIOBOOKS_V1_DIR); + } + + const userDir = getUserAudiobookDir(userId); + return getTestNamespacePath(userDir); } export async function GET(request: NextRequest) { @@ -47,12 +69,31 @@ export async function GET(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; - if (ctxOrRes.authEnabled) { - const denied = await requireAudiobookOwned(bookId, ctxOrRes.userId!, { onDenied: 'notFound' }); - if (denied) return denied; + const { userId, authEnabled } = ctxOrRes; + + // Verify ownership with composite PK - allow access to user's own OR unclaimed audiobooks + if (authEnabled && db && userId) { + const [existingBook] = await db.select().from(audiobooks).where( + and( + eq(audiobooks.id, bookId), + or(eq(audiobooks.userId, userId), eq(audiobooks.userId, UNCLAIMED_USER_ID)) + ) + ); + if (!existingBook) { + return NextResponse.json({ error: 'Book not found' }, { status: 404 }); + } } - const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); + // Get the audiobook directory - check user's directory first, then unclaimed + let intermediateDir = join(getAudiobooksRootDir(request, userId, authEnabled), `${bookId}-audiobook`); + + // If not found in user's directory and auth is enabled, check unclaimed directory + if (!existsSync(intermediateDir) && authEnabled && userId) { + const unclaimedDir = join(getAudiobooksRootDir(request, UNCLAIMED_USER_ID, authEnabled), `${bookId}-audiobook`); + if (existsSync(unclaimedDir)) { + intermediateDir = unclaimedDir; + } + } const chapter = await findStoredChapterByIndex(intermediateDir, chapterIndex, request.signal); if (!chapter || !existsSync(chapter.filePath)) { @@ -128,20 +169,28 @@ export async function DELETE(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; + const { userId, authEnabled } = ctxOrRes; - if (ctxOrRes.authEnabled) { - const denied = await requireAudiobookOwned(bookId, ctxOrRes.userId!, { onDenied: 'forbidden' }); - if (denied) return denied; - - // Delete from DB first - if (db) { - await db.delete(audiobookChapters).where( - and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.chapterIndex, chapterIndex)), - ); + // Verify ownership and delete from DB with composite PK + if (authEnabled && db && userId) { + const [existingBook] = await db.select().from(audiobooks).where( + and(eq(audiobooks.id, bookId), eq(audiobooks.userId, userId)) + ); + if (!existingBook) { + return NextResponse.json({ error: 'Book not found' }, { status: 404 }); } + + // Delete from DB + await db.delete(audiobookChapters).where( + and( + eq(audiobookChapters.bookId, bookId), + eq(audiobookChapters.userId, userId), + eq(audiobookChapters.chapterIndex, chapterIndex) + ), + ); } - const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); + const intermediateDir = join(getAudiobooksRootDir(request, userId, authEnabled), `${bookId}-audiobook`); const chapterPrefix = `${String(chapterIndex + 1).padStart(4, '0')}__`; const files = await readdir(intermediateDir).catch(() => []); for (const file of files) { @@ -167,4 +216,3 @@ export async function DELETE(request: NextRequest) { ); } } - diff --git a/src/app/api/audiobook/route.ts b/src/app/api/audiobook/route.ts index c04834c..5abfafe 100644 --- a/src/app/api/audiobook/route.ts +++ b/src/app/api/audiobook/route.ts @@ -4,32 +4,51 @@ import { readFile, writeFile, mkdir, unlink, rm, rename, readdir } from 'fs/prom import { existsSync, createReadStream } from 'fs'; import { basename, join, resolve } from 'path'; import { randomUUID } from 'crypto'; -import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore'; +import { AUDIOBOOKS_V1_DIR, UNCLAIMED_USER_ID, isAudiobooksV1Ready, getUserAudiobookDir } from '@/lib/server/docstore'; import { encodeChapterFileName, encodeChapterTitleTag, listStoredChapters, ffprobeAudio, escapeFFMetadata } from '@/lib/server/audiobook'; import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; import { db } from '@/db'; import { audiobooks, audiobookChapters } from '@/db/schema'; -import { eq } from 'drizzle-orm'; +import { eq, and, or } from 'drizzle-orm'; import { isAuthEnabled } from '@/lib/server/auth-config'; -import { getAuthContext, requireAuthContext } from '@/lib/server/auth'; +import { requireAuthContext } from '@/lib/server/auth'; export const dynamic = 'force-dynamic'; -function getAudiobooksRootDir(request: NextRequest): string { +/** + * Apply test namespace to a directory path if present in request headers. + */ +function applyTestNamespace(baseDir: string, request: NextRequest): string { const raw = request.headers.get('x-openreader-test-namespace')?.trim(); - if (!raw) return AUDIOBOOKS_V1_DIR; + if (!raw) return baseDir; const safe = raw.replace(/[^a-zA-Z0-9._-]/g, ''); if (!safe || safe === '.' || safe === '..' || safe.includes('..')) { - return AUDIOBOOKS_V1_DIR; + return baseDir; } - const resolved = resolve(AUDIOBOOKS_V1_DIR, safe); - if (!resolved.startsWith(resolve(AUDIOBOOKS_V1_DIR) + '/')) { - return AUDIOBOOKS_V1_DIR; + const resolved = resolve(baseDir, safe); + if (!resolved.startsWith(resolve(baseDir) + '/')) { + return baseDir; } return resolved; } +/** + * Get the base audiobooks directory, accounting for test namespaces. + * When auth is disabled, returns AUDIOBOOKS_V1_DIR (possibly with test namespace). + * When auth is enabled, returns the user-specific directory under AUDIOBOOKS_USERS_DIR. + */ +function getAudiobooksRootDir(request: NextRequest, userId: string | null, authEnabled: boolean): string { + // When auth is disabled, use the flat audiobooks_v1 directory + if (!authEnabled || !userId) { + return applyTestNamespace(AUDIOBOOKS_V1_DIR, request); + } + + // When auth is enabled, use user-specific directory + const userDir = getUserAudiobookDir(userId); + return applyTestNamespace(userDir, request); +} + interface ConversionRequest { chapterTitle: string; buffer: TTSAudioBytes; @@ -183,8 +202,10 @@ export async function POST(request: NextRequest) { } // DB Check / Insert Audiobook - if (isAuthEnabled() && db) { - const [existingBook] = await db.select().from(audiobooks).where(eq(audiobooks.id, bookId)); + if (isAuthEnabled() && db && userId) { + const [existingBook] = await db.select().from(audiobooks).where( + and(eq(audiobooks.id, bookId), eq(audiobooks.userId, userId)) + ); if (!existingBook) { await db.insert(audiobooks).values({ @@ -192,14 +213,10 @@ export async function POST(request: NextRequest) { userId, title: data.chapterTitle || 'Untitled Audiobook', }); - } else { - if (existingBook.userId && existingBook.userId !== userId) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } } } - const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); + const intermediateDir = join(getAudiobooksRootDir(request, userId, ctxOrRes.authEnabled), `${bookId}-audiobook`); // Create intermediate directory await mkdir(intermediateDir, { recursive: true }); @@ -346,7 +363,7 @@ export async function POST(request: NextRequest) { await unlink(inputPath).catch(console.error); // Insert Chapter Record (Denormalized) - if (isAuthEnabled() && db) { + if (isAuthEnabled() && db && userId) { await db.insert(audiobookChapters).values({ id: `${bookId}-${chapterIndex}`, bookId, @@ -357,7 +374,7 @@ export async function POST(request: NextRequest) { format, filePath: finalChapterName }).onConflictDoUpdate({ - target: [audiobookChapters.id], + target: [audiobookChapters.id, audiobookChapters.userId], set: { title: data.chapterTitle, duration, format, filePath: finalChapterName } }); } @@ -404,17 +421,33 @@ export async function GET(request: NextRequest) { ); } - const { userId } = await getAuthContext(request); + const ctxOrRes = await requireAuthContext(request); + if (ctxOrRes instanceof Response) return ctxOrRes; + const { userId, authEnabled } = ctxOrRes; - // Verify ownership - if (isAuthEnabled() && db) { - const [existingBook] = await db.select().from(audiobooks).where(eq(audiobooks.id, bookId)); - if (existingBook && existingBook.userId && existingBook.userId !== userId) { - return NextResponse.json({ error: 'Book not found' }, { status: 404 }); // Hide existence + // Check if audiobook exists for user OR is unclaimed (similar to documents) + if (authEnabled && db && userId) { + const [existingBook] = await db.select().from(audiobooks).where( + and( + eq(audiobooks.id, bookId), + or(eq(audiobooks.userId, userId), eq(audiobooks.userId, UNCLAIMED_USER_ID)) + ) + ); + if (!existingBook) { + return NextResponse.json({ error: 'Book not found' }, { status: 404 }); } } - const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); + // Get the audiobook directory - check user's directory first, then unclaimed + let intermediateDir = join(getAudiobooksRootDir(request, userId, authEnabled), `${bookId}-audiobook`); + + // If not found in user's directory and auth is enabled, check unclaimed directory + if (!existsSync(intermediateDir) && authEnabled && userId) { + const unclaimedDir = join(getAudiobooksRootDir(request, UNCLAIMED_USER_ID, authEnabled), `${bookId}-audiobook`); + if (existsSync(unclaimedDir)) { + intermediateDir = unclaimedDir; + } + } if (!existsSync(intermediateDir)) { return NextResponse.json({ error: 'Book not found' }, { status: 404 }); @@ -610,24 +643,29 @@ export async function DELETE(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; - const userId = ctxOrRes.userId; + const { userId, authEnabled } = ctxOrRes; - // Verify ownership - if (isAuthEnabled() && db) { - const [existingBook] = await db.select().from(audiobooks).where(eq(audiobooks.id, bookId)); + // Delete from DB - with composite PK, we delete by both id and userId + if (authEnabled && db && userId) { + const [existingBook] = await db.select().from(audiobooks).where( + and(eq(audiobooks.id, bookId), eq(audiobooks.userId, userId)) + ); - if (existingBook) { - if (existingBook.userId && existingBook.userId !== userId) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } - - await db.delete(audiobooks).where(eq(audiobooks.id, bookId)); - } else { + if (!existingBook) { return NextResponse.json({ error: 'Book not found' }, { status: 404 }); } + + // Delete chapters first (no foreign key constraint with composite PK) + await db.delete(audiobookChapters).where( + and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, userId)) + ); + + await db.delete(audiobooks).where( + and(eq(audiobooks.id, bookId), eq(audiobooks.userId, userId)) + ); } - const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); + const intermediateDir = join(getAudiobooksRootDir(request, userId, authEnabled), `${bookId}-audiobook`); // If directory doesn't exist, consider it already reset if (!existsSync(intermediateDir)) { diff --git a/src/app/api/audiobook/status/route.ts b/src/app/api/audiobook/status/route.ts index ec810d5..3b9f91d 100644 --- a/src/app/api/audiobook/status/route.ts +++ b/src/app/api/audiobook/status/route.ts @@ -1,20 +1,45 @@ import { NextRequest, NextResponse } from 'next/server'; import { existsSync } from 'fs'; -import { join } from 'path'; -import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore'; +import { join, resolve } from 'path'; +import { AUDIOBOOKS_V1_DIR, UNCLAIMED_USER_ID, getUserAudiobookDir, isAudiobooksV1Ready } from '@/lib/server/docstore'; import { listStoredChapters } from '@/lib/server/audiobook'; import type { AudiobookGenerationSettings } from '@/types/client'; import type { TTSAudiobookFormat, TTSAudiobookChapter } from '@/types/tts'; import { readFile } from 'fs/promises'; -import { requireAuthContext, requireAudiobookOwned } from '@/lib/server/auth'; +import { requireAuthContext } from '@/lib/server/auth'; +import { db } from '@/db'; +import { audiobooks } from '@/db/schema'; +import { eq, and, or } from 'drizzle-orm'; export const dynamic = 'force-dynamic'; -function getAudiobooksRootDir(request: NextRequest): string { +/** + * Get the base audiobooks directory, accounting for test namespaces. + * When auth is disabled, returns AUDIOBOOKS_V1_DIR. + * When auth is enabled, returns the user-specific directory. + */ +function getAudiobooksRootDir(request: NextRequest, userId: string | null, authEnabled: boolean): string { const raw = request.headers.get('x-openreader-test-namespace')?.trim(); - if (!raw) return AUDIOBOOKS_V1_DIR; - const safe = raw.replace(/[^a-zA-Z0-9._-]/g, ''); - return safe ? join(AUDIOBOOKS_V1_DIR, safe) : AUDIOBOOKS_V1_DIR; + + const applyTestNamespace = (baseDir: string): string => { + if (!raw) return baseDir; + const safe = raw.replace(/[^a-zA-Z0-9._-]/g, ''); + if (!safe || safe === '.' || safe === '..' || safe.includes('..')) { + return baseDir; + } + const resolved = resolve(baseDir, safe); + if (!resolved.startsWith(resolve(baseDir) + '/')) { + return baseDir; + } + return resolved; + }; + + if (!authEnabled || !userId) { + return applyTestNamespace(AUDIOBOOKS_V1_DIR); + } + + const userDir = getUserAudiobookDir(userId); + return applyTestNamespace(userDir); } const SAFE_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; @@ -40,9 +65,18 @@ export async function GET(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; - if (ctxOrRes.authEnabled) { - const denied = await requireAudiobookOwned(bookId, ctxOrRes.userId!, { onDenied: 'notFound' }); - if (denied) { + const { userId, authEnabled } = ctxOrRes; + + // Check if audiobook exists for user OR is unclaimed (similar to documents) + if (authEnabled && db && userId) { + const [existingBook] = await db.select().from(audiobooks).where( + and( + eq(audiobooks.id, bookId), + or(eq(audiobooks.userId, userId), eq(audiobooks.userId, UNCLAIMED_USER_ID)) + ) + ); + if (!existingBook) { + // Book doesn't exist for this user or unclaimed - return empty state return NextResponse.json({ chapters: [], exists: false, @@ -53,7 +87,16 @@ export async function GET(request: NextRequest) { } } - const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`); + // Get the audiobook directory - check user's directory first, then unclaimed + let intermediateDir = join(getAudiobooksRootDir(request, userId, authEnabled), `${bookId}-audiobook`); + + // If not found in user's directory and auth is enabled, check unclaimed directory + if (!existsSync(intermediateDir) && authEnabled && userId) { + const unclaimedDir = join(getAudiobooksRootDir(request, UNCLAIMED_USER_ID, authEnabled), `${bookId}-audiobook`); + if (existsSync(unclaimedDir)) { + intermediateDir = unclaimedDir; + } + } if (!existsSync(intermediateDir)) { return NextResponse.json({ diff --git a/src/components/auth/AuthLoader.tsx b/src/components/auth/AuthLoader.tsx index 3aca979..e1e93a3 100644 --- a/src/components/auth/AuthLoader.tsx +++ b/src/components/auth/AuthLoader.tsx @@ -1,15 +1,97 @@ 'use client'; import { useEffect, useRef, useState, ReactNode } from 'react'; +import type { BetterFetchError } from 'better-auth/react'; import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useAuthSession } from '@/hooks/useAuthSession'; import { getAuthClient } from '@/lib/auth-client'; import { LoadingSpinner } from '@/components/Spinner'; +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +type ErrorInfo = { status?: number; message?: string }; + +function isBetterFetchError(input: unknown): input is BetterFetchError { + if (!input || typeof input !== 'object') return false; + const rec = input as Record; + return ( + input instanceof Error && + typeof rec.status === 'number' && + typeof rec.statusText === 'string' && + 'error' in rec + ); +} + +/** + * Normalize different error shapes into a single `{ status, message }`. + * + * Handles: + * - thrown Errors that may include `status` + * - better-auth / better-fetch style endpoint returns: `{ data, error }` + */ +function getErrorInfo(input: unknown): ErrorInfo | null { + if (!input) return null; + + if (isBetterFetchError(input)) { + return { + status: input.status, + message: input.statusText || input.message, + }; + } + + if (input instanceof Error) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const status = typeof (input as any).status === 'number' ? (input as any).status : undefined; + return { status, message: input.message }; + } + + // Handle better-fetch style: { error: { status, message } } OR { error: string } + if (typeof input === 'object') { + const rec = input as Record; + if ('error' in rec && rec.error) { + const err = rec.error; + if (isBetterFetchError(err)) { + return { + status: err.status, + message: err.statusText || err.message, + }; + } + if (typeof err === 'object' && err !== null) { + const e = err as Record; + const status = typeof e.status === 'number' ? e.status : undefined; + const message = typeof e.message === 'string' ? e.message : undefined; + return { status, message }; + } + return { message: typeof err === 'string' ? err : 'Request failed' }; + } + + const status = rec.status; + const message = rec.message; + const out: ErrorInfo = {}; + if (typeof status === 'number') out.status = status; + if (typeof message === 'string') out.message = message; + if (out.status !== undefined || out.message !== undefined) return out; + } + + if (typeof input === 'string') return { message: input }; + + return null; +} + +function isRateLimited(info: ErrorInfo | null): boolean { + if (!info) return false; + if (info.status === 429) return true; + // Fallback for cases where the server didn't return a numeric status. + const msg = info.message || ''; + return /too\s+many\s+requests|rate\s*limit/i.test(msg); +} + export function AuthLoader({ children }: { children: ReactNode }) { const { authEnabled, baseUrl } = useAuthConfig(); const { refresh: refreshRateLimit } = useAuthRateLimit(); - const { data: session, isPending } = useAuthSession(); + const { data: session, isPending, error: sessionError, refetch: refetchSession } = useAuthSession(); const [isAutoLoggingIn, setIsAutoLoggingIn] = useState(false); const [bootstrapError, setBootstrapError] = useState(null); const [retryNonce, setRetryNonce] = useState(0); @@ -43,18 +125,78 @@ export function AuthLoader({ children }: { children: ReactNode }) { try { const client = getAuthClient(baseUrl); - await client.signIn.anonymous(); + + // In Playwright/`next start` we sometimes hit 429s on anonymous sign-in. + // Keep using better-auth client so its session hook updates correctly, + // but add retry/backoff around the call. + const maxAttempts = 6; + const baseDelayMs = 500; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const attemptTag = `[AuthLoader] better-auth signIn.anonymous attempt ${attempt}/${maxAttempts}`; + try { + console.info(attemptTag); + const result = await client.signIn.anonymous(); + const info = getErrorInfo(result); + if (info) { + // better-auth client endpoints often do NOT throw; they return { data, error }. + // Convert that into a thrown error so our retry/backoff logic works. + const e = new Error(info.message || 'Anonymous sign-in failed'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (e as any).status = info.status; + console.warn(`${attemptTag} (non-throwing error response)`, info); + throw e; + } + + console.info(`${attemptTag} (success)`, result ? { hasResult: true } : { hasResult: false }); + + // In some environments (notably Playwright against `next start`), + // the session signal does not immediately update after setting the + // session cookie. Force an explicit session refetch. + if (typeof refetchSession === 'function') { + console.info('[AuthLoader] refetching session after anonymous sign-in'); + await refetchSession(); + } + + // Give React a moment to observe the updated session. + await sleep(50); + break; + } catch (err) { + const info = getErrorInfo(err); + console.warn(`${attemptTag} (failed)`, info ?? undefined); + console.warn(err); + + if (isRateLimited(info) && attempt < maxAttempts) { + // better-auth doesn't currently expose Retry-After headers here; + // fall back to exponential backoff. + const delayMs = Math.min(10_000, baseDelayMs * Math.pow(2, attempt - 1)); + console.warn(`${attemptTag} rate-limited; waiting ${delayMs}ms before retry`); + await sleep(delayMs); + continue; + } + + throw err; + } + } + await refreshRateLimit(); } catch (err) { - console.error('Auto-login failed', err); - setBootstrapError('Unable to start an anonymous session.'); + console.error('[AuthLoader] auto-login failed', err); + setBootstrapError('Unable to start an anonymous session (rate limited or network error).'); } finally { setIsAutoLoggingIn(false); } }; checkStatus(); - }, [session, isPending, authEnabled, baseUrl, refreshRateLimit, retryNonce]); + }, [session, isPending, authEnabled, baseUrl, refreshRateLimit, refetchSession, retryNonce]); + + useEffect(() => { + if (!authEnabled) return; + if (sessionError) { + console.warn('[AuthLoader] useSession error', sessionError); + } + }, [authEnabled, sessionError]); // Show loader if: // 1. Auth client is initializing (isPending) AND auth is enabled diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index fa42b22..fb7c19f 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -100,6 +100,10 @@ interface SetTextOptions { const CONTINUATION_LOOKAHEAD = 600; const SENTENCE_ENDING = /[.?!…]["'”’)\]]*\s*$/; +// Tiny silent WAV used to unlock HTML5 audio on iOS/Safari. +const SILENT_WAV_DATA_URI = + 'data:audio/wav;base64,UklGRkQDAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YSADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=='; + const normalizeLocationKey = (location: TTSLocation) => typeof location === 'number' ? `num:${location}` : `str:${location}`; @@ -367,6 +371,109 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const sentencesRef = useRef([]); const currentIndexRef = useRef(0); + const audioUnlockAttemptRef = useRef(0); + + // Safari/iOS (HTML5 audio) can spontaneously reset playbackRate to 1. Keep re-applying + // the desired rate while a sentence is playing. + const rateWatchdogIntervalRef = useRef | null>(null); + + const clearRateWatchdog = useCallback(() => { + if (!rateWatchdogIntervalRef.current) return; + clearInterval(rateWatchdogIntervalRef.current); + rateWatchdogIntervalRef.current = null; + }, []); + + const applyPlaybackRateToHowl = useCallback((howl: Howl | null) => { + if (!howl) return; + + try { + howl.rate(audioSpeed); + } catch { + // ignore + } + + // Best-effort: Howler doesn't expose the underlying HTMLAudioElement publicly. + // This helps on browsers that reset playbackRate/defaultPlaybackRate. + try { + const sounds = (howl as unknown as { _sounds?: Array<{ _node?: unknown }> })._sounds; + const node = sounds?.[0]?._node as unknown; + if (node && typeof node === 'object') { + const anyNode = node as { playbackRate?: number; defaultPlaybackRate?: number }; + if (typeof anyNode.playbackRate === 'number') anyNode.playbackRate = audioSpeed; + if (typeof anyNode.defaultPlaybackRate === 'number') anyNode.defaultPlaybackRate = audioSpeed; + } + } catch { + // ignore + } + }, [audioSpeed]); + + const startRateWatchdog = useCallback((howl: Howl | null) => { + if (!howl) return; + clearRateWatchdog(); + + // Apply immediately + keep applying while playback is active. + applyPlaybackRateToHowl(howl); + rateWatchdogIntervalRef.current = setInterval(() => { + applyPlaybackRateToHowl(howl); + }, 250); + }, [applyPlaybackRateToHowl, clearRateWatchdog]); + + const unlockPlaybackOnUserGesture = useCallback(() => { + // Best-effort; safe to call multiple times. + audioUnlockAttemptRef.current += 1; + const attempt = audioUnlockAttemptRef.current; + + try { + void audioContext?.resume(); + } catch { + // ignore + } + + try { + const el = new Audio(SILENT_WAV_DATA_URI); + try { + el.setAttribute('playsinline', 'true'); + } catch { + // ignore + } + el.preload = 'auto'; + el.volume = 0; + + const p = el.play(); + if (p && typeof (p as Promise).then === 'function') { + void (p as Promise) + .then(() => { + if (audioUnlockAttemptRef.current !== attempt) return; + try { + el.pause(); + el.currentTime = 0; + } catch { + // ignore + } + }) + .catch(() => { + // ignore + }); + } + } catch { + // ignore + } + }, [audioContext]); + + const isAutoplayBlockedError = useCallback((err: unknown) => { + const msg = (() => { + if (typeof err === 'string') return err; + if (err instanceof Error) return err.message; + if (typeof err === 'object' && err !== null && 'message' in err) { + const maybe = (err as { message?: unknown }).message; + if (typeof maybe === 'string') return maybe; + } + return ''; + })(); + + return /notallowed|not allowed|user gesture|interaction|autoplay|play\(\) failed/i.test(msg); + }, []); + useEffect(() => { sentencesRef.current = sentences; }, [sentences]); @@ -395,6 +502,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement * @param {boolean} [clearPending=false] - Whether to clear pending requests */ const abortAudio = useCallback((clearPending = false) => { + clearRateWatchdog(); if (activeHowl) { activeHowl.stop(); activeHowl.unload(); @@ -414,7 +522,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement pageTurnTimeoutRef.current = null; } setCurrentWordIndex(null); - }, [activeHowl]); + }, [activeHowl, clearRateWatchdog]); /** * Pauses the current audio playback @@ -666,15 +774,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement * Toggles the playback state between playing and paused */ const togglePlay = useCallback(() => { - setIsPlaying((prev) => { - if (!prev) { - return true; - } else { - abortAudio(); - return false; - } - }); - }, [abortAudio]); + if (isPlaying) { + abortAudio(); + setIsPlaying(false); + return; + } + + // Ensure audio is unlocked while we're still in the click/tap handler. + unlockPlaybackOnUserGesture(); + setIsPlaying(true); + }, [abortAudio, isPlaying, unlockPlaybackOnUserGesture]); /** @@ -1068,6 +1177,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement pool: 1, rate: audioSpeed, onload: function (this: Howl) { + applyPlaybackRateToHowl(this); const estimate = pageTurnEstimateRef.current; if (!estimate || estimate.sentenceIndex !== sentenceIndex) return; if (!visualPageChangeHandlerRef.current) return; @@ -1089,14 +1199,40 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement visualPageChangeHandlerRef.current?.(currentEstimate.location); }, delayMs); }, - onplay: () => { + onplay: function (this: Howl) { setIsProcessing(false); + startRateWatchdog(this); if ('mediaSession' in navigator) { navigator.mediaSession.playbackState = 'playing'; } }, - onplayerror: function (this: Howl, error) { - console.warn('Howl playback error:', error); + onplayerror: function (this: Howl, soundId, error) { + const actualError = error ?? soundId; + console.warn('Howl playback error:', actualError); + + // Common on iOS/Safari when the actual play() call happens after awaiting TTS. + // Do not skip/advance in this case; just pause and tell the user to tap play again. + if (isAutoplayBlockedError(actualError)) { + setIsProcessing(false); + setActiveHowl(null); + try { + this.unload(); + } catch { + // ignore unload errors + } + setIsPlaying(false); + + toast.error('Playback was blocked by your browser. Tap play again to start.', { + id: 'tts-playback-blocked', + style: { + background: 'var(--background)', + color: 'var(--accent)', + }, + duration: 4000, + }); + return; + } + playErrorAttempts += 1; // Avoid looping for many seconds on Safari: if playback still fails after a single @@ -1106,6 +1242,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement setActiveHowl(null); this.unload(); setIsPlaying(false); + + toast.error('Audio playback failed. Skipped sentence and paused.', { + id: 'tts-playback-error', + style: { + background: 'var(--background)', + color: 'var(--accent)', + }, + duration: 4000, + }); + advance(); return; } @@ -1117,8 +1263,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement this.load(); } }, - onloaderror: async function (this: Howl, error) { - console.warn(`Error loading audio (attempt ${retryCount + 1}/${MAX_RETRIES}):`, error); + onloaderror: async function (this: Howl, soundId, error) { + const actualError = error ?? soundId; + console.warn(`Error loading audio (attempt ${retryCount + 1}/${MAX_RETRIES}):`, actualError); if (retryCount < MAX_RETRIES) { // Calculate exponential backoff delay @@ -1184,6 +1331,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } }, onend: function (this: Howl) { + clearRateWatchdog(); this.unload(); setActiveHowl(null); if (pageTurnTimeoutRef.current) { @@ -1195,6 +1343,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } }, onstop: function (this: Howl) { + clearRateWatchdog(); setIsProcessing(false); this.unload(); } @@ -1225,7 +1374,18 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement advance(); return null; } - }, [abortAudio, isPlaying, advance, activeHowl, processSentence, audioSpeed]); + }, [ + abortAudio, + isPlaying, + advance, + activeHowl, + processSentence, + audioSpeed, + isAutoplayBlockedError, + applyPlaybackRateToHowl, + startRateWatchdog, + clearRateWatchdog, + ]); const playAudio = useCallback(async () => { const sentence = sentences[currentIndex]; @@ -1251,6 +1411,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } }, [sentences, currentIndex, playSentenceWithHowl, voice, speed, configTTSProvider, ttsModel]); + // Keep the current playback rate applied to the active Howl. Some browsers (notably + // iOS Safari with HTML5 audio) can reset playbackRate after initial load/play. + useEffect(() => { + if (!activeHowl) return; + applyPlaybackRateToHowl(activeHowl); + if (isPlaying) { + startRateWatchdog(activeHowl); + } + }, [activeHowl, audioSpeed, applyPlaybackRateToHowl, isPlaying, startRateWatchdog]); + // Place useBackgroundState after playAudio is defined const isBackgrounded = useBackgroundState({ activeHowl, @@ -1408,9 +1578,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const stopAndPlayFromIndex = useCallback((index: number) => { abortAudio(); + // Same autoplay-unlock issue as togglePlay when starting from a fresh load. + unlockPlaybackOnUserGesture(); + setCurrentIndex(index); setIsPlaying(true); - }, [abortAudio]); + }, [abortAudio, unlockPlaybackOnUserGesture]); /** * Sets the speed and restarts the playback diff --git a/src/db/schema_postgres.ts b/src/db/schema_postgres.ts index 276cef4..d5fd7ab 100644 --- a/src/db/schema_postgres.ts +++ b/src/db/schema_postgres.ts @@ -14,26 +14,30 @@ export const documents = pgTable('documents', { })); export const audiobooks = pgTable('audiobooks', { - id: text('id').primaryKey(), - userId: text('user_id'), + id: text('id').notNull(), + userId: text('user_id').notNull(), title: text('title').notNull(), author: text('author'), description: text('description'), coverPath: text('cover_path'), duration: real('duration').default(0), createdAt: timestamp('created_at').defaultNow(), -}); +}, (table) => ({ + pk: primaryKey({ columns: [table.id, table.userId] }), +})); export const audiobookChapters = pgTable('audiobook_chapters', { - id: text('id').primaryKey(), - bookId: text('book_id').notNull().references(() => audiobooks.id, { onDelete: 'cascade' }), - userId: text('user_id'), + id: text('id').notNull(), + bookId: text('book_id').notNull(), + userId: text('user_id').notNull(), chapterIndex: integer('chapter_index').notNull(), title: text('title').notNull(), duration: real('duration').default(0), filePath: text('file_path').notNull(), format: text('format').notNull(), // mp3, m4b -}); +}, (table) => ({ + pk: primaryKey({ columns: [table.id, table.userId] }), +})); export const user = pgTable("user", { id: text("id").primaryKey(), diff --git a/src/db/schema_sqlite.ts b/src/db/schema_sqlite.ts index 1d1a3dc..e369796 100644 --- a/src/db/schema_sqlite.ts +++ b/src/db/schema_sqlite.ts @@ -15,26 +15,30 @@ export const documents = sqliteTable('documents', { })); export const audiobooks = sqliteTable('audiobooks', { - id: text('id').primaryKey(), - userId: text('user_id'), + id: text('id').notNull(), + userId: text('user_id').notNull(), title: text('title').notNull(), author: text('author'), description: text('description'), coverPath: text('cover_path'), duration: real('duration').default(0), createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`), -}); +}, (table) => ({ + pk: primaryKey({ columns: [table.id, table.userId] }), +})); export const audiobookChapters = sqliteTable('audiobook_chapters', { - id: text('id').primaryKey(), - bookId: text('book_id').notNull().references(() => audiobooks.id, { onDelete: 'cascade' }), - userId: text('user_id'), + id: text('id').notNull(), + bookId: text('book_id').notNull(), + userId: text('user_id').notNull(), chapterIndex: integer('chapter_index').notNull(), title: text('title').notNull(), duration: real('duration').default(0), filePath: text('file_path').notNull(), format: text('format').notNull(), // mp3, m4b -}); +}, (table) => ({ + pk: primaryKey({ columns: [table.id, table.userId] }), +})); export const user = sqliteTable("user", { id: text("id").primaryKey(), diff --git a/src/hooks/useAuthSession.ts b/src/hooks/useAuthSession.ts index ac6c60e..a817221 100644 --- a/src/hooks/useAuthSession.ts +++ b/src/hooks/useAuthSession.ts @@ -4,6 +4,8 @@ import { useMemo } from 'react'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { getAuthClient } from '@/lib/auth-client'; +type SessionHookResult = ReturnType['useSession']>; + /** * Hook for session that uses the correct baseUrl from context */ @@ -16,7 +18,18 @@ export function useAuthSession() { }, [baseUrl, authEnabled]); if (!client) { - return { data: null, isPending: false, error: null }; + // Keep a stable shape so consumers can always destructure the same fields. + // This avoids union-type issues when auth is disabled. + const empty: SessionHookResult = { + data: null, + isPending: false, + isRefetching: false, + // better-auth types use BetterFetchError | null + // eslint-disable-next-line @typescript-eslint/no-explicit-any + error: null as any, + refetch: async () => {}, + }; + return empty; } return client.useSession(); diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 0a16158..8753b47 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -7,9 +7,7 @@ import type { NextRequest } from 'next/server'; import { db } from "@/db"; import { rateLimiter } from "@/lib/server/rate-limiter"; import { isAuthEnabled } from "@/lib/server/auth-config"; -import { eq } from 'drizzle-orm'; - - +import { transferUserAudiobooks } from "@/lib/server/claim-data"; import * as schema from "@/db/schema"; // Import the dynamic schema @@ -37,6 +35,11 @@ const createAuth = () => betterAuth({ console.log("Password reset requested for:", data.user.email); }, }, + rateLimit: { + // Disable rate limiting when running tests to support parallel test workers + // In production, better-auth's default rate limiting applies + enabled: process.env.DISABLE_AUTH_RATE_LIMIT !== 'true', + }, socialProviders: { ...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET && { github: { @@ -72,6 +75,17 @@ const createAuth = () => betterAuth({ console.error("Error transferring rate limit data during account linking:", error); // Don't throw here to prevent blocking the account linking process } + + // Transfer audiobooks from anonymous user to new authenticated user + try { + const transferred = await transferUserAudiobooks(anonymousUser.user.id, newUser.user.id); + if (transferred > 0) { + console.log(`Successfully transferred ${transferred} audiobook(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`); + } + } catch (error) { + console.error("Error transferring audiobooks during account linking:", error); + // Don't throw here to prevent blocking the account linking process + } } catch (error) { console.error("Error in onLinkAccount callback:", error); // Don't throw here to prevent blocking the account linking process @@ -128,32 +142,4 @@ export async function requireAuthContext( } return ctx; -} - -export type AudiobookAccessMode = 'forbidden' | 'notFound'; - -export async function requireAudiobookOwned( - bookId: string, - userId: string, - options?: { onDenied?: AudiobookAccessMode }, -): Promise { - if (!isAuthEnabled() || !db) return null; - - const [existingBook] = await db - .select({ userId: schema.audiobooks.userId }) - .from(schema.audiobooks) - .where(eq(schema.audiobooks.id, bookId)); - - if (!existingBook) { - return NextResponse.json({ error: 'Book not found' }, { status: 404 }); - } - - if (existingBook.userId && existingBook.userId !== userId) { - if (options?.onDenied === 'notFound') { - return NextResponse.json({ error: 'Book not found' }, { status: 404 }); - } - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } - - return null; } \ No newline at end of file diff --git a/src/lib/server/claim-data.ts b/src/lib/server/claim-data.ts index 4810833..2620c48 100644 --- a/src/lib/server/claim-data.ts +++ b/src/lib/server/claim-data.ts @@ -1,39 +1,45 @@ import { db } from '@/db'; import { documents, audiobooks, audiobookChapters } from '@/db/schema'; -import { eq, isNull, count } from 'drizzle-orm'; +import { eq, and, count } from 'drizzle-orm'; import fs from 'fs/promises'; import { existsSync } from 'fs'; import path from 'path'; -import { DOCUMENTS_V1_DIR, AUDIOBOOKS_V1_DIR } from './docstore'; +import { + DOCUMENTS_V1_DIR, + AUDIOBOOKS_V1_DIR, + UNCLAIMED_USER_ID, + getUnclaimedAudiobookDir, + getUserAudiobookDir, + moveAudiobookToUser, + listUserAudiobookIds +} from './docstore'; import { listStoredChapters } from './audiobook'; import { isAuthEnabled } from '@/lib/server/auth-config'; -// Helper to check if a file is already indexed +// Helper to check if a document is already indexed async function isDocumentIndexed(id: string) { - if (!isAuthEnabled() || !db) return false; // If no DB, assume not indexed or handle differently? - // Actually if no DB, we don't index into DB. So returning false is fine, - // but scanAndPopulateDB should probably return early if no DB. - + if (!isAuthEnabled() || !db) return false; const result = await db.select({ id: documents.id }).from(documents).where(eq(documents.id, id)); return result.length > 0; } -async function isAudiobookIndexed(id: string) { +// Helper to check if an audiobook is indexed for a specific user (composite PK) +async function isAudiobookIndexedForUser(id: string, userId: string) { if (!isAuthEnabled() || !db) return false; - const result = await db.select({ id: audiobooks.id }).from(audiobooks).where(eq(audiobooks.id, id)); + const result = await db.select({ id: audiobooks.id }).from(audiobooks).where( + and(eq(audiobooks.id, id), eq(audiobooks.userId, userId)) + ); return result.length > 0; } -const UNCLAIMED_ID = 'unclaimed'; - /** * Returns count of unclaimed documents and audiobooks in the DB (userId IS 'unclaimed') */ export async function getUnclaimedCounts() { if (!isAuthEnabled() || !db) return { documents: 0, audiobooks: 0 }; - const [docCount] = await db.select({ count: count() }).from(documents).where(eq(documents.userId, UNCLAIMED_ID)); - const [bookCount] = await db.select({ count: count() }).from(audiobooks).where(eq(audiobooks.userId, UNCLAIMED_ID)); + const [docCount] = await db.select({ count: count() }).from(documents).where(eq(documents.userId, UNCLAIMED_USER_ID)); + const [bookCount] = await db.select({ count: count() }).from(audiobooks).where(eq(audiobooks.userId, UNCLAIMED_USER_ID)); return { documents: docCount?.count ?? 0, @@ -41,6 +47,41 @@ export async function getUnclaimedCounts() { }; } +/** + * Migrate legacy audiobooks from AUDIOBOOKS_V1_DIR to the unclaimed user folder. + * This handles the case where audiobooks were created before the per-user storage refactor. + */ +async function migrateLegacyAudiobooksToUnclaimed(): Promise { + if (!existsSync(AUDIOBOOKS_V1_DIR)) return 0; + + const unclaimedDir = getUnclaimedAudiobookDir(); + await fs.mkdir(unclaimedDir, { recursive: true }); + + const entries = await fs.readdir(AUDIOBOOKS_V1_DIR, { withFileTypes: true }); + let migrated = 0; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (!entry.name.endsWith('-audiobook')) continue; + + const sourceDir = path.join(AUDIOBOOKS_V1_DIR, entry.name); + const targetDir = path.join(unclaimedDir, entry.name); + + // Skip if already exists in unclaimed + if (existsSync(targetDir)) continue; + + try { + await fs.rename(sourceDir, targetDir); + migrated++; + console.log(`Migrated legacy audiobook to unclaimed: ${entry.name}`); + } catch (err) { + console.error(`Error migrating legacy audiobook ${entry.name}:`, err); + } + } + + return migrated; +} + export async function scanAndPopulateDB() { if (!isAuthEnabled() || !db) { console.log('Skipping DB population (Auth/DB disabled)'); @@ -49,19 +90,10 @@ export async function scanAndPopulateDB() { console.log('Scanning file system for un-indexed content...'); - // 0. Fix legacy NULL userIds - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (db as any).update(documents).set({ userId: UNCLAIMED_ID }).where(isNull(documents.userId)); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (db as any).update(audiobooks).set({ userId: UNCLAIMED_ID }).where(isNull(audiobooks.userId)); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (db as any).update(audiobookChapters).set({ userId: UNCLAIMED_ID }).where(isNull(audiobookChapters.userId)); - } catch (err) { - console.error('Error migrating legacy NULL userIds:', err); - } + // 0. Migrate legacy audiobooks from AUDIOBOOKS_V1_DIR to unclaimed folder + await migrateLegacyAudiobooksToUnclaimed(); - // 1. Scan Documents + // 1. Scan Documents (unchanged - documents use shared storage) if (existsSync(DOCUMENTS_V1_DIR)) { const files = await fs.readdir(DOCUMENTS_V1_DIR); for (const file of files) { @@ -86,7 +118,7 @@ export async function scanAndPopulateDB() { await db.insert(documents).values({ id, - userId: UNCLAIMED_ID, + userId: UNCLAIMED_USER_ID, name, type: ext, size: stats.size, @@ -97,17 +129,18 @@ export async function scanAndPopulateDB() { } } - // 2. Scan Audiobooks - if (existsSync(AUDIOBOOKS_V1_DIR)) { - const entries = await fs.readdir(AUDIOBOOKS_V1_DIR, { withFileTypes: true }); + // 2. Scan Audiobooks from unclaimed folder + const unclaimedDir = getUnclaimedAudiobookDir(); + if (existsSync(unclaimedDir)) { + const entries = await fs.readdir(unclaimedDir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; if (!entry.name.endsWith('-audiobook')) continue; const bookId = entry.name.replace('-audiobook', ''); - if (await isAudiobookIndexed(bookId)) continue; + if (await isAudiobookIndexedForUser(bookId, UNCLAIMED_USER_ID)) continue; - const dirPath = path.join(AUDIOBOOKS_V1_DIR, entry.name); + const dirPath = path.join(unclaimedDir, entry.name); let title = `Unknown Title`; @@ -126,7 +159,7 @@ export async function scanAndPopulateDB() { await db.insert(audiobooks).values({ id: bookId, - userId: UNCLAIMED_ID, + userId: UNCLAIMED_USER_ID, title: title, duration: totalDuration, }); @@ -136,13 +169,13 @@ export async function scanAndPopulateDB() { await db.insert(audiobookChapters).values({ id: `${bookId}-${chapter.index}`, bookId: bookId, - userId: UNCLAIMED_ID, + userId: UNCLAIMED_USER_ID, chapterIndex: chapter.index, title: chapter.title, duration: chapter.durationSec || 0, filePath: chapter.filePath, format: chapter.format - }) + }); } } } @@ -154,25 +187,134 @@ export async function scanAndPopulateDB() { export async function claimAnonymousData(userId: string) { if (!isAuthEnabled() || !db || !userId) return { documents: 0, audiobooks: 0 }; - // Update Documents + // Get list of unclaimed audiobook IDs before updating DB + const unclaimedBookIds = await listUserAudiobookIds(UNCLAIMED_USER_ID); + + // Update Documents - documents use shared storage, only DB update needed const docResult = await db.update(documents) .set({ userId }) - .where(eq(documents.userId, UNCLAIMED_ID)) - .returning({ id: documents.id }); // If supported by driver, otherwise use run and check changes + .where(eq(documents.userId, UNCLAIMED_USER_ID)) + .returning({ id: documents.id }); - // Update Audiobooks - const bookResult = await db.update(audiobooks) - .set({ userId }) - .where(eq(audiobooks.userId, UNCLAIMED_ID)) - .returning({ id: audiobooks.id }); + // For audiobooks, we need to: + // 1. Move the physical folders from unclaimed to user's folder + // 2. Update the DB records - // Update Chapters (denormalized userId) - await db.update(audiobookChapters) - .set({ userId }) - .where(eq(audiobookChapters.userId, UNCLAIMED_ID)); // Or match by bookId join + let audiobooksClaimedCount = 0; + const userDir = getUserAudiobookDir(userId); + await fs.mkdir(userDir, { recursive: true }); + + for (const bookId of unclaimedBookIds) { + try { + // Move the audiobook folder + const moved = await moveAudiobookToUser(bookId, UNCLAIMED_USER_ID, userId); + if (moved) { + // Update DB - delete old record and insert new one (composite PK requires this) + const [oldRecord] = await db.select().from(audiobooks).where( + and(eq(audiobooks.id, bookId), eq(audiobooks.userId, UNCLAIMED_USER_ID)) + ); + + if (oldRecord) { + // Get chapters + const oldChapters = await db.select().from(audiobookChapters).where( + and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, UNCLAIMED_USER_ID)) + ); + + // Delete old records + await db.delete(audiobookChapters).where( + and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, UNCLAIMED_USER_ID)) + ); + await db.delete(audiobooks).where( + and(eq(audiobooks.id, bookId), eq(audiobooks.userId, UNCLAIMED_USER_ID)) + ); + + // Insert new records with new userId + await db.insert(audiobooks).values({ + ...oldRecord, + userId, + }); + + for (const chapter of oldChapters) { + await db.insert(audiobookChapters).values({ + ...chapter, + userId, + }); + } + + audiobooksClaimedCount++; + console.log(`Claimed audiobook: ${bookId}`); + } + } + } catch (err) { + console.error(`Error claiming audiobook ${bookId}:`, err); + } + } return { documents: docResult.length, - audiobooks: bookResult.length + audiobooks: audiobooksClaimedCount }; } + +/** + * Transfer audiobooks from one user to another. + * Used when an anonymous user creates a real account. + * @returns number of audiobooks transferred + */ +export async function transferUserAudiobooks(fromUserId: string, toUserId: string): Promise { + if (!isAuthEnabled() || !db || !fromUserId || !toUserId) return 0; + + const bookIds = await listUserAudiobookIds(fromUserId); + let transferred = 0; + + const toUserDir = getUserAudiobookDir(toUserId); + await fs.mkdir(toUserDir, { recursive: true }); + + for (const bookId of bookIds) { + try { + // Move the audiobook folder + const moved = await moveAudiobookToUser(bookId, fromUserId, toUserId); + if (moved) { + // Update DB - delete old record and insert new one (composite PK) + const [oldRecord] = await db.select().from(audiobooks).where( + and(eq(audiobooks.id, bookId), eq(audiobooks.userId, fromUserId)) + ); + + if (oldRecord) { + // Get chapters + const oldChapters = await db.select().from(audiobookChapters).where( + and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, fromUserId)) + ); + + // Delete old records + await db.delete(audiobookChapters).where( + and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, fromUserId)) + ); + await db.delete(audiobooks).where( + and(eq(audiobooks.id, bookId), eq(audiobooks.userId, fromUserId)) + ); + + // Insert new records with new userId + await db.insert(audiobooks).values({ + ...oldRecord, + userId: toUserId, + }); + + for (const chapter of oldChapters) { + await db.insert(audiobookChapters).values({ + ...chapter, + userId: toUserId, + }); + } + + transferred++; + console.log(`Transferred audiobook ${bookId} from ${fromUserId} to ${toUserId}`); + } + } + } catch (err) { + console.error(`Error transferring audiobook ${bookId}:`, err); + } + } + + return transferred; +} diff --git a/src/lib/server/docstore.ts b/src/lib/server/docstore.ts index 340116f..07ebc4b 100644 --- a/src/lib/server/docstore.ts +++ b/src/lib/server/docstore.ts @@ -8,6 +8,101 @@ import { decodeChapterTitleTag, encodeChapterFileName, encodeChapterTitleTag, ff export const DOCSTORE_DIR = path.join(process.cwd(), 'docstore'); export const DOCUMENTS_V1_DIR = path.join(DOCSTORE_DIR, 'documents_v1'); export const AUDIOBOOKS_V1_DIR = path.join(DOCSTORE_DIR, 'audiobooks_v1'); +export const AUDIOBOOKS_USERS_DIR = path.join(DOCSTORE_DIR, 'audiobooks_users'); + +export const UNCLAIMED_USER_ID = 'unclaimed'; + +/** + * Get the audiobook directory for a specific user when auth is enabled. + * Returns path like: docstore/audiobooks_users/{userId} + */ +export function getUserAudiobookDir(userId: string): string { + // Sanitize userId to prevent path traversal + const safeUserId = userId.replace(/[^a-zA-Z0-9._-]/g, ''); + if (!safeUserId || safeUserId === '.' || safeUserId === '..' || safeUserId.includes('..')) { + throw new Error('Invalid userId for audiobook directory'); + } + return path.join(AUDIOBOOKS_USERS_DIR, safeUserId); +} + +/** + * Get the unclaimed audiobooks directory for pre-auth content. + * Returns path like: docstore/audiobooks_users/unclaimed + */ +export function getUnclaimedAudiobookDir(): string { + return path.join(AUDIOBOOKS_USERS_DIR, UNCLAIMED_USER_ID); +} + +/** + * Get the full path to a specific audiobook directory. + * - When auth is disabled: docstore/audiobooks_v1/{bookId}-audiobook + * - When auth is enabled: docstore/audiobooks_users/{userId}/{bookId}-audiobook + */ +export function getAudiobookPath(bookId: string, userId: string | null, authEnabled: boolean): string { + if (!authEnabled || !userId) { + return path.join(AUDIOBOOKS_V1_DIR, `${bookId}-audiobook`); + } + return path.join(getUserAudiobookDir(userId), `${bookId}-audiobook`); +} + +/** + * Move an audiobook folder from one user's directory to another. + * Used for claiming unclaimed audiobooks or transferring on account linking. + * @returns true if moved successfully, false if source doesn't exist + */ +export async function moveAudiobookToUser( + bookId: string, + fromUserId: string, + toUserId: string +): Promise { + const sourceDir = path.join(getUserAudiobookDir(fromUserId), `${bookId}-audiobook`); + const targetUserDir = getUserAudiobookDir(toUserId); + const targetDir = path.join(targetUserDir, `${bookId}-audiobook`); + + if (!existsSync(sourceDir)) { + return false; + } + + // Ensure target user directory exists + await mkdir(targetUserDir, { recursive: true }); + + // If target already exists, we need to merge or skip + if (existsSync(targetDir)) { + // Target exists - merge contents (move files that don't exist in target) + const result = await mergeDirectoryContents(sourceDir, targetDir); + // Try to remove source if empty + try { + const remaining = await readdir(sourceDir); + if (remaining.length === 0) { + await rm(sourceDir, { recursive: true, force: true }); + } + } catch { /* ignore */ } + return result.moved > 0 || result.skipped > 0; + } + + // Simple rename/move + await rename(sourceDir, targetDir); + return true; +} + +/** + * List all audiobook IDs in a user's directory. + */ +export async function listUserAudiobookIds(userId: string): Promise { + const userDir = getUserAudiobookDir(userId); + if (!existsSync(userDir)) return []; + + const entries = await readdir(userDir, { withFileTypes: true }); + const bookIds: string[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (!entry.name.endsWith('-audiobook')) continue; + bookIds.push(entry.name.replace('-audiobook', '')); + } + + return bookIds; +} const MIGRATIONS_DIR = path.join(DOCSTORE_DIR, '.migrations'); const MIGRATIONS_STATE_PATH = path.join(MIGRATIONS_DIR, 'state.json'); @@ -341,6 +436,8 @@ async function mergeDirectoryContents(sourceDir: string, targetDir: string): Pro export async function ensureAudiobooksV1Ready(): Promise { await mkdir(DOCSTORE_DIR, { recursive: true }); await mkdir(AUDIOBOOKS_V1_DIR, { recursive: true }); + // Also ensure the user-specific audiobooks directory exists for auth-enabled scenarios + await mkdir(AUDIOBOOKS_USERS_DIR, { recursive: true }); const state = await loadMigrationState(); const legacyDirsPresent = await hasLegacyAudiobookDirs(); diff --git a/src/lib/server/rate-limiter.ts b/src/lib/server/rate-limiter.ts index ff738ca..62c7999 100644 --- a/src/lib/server/rate-limiter.ts +++ b/src/lib/server/rate-limiter.ts @@ -17,6 +17,10 @@ export const RATE_LIMITS = { // eslint-disable-next-line @typescript-eslint/no-explicit-any const safeDb = () => db as any; +type UserTtsCharsInsert = typeof userTtsChars.$inferInsert; +type UserTtsCharsDateValue = UserTtsCharsInsert['date']; +type UserTtsCharsUpdatedAtValue = UserTtsCharsInsert['updatedAt']; + export interface RateLimitResult { allowed: boolean; @@ -99,6 +103,14 @@ function getRowsAffected(result: unknown): number { export class RateLimiter { constructor() { } + private isPostgres(): boolean { + return Boolean(process.env.POSTGRES_URL); + } + + private getUpdatedAtValue(): Date | number { + return this.isPostgres() ? new Date() : Date.now(); + } + /** * Check if a user can use TTS and increment their char count if allowed */ @@ -114,6 +126,7 @@ export class RateLimiter { } const today = new Date().toISOString().split('T')[0]; + const dateValue = today as unknown as UserTtsCharsDateValue; const userLimit = user.isAnonymous ? RATE_LIMITS.ANONYMOUS : RATE_LIMITS.AUTHENTICATED; const buckets: Bucket[] = [{ key: user.id, limit: userLimit }]; @@ -134,6 +147,13 @@ export class RateLimiter { try { if (!db) throw new Error("DB not initialized"); + + const updatedAt = this.getUpdatedAtValue() as unknown as UserTtsCharsUpdatedAtValue; + + // Use a DB transaction to avoid partial increments across buckets and to avoid + // non-transactional "rollback" logic that can corrupt counts under concurrency. + // Note: We intentionally allow a request to push a bucket over its limit; we only + // block when the bucket was already exhausted before this request starts updating. // eslint-disable-next-line @typescript-eslint/no-explicit-any return await safeDb().transaction(async (tx: any) => { // Ensure records exist for each bucket @@ -141,31 +161,30 @@ export class RateLimiter { await tx.insert(userTtsChars) .values({ userId: bucket.key, - date: today as string, + date: dateValue, charCount: 0, }) .onConflictDoUpdate({ target: [userTtsChars.userId, userTtsChars.date], - set: { updatedAt: new Date() }, + set: { updatedAt }, }); } - // Attempt to increment each bucket + // Attempt to increment each bucket. The `lt(..., limit)` guard blocks requests + // that start after the bucket is already exhausted, while still allowing a + // request to push the count over the limit. for (const bucket of buckets) { const updateResult = await tx.update(userTtsChars) .set({ charCount: sql`${userTtsChars.charCount} + ${charCount}`, - updatedAt: new Date() + updatedAt, }) .where(and( eq(userTtsChars.userId, bucket.key), - eq(userTtsChars.date, today as string), + eq(userTtsChars.date, dateValue), lt(userTtsChars.charCount, bucket.limit) )); - // If any bucket is already at/over its limit, reject the request (and roll back all increments). - // Note: we intentionally allow a request to push a bucket over its limit; we only block when the - // bucket was already exhausted before this request started. if (getRowsAffected(updateResult) <= 0) { throw new RateLimitExceeded(); } @@ -176,7 +195,7 @@ export class RateLimiter { for (const bucket of buckets) { const result = await tx.select({ currentCount: userTtsChars.charCount }) .from(userTtsChars) - .where(and(eq(userTtsChars.userId, bucket.key), eq(userTtsChars.date, today))); + .where(and(eq(userTtsChars.userId, bucket.key), eq(userTtsChars.date, dateValue))); const currentCount = result[0]?.currentCount ? Number(result[0].currentCount) : 0; bucketResults.push({ currentCount, limit: bucket.limit }); @@ -272,38 +291,36 @@ export class RateLimiter { if (!db) return; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await safeDb().transaction(async (tx: any) => { - const today = new Date().toISOString().split('T')[0]; + const today = new Date().toISOString().split('T')[0]; + const dateValue = today as unknown as UserTtsCharsDateValue; + const updatedAt = this.getUpdatedAtValue() as unknown as UserTtsCharsUpdatedAtValue; - // Get anonymous user's current count - const anonymousResult = await tx.select({ charCount: userTtsChars.charCount }) - .from(userTtsChars) - .where(and(eq(userTtsChars.userId, anonymousUserId), eq(userTtsChars.date, today))); + const anonymousResult = await safeDb().select({ charCount: userTtsChars.charCount }) + .from(userTtsChars) + .where(and(eq(userTtsChars.userId, anonymousUserId), eq(userTtsChars.date, dateValue))); - if (anonymousResult.length > 0) { - const anonymousCount = Number(anonymousResult[0].charCount); + if (anonymousResult.length === 0) return; - const existingAuth = await tx.select({ charCount: userTtsChars.charCount }) - .from(userTtsChars) - .where(and(eq(userTtsChars.userId, authenticatedUserId), eq(userTtsChars.date, today))); + const anonymousCount = Number(anonymousResult[0].charCount); - if (existingAuth.length === 0) { - await tx.insert(userTtsChars).values({ userId: authenticatedUserId, date: today, charCount: anonymousCount }); - } else { - const existingCount = Number(existingAuth[0].charCount); - if (anonymousCount > existingCount) { - await tx.update(userTtsChars) - .set({ charCount: anonymousCount, updatedAt: new Date() }) - .where(and(eq(userTtsChars.userId, authenticatedUserId), eq(userTtsChars.date, today))); - } - } + const existingAuth = await safeDb().select({ charCount: userTtsChars.charCount }) + .from(userTtsChars) + .where(and(eq(userTtsChars.userId, authenticatedUserId), eq(userTtsChars.date, dateValue))); - // Remove anonymous user's record - await tx.delete(userTtsChars) - .where(and(eq(userTtsChars.userId, anonymousUserId), eq(userTtsChars.date, today))); + if (existingAuth.length === 0) { + await safeDb().insert(userTtsChars) + .values({ userId: authenticatedUserId, date: dateValue, charCount: anonymousCount }); + } else { + const existingCount = Number(existingAuth[0].charCount); + if (anonymousCount > existingCount) { + await safeDb().update(userTtsChars) + .set({ charCount: anonymousCount, updatedAt }) + .where(and(eq(userTtsChars.userId, authenticatedUserId), eq(userTtsChars.date, dateValue))); } - }); + } + + await safeDb().delete(userTtsChars) + .where(and(eq(userTtsChars.userId, anonymousUserId), eq(userTtsChars.date, dateValue))); } /** @@ -313,11 +330,11 @@ export class RateLimiter { const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - daysToKeep); const cutoffDateStr = cutoffDate.toISOString().split('T')[0]; + const cutoffDateValue = cutoffDateStr as unknown as UserTtsCharsDateValue; if (!db) return; // Assuming string comparison works for YYYY-MM-DD - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await safeDb().delete(userTtsChars).where(lt(userTtsChars.date, cutoffDateStr as any)); + await safeDb().delete(userTtsChars).where(lt(userTtsChars.date, cutoffDateValue)); } private getResetTime(): Date { diff --git a/tests/export.spec.ts b/tests/export.spec.ts index 2d3d727..1e297ec 100644 --- a/tests/export.spec.ts +++ b/tests/export.spec.ts @@ -46,11 +46,12 @@ async function downloadFullAudiobook(page: Page, timeoutMs = 60_000) { page.waitForEvent('download', { timeout: timeoutMs }), fullDownloadButton.click(), ]); - const downloadedPath = await download.path(); - expect(downloadedPath).toBeTruthy(); - const stats = fs.statSync(downloadedPath!); + const tempFilePath = `./tmp_download_${Date.now()}.mp3`; + await download.saveAs(tempFilePath); + expect(fs.existsSync(tempFilePath)).toBeTruthy(); + const stats = fs.statSync(tempFilePath); expect(stats.size).toBeGreaterThan(0); - return downloadedPath!; + return tempFilePath; } async function getAudioDurationSeconds(filePath: string) { @@ -73,6 +74,40 @@ async function expectChaptersBackendState(page: Page, bookId: string) { return json; } +/** + * Poll the backend until the chapter count stops changing for `stableMs` milliseconds. + * This helps avoid race conditions where in-flight TTS requests complete after cancellation. + */ +async function waitForStableChapterCount( + page: Page, + bookId: string, + { stableMs = 2000, timeoutMs = 30000 } = {} +): Promise<{ count: number; json: ReturnType extends Promise ? T : never }> { + const startTime = Date.now(); + let lastCount = -1; + let lastStableTime = Date.now(); + let lastJson: Awaited> | null = null; + + while (Date.now() - startTime < timeoutMs) { + const json = await expectChaptersBackendState(page, bookId); + const currentCount = json.chapters?.length ?? 0; + lastJson = json; + + if (currentCount !== lastCount) { + lastCount = currentCount; + lastStableTime = Date.now(); + } else if (Date.now() - lastStableTime >= stableMs) { + // Count has been stable for stableMs + return { count: currentCount, json }; + } + + await page.waitForTimeout(200); + } + + // Timeout reached, return whatever we have + return { count: lastCount, json: lastJson! }; +} + async function resetAudiobookById(page: Page, bookId: string) { const res = await page.request.delete(`/api/audiobook?bookId=${bookId}`); expect(res.ok() || res.status() === 404).toBeTruthy(); @@ -96,264 +131,336 @@ async function resetAudiobookIfPresent(page: Page) { await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 }); } -test.describe('Audiobook export', () => { - test.describe.configure({ mode: 'serial', timeout: 120_000 }); +test('exports full MP3 audiobook for PDF using mocked 10s TTS sample', async ({ page }) => { + // Ensure TTS is mocked and app is ready + await setupTest(page); - test('exports full MP3 audiobook for PDF using mocked 10s TTS sample', async ({ page }) => { - // Ensure TTS is mocked and app is ready - await setupTest(page); + // Upload and open the sample PDF in the viewer + await uploadAndDisplay(page, 'sample.pdf'); - // Upload and open the sample PDF in the viewer - await uploadAndDisplay(page, 'sample.pdf'); + // Capture the generated document/book id from the /pdf/[id] URL + const bookId = await getBookIdFromUrl(page, 'pdf'); + await resetAudiobookById(page, bookId); - // Capture the generated document/book id from the /pdf/[id] URL - const bookId = await getBookIdFromUrl(page, 'pdf'); - await resetAudiobookById(page, bookId); + // Open the audiobook export modal from the header button + await openExportModal(page); - // Open the audiobook export modal from the header button - await openExportModal(page); + // While there are no chapters yet, we can still switch the container format. + // Choose MP3 so we can validate MP3 duration end-to-end. + await setContainerFormatToMP3(page); - // While there are no chapters yet, we can still switch the container format. - // Choose MP3 so we can validate MP3 duration end-to-end. - await setContainerFormatToMP3(page); + // Start generation; this will call the mocked /api/tts which returns a 10s sample.mp3 per page + await startGeneration(page); - // Start generation; this will call the mocked /api/tts which returns a 10s sample.mp3 per page - await startGeneration(page); + // Wait for chapters list to appear and populate at least two items (Pages 1 and 2) + await waitForChaptersHeading(page); + const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' }); + await expect(chapterActionsButtons).toHaveCount(2, { timeout: 60_000 }); - // Wait for chapters list to appear and populate at least two items (Pages 1 and 2) - await waitForChaptersHeading(page); - const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' }); - await expect(chapterActionsButtons).toHaveCount(2, { timeout: 60_000 }); + // Trigger full download from the FRONTEND button and capture via Playwright's download API. + // The button label can be "Full Download (MP3)" or "Full Download (M4B)" depending on + // the server-side detected format, so match more loosely on the accessible name. + const downloadedPath = await downloadFullAudiobook(page); - // Trigger full download from the FRONTEND button and capture via Playwright's download API. - // The button label can be "Full Download (MP3)" or "Full Download (M4B)" depending on - // the server-side detected format, so match more loosely on the accessible name. - const downloadedPath = await downloadFullAudiobook(page); + // Use ffprobe (same toolchain as the server) to validate the combined audio duration. + // The TTS route is mocked to return a 10s sample.mp3 for each page, so with at least + // two chapters we should be close to ~20 seconds of audio. + const durationSeconds = await getAudioDurationSeconds(downloadedPath); + // Duration must be within a reasonable window around 20 seconds to allow + // for encoding variations and container overhead. + expect(durationSeconds).toBeGreaterThan(18); + expect(durationSeconds).toBeLessThan(22); - // Use ffprobe (same toolchain as the server) to validate the combined audio duration. - // The TTS route is mocked to return a 10s sample.mp3 for each page, so with at least - // two chapters we should be close to ~20 seconds of audio. - const durationSeconds = await getAudioDurationSeconds(downloadedPath); - // Duration must be within a reasonable window around 20 seconds to allow - // for encoding variations and container overhead. - expect(durationSeconds).toBeGreaterThan(18); - expect(durationSeconds).toBeLessThan(22); + // Also check the chapter metadata API for consistency + const json = await expectChaptersBackendState(page, bookId); + expect(json.exists).toBe(true); + expect(Array.isArray(json.chapters)).toBe(true); + expect(json.chapters.length).toBeGreaterThanOrEqual(2); + for (const ch of json.chapters) { + expect(ch.duration).toBeGreaterThan(0); + } - // Also check the chapter metadata API for consistency - const json = await expectChaptersBackendState(page, bookId); - expect(json.exists).toBe(true); - expect(Array.isArray(json.chapters)).toBe(true); - expect(json.chapters.length).toBeGreaterThanOrEqual(2); - for (const ch of json.chapters) { - expect(ch.duration).toBeGreaterThan(0); - } - - await resetAudiobookIfPresent(page); - }); - - test('handles partial EPUB audiobook generation, cancel, and full download of partial audiobook', async ({ page }) => { - await setupTest(page); - - // Upload and open the sample EPUB in the viewer - await uploadAndDisplay(page, 'sample.epub'); - - // URL should now be /epub/[id] - const bookId = await getBookIdFromUrl(page, 'epub'); - await resetAudiobookById(page, bookId); - - // Open the audiobook export modal from the header button - await openExportModal(page); - - // Set container format to MP3 - await setContainerFormatToMP3(page); - - // Start generation - await startGeneration(page); - - // Progress card should appear with a Cancel button while chapters are being generated - const cancelButton = page.getByRole('button', { name: 'Cancel' }); - await expect(cancelButton).toBeVisible({ timeout: 60_000 }); - - await expect(page.getByRole('heading', { name: 'Chapters' })).toBeVisible({ timeout: 60_000 }); - - // Wait until at least 3 chapters are listed in the UI; record the exact count at the - // moment we decide to cancel, and assert that no additional chapters are added afterward. - const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' }); - await expect(chapterActionsButtons.nth(2)).toBeVisible({ timeout: 120_000 }); - const chapterCountBeforeCancel = await chapterActionsButtons.count(); - expect(chapterCountBeforeCancel).toBeGreaterThanOrEqual(3); - - // Now cancel the in-flight generation - await cancelButton.click(); - - // After cancellation, the inline progress card's Cancel button should be gone - await expect(page.getByRole('button', { name: 'Cancel' })).toHaveCount(0); - - // After cancellation, determine the canonical chapter count from the backend and - // assert that the UI eventually reflects this count. Some in-flight chapters may - // complete right as we cancel, so we treat the backend state as source of truth. - const jsonAfterCancel = await expectChaptersBackendState(page, bookId); - expect(jsonAfterCancel.exists).toBe(true); - expect(Array.isArray(jsonAfterCancel.chapters)).toBe(true); - const chapterCountAfterCancel = jsonAfterCancel.chapters.length; - expect(chapterCountAfterCancel).toBeGreaterThanOrEqual(chapterCountBeforeCancel); - - // Wait for the UI to reflect the final backend chapter count to avoid race - // conditions between the modal's soft refresh and our assertions. - await expect(chapterActionsButtons).toHaveCount(chapterCountAfterCancel, { timeout: 60_000 }); - - // The Full Download button should still be available for the partially generated audiobook - const downloadedPath = await downloadFullAudiobook(page); - - const durationSeconds = await getAudioDurationSeconds(downloadedPath); - expect(durationSeconds).toBeGreaterThan(25); - expect(durationSeconds).toBeLessThan(300); - - // Backend should still reflect the same number of chapters as when we first - // observed the stabilized post-cancellation state, and should not contain - // additional "impartial" chapters produced after cancellation. - const json = await expectChaptersBackendState(page, bookId); - expect(json.exists).toBe(true); - expect(Array.isArray(json.chapters)).toBe(true); - expect(json.chapters.length).toBe(chapterCountAfterCancel); - - await resetAudiobookIfPresent(page); - }); - - test('downloads a single chapter via chapter actions menu (PDF)', async ({ page }) => { - await setupTest(page); - await uploadAndDisplay(page, 'sample.pdf'); - - const bookId = await getBookIdFromUrl(page, 'pdf'); - await resetAudiobookById(page, bookId); - - await openExportModal(page); - await setContainerFormatToMP3(page); - await startGeneration(page); - - await waitForChaptersHeading(page); - - // Wait for at least one chapter row to appear (one "Chapter actions" button) - const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' }); - await expect(chapterActionsButtons.first()).toBeVisible({ timeout: 90_000 }); - - // Download via frontend button - const downloadedPath = await downloadFullAudiobook(page); - - const durationSeconds = await getAudioDurationSeconds(downloadedPath); - // For EPUB we just assert a sane non-trivial duration; at least one 10s mocked chapter. - expect(durationSeconds).toBeGreaterThan(9); - expect(durationSeconds).toBeLessThan(300); - - await resetAudiobookIfPresent(page); - }); - - test('reset removes all generated chapters for a PDF audiobook', async ({ page }) => { - await setupTest(page); - await uploadAndDisplay(page, 'sample.pdf'); - - const bookId = await getBookIdFromUrl(page, 'pdf'); - await resetAudiobookById(page, bookId); - - await openExportModal(page); - await setContainerFormatToMP3(page); - await startGeneration(page); - - await waitForChaptersHeading(page); - - // Wait for Reset button to become visible, indicating resumable/generated state - const resetButton = page.getByRole('button', { name: 'Reset' }); - await expect(resetButton).toBeVisible({ timeout: 120_000 }); - - await resetButton.click(); - - // Confirm in the Reset Audiobook dialog - await expect(page.getByRole('heading', { name: 'Reset Audiobook' })).toBeVisible({ timeout: 15000 }); - const confirmReset = page.getByRole('button', { name: 'Reset' }).last(); - await confirmReset.click(); - - // After reset, generation should be startable again - await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 }); - - // Backend should report no existing chapters for this bookId - const res = await page.request.get(`/api/audiobook/status?bookId=${bookId}`); - expect(res.ok()).toBeTruthy(); - const json = await res.json(); - expect(json.exists).toBe(false); - expect(Array.isArray(json.chapters)).toBe(true); - expect(json.chapters.length).toBe(0); - }); - - test('regenerates a PDF audiobook chapter and preserves chapter count and full download', async ({ page }) => { - await setupTest(page); - await uploadAndDisplay(page, 'sample.pdf'); - - // Extract bookId from /pdf/[id] URL (for backend verification later) - const bookId = await getBookIdFromUrl(page, 'pdf'); - await resetAudiobookById(page, bookId); - - // Open Export Audiobook modal - await openExportModal(page); - - // Set container format to MP3 - await setContainerFormatToMP3(page); - - // Start generation - await startGeneration(page); - - // Wait for chapters to appear - await waitForChaptersHeading(page); - - const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' }); - // Ensure we have at least two chapters for this PDF - await expect(chapterActionsButtons.nth(1)).toBeVisible({ timeout: 60_000 }); - const chapterCountBefore = await chapterActionsButtons.count(); - expect(chapterCountBefore).toBeGreaterThanOrEqual(2); - - // Open the actions menu for the first chapter and trigger Regenerate - const firstChapterActions = chapterActionsButtons.first(); - await firstChapterActions.click(); - - // In the headlessui Menu, each option is a menuitem. Use that role instead of button. - const regenerateMenuItem = page.getByRole('menuitem', { name: /Regenerate/i }); - await expect(regenerateMenuItem).toBeVisible({ timeout: 15000 }); - await regenerateMenuItem.click(); - - // During regeneration, the row may show a "Regenerating" label; wait for any such - // indicator to disappear, signaling completion. - const regeneratingLabel = page.getByText(/Regenerating/); - await expect(regeneratingLabel).toHaveCount(0, { timeout: 120_000 }); - - // After regeneration completes in the UI, verify backend chapter state is fully updated - // before triggering a full download to avoid races with ffmpeg concat on Alpine. - const backendStateAfterRegenerate = await expectChaptersBackendState(page, bookId); - expect(backendStateAfterRegenerate.exists).toBe(true); - expect(Array.isArray(backendStateAfterRegenerate.chapters)).toBe(true); - expect(backendStateAfterRegenerate.chapters.length).toBe(chapterCountBefore); - for (const ch of backendStateAfterRegenerate.chapters) { - expect(ch.duration).toBeGreaterThan(0); - } - - // Chapter count should remain exactly the same after regeneration (no duplicates) - await expect(chapterActionsButtons).toHaveCount(chapterCountBefore, { timeout: 20_000 }); - - // Full Download should still work and produce a valid combined audiobook - const downloadedPath = await downloadFullAudiobook(page); - - const durationSeconds = await getAudioDurationSeconds(downloadedPath); - // With two mocked 10s chapters we expect roughly 20s; allow a small window. - expect(durationSeconds).toBeGreaterThan(18); - expect(durationSeconds).toBeLessThan(22); - - // Backend should still report the same number of chapters and valid durations - const json = await expectChaptersBackendState(page, bookId); - expect(json.exists).toBe(true); - expect(Array.isArray(json.chapters)).toBe(true); - expect(json.chapters.length).toBe(chapterCountBefore); - for (const ch of json.chapters) { - expect(ch.duration).toBeGreaterThan(0); - } - - await resetAudiobookIfPresent(page); - }); + await resetAudiobookIfPresent(page); +}); + +test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async ({ page }) => { + await setupTest(page); + + // Upload and open the sample EPUB in the viewer + await uploadAndDisplay(page, 'sample.epub'); + + // URL should now be /epub/[id] + const bookId = await getBookIdFromUrl(page, 'epub'); + await resetAudiobookById(page, bookId); + + // Open the audiobook export modal from the header button + await openExportModal(page); + + // Set container format to MP3 + await setContainerFormatToMP3(page); + + // Start generation + await startGeneration(page); + + // Progress card should appear with a Cancel button while chapters are being generated + const cancelButton = page.getByRole('button', { name: 'Cancel' }); + await expect(cancelButton).toBeVisible({ timeout: 60_000 }); + + await expect(page.getByRole('heading', { name: 'Chapters' })).toBeVisible({ timeout: 60_000 }); + + // Wait until at least 3 chapters are listed in the UI; record the exact count at the + // moment we decide to cancel, and assert that no additional chapters are added afterward. + const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' }); + await expect(chapterActionsButtons.nth(2)).toBeVisible({ timeout: 120_000 }); + const chapterCountBeforeCancel = await chapterActionsButtons.count(); + expect(chapterCountBeforeCancel).toBeGreaterThanOrEqual(3); + + // Now cancel the in-flight generation + await cancelButton.click(); + + // After cancellation, the inline progress card's Cancel button should be gone + await expect(page.getByRole('button', { name: 'Cancel' })).toHaveCount(0); + + // After cancellation, wait for the chapter count to stabilize. In-flight TTS + // requests may still complete after we click cancel, so we poll until the + // count stops changing for a brief period. + const { count: chapterCountAfterCancel, json: jsonAfterCancel } = await waitForStableChapterCount( + page, + bookId, + { stableMs: 2000, timeoutMs: 30000 } + ); + expect(jsonAfterCancel.exists).toBe(true); + expect(Array.isArray(jsonAfterCancel.chapters)).toBe(true); + expect(chapterCountAfterCancel).toBeGreaterThanOrEqual(chapterCountBeforeCancel); + + // Wait for the UI to reflect the final backend chapter count to avoid race + // conditions between the modal's soft refresh and our assertions. + await expect(chapterActionsButtons).toHaveCount(chapterCountAfterCancel, { timeout: 60_000 }); + + // The Full Download button should still be available for the partially generated audiobook + const downloadedPath = await downloadFullAudiobook(page); + + const durationSeconds = await getAudioDurationSeconds(downloadedPath); + expect(durationSeconds).toBeGreaterThan(25); + expect(durationSeconds).toBeLessThan(300); + + // Backend should still reflect the same number of chapters as when we first + // observed the stabilized post-cancellation state. + const json = await expectChaptersBackendState(page, bookId); + expect(json.exists).toBe(true); + expect(Array.isArray(json.chapters)).toBe(true); + expect(json.chapters.length).toBe(chapterCountAfterCancel); + + await resetAudiobookIfPresent(page); +}); + +test('exports a single MP3 audiobook PDF page via chapters menu', async ({ page }) => { + await setupTest(page); + await uploadAndDisplay(page, 'sample.pdf'); + + const bookId = await getBookIdFromUrl(page, 'pdf'); + await resetAudiobookById(page, bookId); + + await openExportModal(page); + await setContainerFormatToMP3(page); + await startGeneration(page); + + await waitForChaptersHeading(page); + + // Wait for at least one chapter row to appear (one "Chapter actions" button) + const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' }); + await expect(chapterActionsButtons.first()).toBeVisible({ timeout: 90_000 }); + + // Download via frontend button + const downloadedPath = await downloadFullAudiobook(page); + + const durationSeconds = await getAudioDurationSeconds(downloadedPath); + // For EPUB we just assert a sane non-trivial duration; at least one 10s mocked chapter. + expect(durationSeconds).toBeGreaterThan(9); + expect(durationSeconds).toBeLessThan(300); + + await resetAudiobookIfPresent(page); +}); + +test('resets all MP3 audiobook PDF pages', async ({ page }) => { + await setupTest(page); + await uploadAndDisplay(page, 'sample.pdf'); + + const bookId = await getBookIdFromUrl(page, 'pdf'); + await resetAudiobookById(page, bookId); + + await openExportModal(page); + await setContainerFormatToMP3(page); + await startGeneration(page); + + await waitForChaptersHeading(page); + + // Wait for Reset button to become visible, indicating resumable/generated state + const resetButton = page.getByRole('button', { name: 'Reset' }); + await expect(resetButton).toBeVisible({ timeout: 120_000 }); + + await resetButton.click(); + + // Confirm in the Reset Audiobook dialog + await expect(page.getByRole('heading', { name: 'Reset Audiobook' })).toBeVisible({ timeout: 15000 }); + const confirmReset = page.getByRole('button', { name: 'Reset' }).last(); + await confirmReset.click(); + + // After reset, generation should be startable again + await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 }); + + // Backend should report no existing chapters for this bookId + const res = await page.request.get(`/api/audiobook/status?bookId=${bookId}`); + expect(res.ok()).toBeTruthy(); + const json = await res.json(); + expect(json.exists).toBe(false); + expect(Array.isArray(json.chapters)).toBe(true); + expect(json.chapters.length).toBe(0); +}); + +test('regenerates a single MP3 audiobook PDF page and exports full audiobook', async ({ page }) => { + await setupTest(page); + await uploadAndDisplay(page, 'sample.pdf'); + + // Extract bookId from /pdf/[id] URL (for backend verification later) + const bookId = await getBookIdFromUrl(page, 'pdf'); + await resetAudiobookById(page, bookId); + + // Open Export Audiobook modal + await openExportModal(page); + + // Set container format to MP3 + await setContainerFormatToMP3(page); + + // Start generation + await startGeneration(page); + + // Wait for chapters to appear + await waitForChaptersHeading(page); + + const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' }); + // Ensure we have at least two chapters for this PDF + await expect(chapterActionsButtons.nth(1)).toBeVisible({ timeout: 60_000 }); + const chapterCountBefore = await chapterActionsButtons.count(); + expect(chapterCountBefore).toBeGreaterThanOrEqual(2); + + // Open the actions menu for the first chapter and trigger Regenerate + const firstChapterActions = chapterActionsButtons.first(); + await firstChapterActions.click(); + + // In the headlessui Menu, each option is a menuitem. Use that role instead of button. + const regenerateMenuItem = page.getByRole('menuitem', { name: /Regenerate/i }); + await expect(regenerateMenuItem).toBeVisible({ timeout: 15000 }); + await regenerateMenuItem.click(); + + // During regeneration, the row may show a "Regenerating" label; wait for any such + // indicator to disappear, signaling completion. + const regeneratingLabel = page.getByText(/Regenerating/); + await expect(regeneratingLabel).toHaveCount(0, { timeout: 120_000 }); + + // After regeneration completes in the UI, verify backend chapter state is fully updated + // before triggering a full download to avoid races with ffmpeg concat on Alpine. + const backendStateAfterRegenerate = await expectChaptersBackendState(page, bookId); + expect(backendStateAfterRegenerate.exists).toBe(true); + expect(Array.isArray(backendStateAfterRegenerate.chapters)).toBe(true); + expect(backendStateAfterRegenerate.chapters.length).toBe(chapterCountBefore); + for (const ch of backendStateAfterRegenerate.chapters) { + expect(ch.duration).toBeGreaterThan(0); + } + + // Chapter count should remain exactly the same after regeneration (no duplicates) + await expect(chapterActionsButtons).toHaveCount(chapterCountBefore, { timeout: 20_000 }); + + // Full Download should still work and produce a valid combined audiobook + const downloadedPath = await downloadFullAudiobook(page); + + const durationSeconds = await getAudioDurationSeconds(downloadedPath); + // With two mocked 10s chapters we expect roughly 20s; allow a small window. + expect(durationSeconds).toBeGreaterThan(18); + expect(durationSeconds).toBeLessThan(22); + + // Backend should still report the same number of chapters and valid durations + const json = await expectChaptersBackendState(page, bookId); + expect(json.exists).toBe(true); + expect(Array.isArray(json.chapters)).toBe(true); + expect(json.chapters.length).toBe(chapterCountBefore); + for (const ch of json.chapters) { + expect(ch.duration).toBeGreaterThan(0); + } + + await resetAudiobookIfPresent(page); +}); + +test('resumes audiobook when a chapter is missing and full download succeeds (PDF)', async ({ page }) => { + await setupTest(page); + await uploadAndDisplay(page, 'sample.pdf'); + + const bookId = await getBookIdFromUrl(page, 'pdf'); + await resetAudiobookById(page, bookId); + + await openExportModal(page); + await setContainerFormatToMP3(page); + await startGeneration(page); + + await waitForChaptersHeading(page); + const chapterActionsButtons = page.getByRole('button', { name: 'Chapter actions' }); + await expect(chapterActionsButtons).toHaveCount(2, { timeout: 60_000 }); + + // Delete the first chapter via the backend API so the audiobook has a missing index (0). + // This is more reliable than clicking through the chapter actions menu in headless runs. + const deleteRes = await page.request.delete(`/api/audiobook/chapter?bookId=${bookId}&chapterIndex=0`); + expect(deleteRes.ok()).toBeTruthy(); + + // Wait for backend to reflect only one remaining chapter (index 1). + await expect + .poll(async () => { + const json = await expectChaptersBackendState(page, bookId); + return json.chapters?.length ?? 0; + }, { timeout: 30_000 }) + .toBe(1); + + const jsonAfterDelete = await expectChaptersBackendState(page, bookId); + expect(jsonAfterDelete.exists).toBe(true); + expect(Array.isArray(jsonAfterDelete.chapters)).toBe(true); + expect(jsonAfterDelete.chapters.length).toBe(1); + expect(jsonAfterDelete.chapters[0]?.index).toBe(1); + + // Close and reopen the modal to ensure "resume" loads the missing placeholder from the backend. + await page.getByRole('button', { name: 'Close' }).click(); + await expect(page.getByRole('heading', { name: 'Export Audiobook' })).toHaveCount(0); + await openExportModal(page); + + await waitForChaptersHeading(page); + await expect(page.getByText(/Missing •/)).toHaveCount(1, { timeout: 15_000 }); + await expect(page.getByRole('button', { name: 'Resume' })).toBeVisible({ timeout: 15_000 }); + + // Resume should regenerate the missing chapter and allow a full download to succeed. + await page.getByRole('button', { name: 'Resume' }).click(); + + // Wait for backend to have both chapters again. + await expect + .poll(async () => { + const json = await expectChaptersBackendState(page, bookId); + return json.chapters?.length ?? 0; + }, { timeout: 120_000 }) + .toBe(2); + + // UI should also stop showing a missing placeholder after resume completes. + await expect(page.getByText(/Missing •/)).toHaveCount(0, { timeout: 120_000 }); + + const downloadedPath = await downloadFullAudiobook(page); + const durationSeconds = await getAudioDurationSeconds(downloadedPath); + expect(durationSeconds).toBeGreaterThan(18); + expect(durationSeconds).toBeLessThan(22); + + const jsonAfterResume = await expectChaptersBackendState(page, bookId); + expect(jsonAfterResume.exists).toBe(true); + expect(Array.isArray(jsonAfterResume.chapters)).toBe(true); + expect(jsonAfterResume.chapters.length).toBe(2); + expect(jsonAfterResume.chapters.map((c: { index: number }) => c.index).sort()).toEqual([0, 1]); + for (const ch of jsonAfterResume.chapters) { + expect(ch.duration).toBeGreaterThan(0); + } + + await resetAudiobookIfPresent(page); }); diff --git a/tests/helpers.ts b/tests/helpers.ts index 3121469..de3cfb3 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -6,50 +6,6 @@ const DIR = './tests/files/'; const TTS_MOCK_PATH = path.join(__dirname, 'files', 'sample.mp3'); let ttsMockBuffer: Buffer | null = null; -type RateLimitStatusResponse = { - authEnabled: boolean; - userType?: 'anonymous' | 'authenticated' | 'unauthenticated'; -}; - -async function getRateLimitStatus(page: Page): Promise { - try { - const res = await page.request.get('/api/rate-limit/status'); - if (!res.ok()) return null; - return (await res.json()) as RateLimitStatusResponse; - } catch { - return null; - } -} - -async function ensureAnonymousSession(page: Page): Promise { - const initial = await getRateLimitStatus(page); - if (!initial) return; - if (!initial.authEnabled) return; - if (initial.userType && initial.userType !== 'unauthenticated') return; - - // Create a session cookie for this test context. - // This avoids races where the app makes authenticated API calls before AuthLoader finishes. - try { - await page.request.post('/api/auth/sign-in/anonymous', { data: {} }); - } catch { - // ignore - } - - // Wait until the server sees us as anonymous/authenticated (i.e. cookie persisted). - const deadline = Date.now() + 15_000; - // eslint-disable-next-line no-constant-condition - while (true) { - const next = await getRateLimitStatus(page); - if (next && (!next.authEnabled || (next.userType && next.userType !== 'unauthenticated'))) { - return; - } - if (Date.now() > deadline) { - throw new Error('Timed out waiting for anonymous auth session in tests'); - } - await page.waitForTimeout(200); - } -} - async function ensureTtsRouteMock(page: Page) { if (!ttsMockBuffer) { ttsMockBuffer = fs.readFileSync(TTS_MOCK_PATH); From 2c23dc904371c4cf66dfcdd874943f72f5aff2c9 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 3 Feb 2026 12:17:30 -0700 Subject: [PATCH 14/55] refactor(db): remove conditional database usage from API layer Ensure all metadata operations consistently use the database layer, removing filesystem-only fallback paths and conditional DB checks. - Simplified migration scripts to always run on startup - Updated document/audiobook APIs to always query DB - Added ensureDbIndexed() calls across all routes - Extracted test namespace utilities to dedicated module - Removed migration-manager.ts (functionality consolidated) - Updated rate limiter to assume DB is always available BREAKING CHANGE: Database is now required in all configurations. When auth is disabled, SQLite is used by default at /app/docstore/sqlite3.db. --- template.env => .env.example | 6 +- README.md | 11 +- drizzle.config.pg.ts | 6 +- drizzle/scripts/generate.mjs | 16 +- .../{migrate-if-auth.mjs => migrate.mjs} | 9 - package.json | 4 +- src/app/api/audiobook/chapter/route.ts | 99 +++---- src/app/api/audiobook/route.ts | 132 +++++---- src/app/api/audiobook/status/route.ts | 75 ++---- src/app/api/documents/route.ts | 135 +++------- src/app/api/user/claim/route.ts | 27 +- src/components/auth/ClaimDataModal.tsx | 6 +- src/db/index.ts | 6 - src/lib/server/claim-data.ts | 183 +------------ src/lib/server/db-indexing.ts | 254 ++++++++++++++++++ src/lib/server/docstore.ts | 52 +--- src/lib/server/migration-manager.ts | 178 ------------ src/lib/server/rate-limiter.ts | 12 - src/lib/server/test-namespace.ts | 30 +++ tests/export.spec.ts | 123 ++++++--- 20 files changed, 596 insertions(+), 768 deletions(-) rename template.env => .env.example (84%) rename drizzle/scripts/{migrate-if-auth.mjs => migrate.mjs} (72%) create mode 100644 src/lib/server/db-indexing.ts delete mode 100644 src/lib/server/migration-manager.ts create mode 100644 src/lib/server/test-namespace.ts diff --git a/template.env b/.env.example similarity index 84% rename from template.env rename to .env.example index 6e50229..f0aa30c 100644 --- a/template.env +++ b/.env.example @@ -9,12 +9,14 @@ API_KEY=api_key_optional # Path to your local whisper.cpp CLI binary for STT timestamp generation WHISPER_CPP_BIN=/whisper.cpp/build/bin/whisper-cli +# Backend DB used for server-side metadata (documents/audiobooks) and, when auth is enabled, auth tables. +# Defaults to SQLite at docstore/sqlite3.db when not set. +POSTGRES_URL= + # Auth (recommended for contributors and public instances) # (Optional) Auth is only enabled when **both** BETTER_AUTH_SECRET and BETTER_AUTH_URL are set BETTER_AUTH_URL=http://localhost:3003 # Your externally facing URL for this app BETTER_AUTH_SECRET=some_random_secret_key # Generate with `openssl rand -base64 32` -# (Optional) Backend DB used only when authentication is enabled, defaults to SQLite when not set -POSTGRES_URL= # (Optional) Sign in w/ GitHub Configuration GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= diff --git a/README.md b/README.md index 3753019..040c20c 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,8 @@ OpenReader WebUI is an open source text to speech document reader web app built > - `API_BASE` should point to your TTS API server's base URL (if running Kokoro-FastAPI locally in Docker, use `http://host.docker.internal:8880/v1`). > - `BETTER_AUTH_URL` should be your externally-facing URL for this app (for example `https://reader.example.com` or `http://localhost:3003`). > - To enable auth, set **both** `BETTER_AUTH_URL` and `BETTER_AUTH_SECRET` generated with `openssl rand -base64 32`. - > - If you set `POSTGRES_URL`, the container will attempt to run migrations against it. Ensure the database is accessible. + > - OpenReader always uses a backend DB for server-side metadata. By default it uses SQLite at `/app/docstore/sqlite3.db` (persisted when `/app/docstore` is mounted). + > - If you set `POSTGRES_URL`, the container uses Postgres instead of SQLite and will run migrations against it on startup. Ensure the database is accessible.
Docker environment variables (Click to expand) @@ -85,7 +86,7 @@ OpenReader WebUI is an open source text to speech document reader web app built | `API_KEY` | Default TTS API key | `none` or your provider key | | `BETTER_AUTH_URL` | Enables auth when set with `BETTER_AUTH_SECRET` | External URL for this app, e.g. `http://localhost:3003` or `https://reader.example.com` | | `BETTER_AUTH_SECRET` | Enables auth when set with `BETTER_AUTH_URL` | Generate with `openssl rand -base64 32` | - | `POSTGRES_URL` | Use Postgres for auth storage instead of SQLite | If set, startup migrations target Postgres | + | `POSTGRES_URL` | Use Postgres for server DB (metadata + auth tables) instead of SQLite | If set, startup migrations target Postgres | | `GITHUB_CLIENT_ID` | Optional GitHub OAuth sign-in | Requires `GITHUB_CLIENT_SECRET` | | `GITHUB_CLIENT_SECRET` | Optional GitHub OAuth sign-in | Requires `GITHUB_CLIENT_ID` | @@ -241,7 +242,7 @@ Optionally required for different features: 3. Configure the environment: ```bash - cp template.env .env + cp .env.example .env # Edit .env with your configuration settings ``` @@ -258,10 +259,10 @@ Optionally required for different features: > > Note: The base URL for the TTS API should be accessible and relative to the Next.js server -4. Run auth DB migrations: +4. Run DB migrations: - **Production / Docker**: Migrations run automatically on startup via `pnpm start`. - - **Development**: Run explicitly: + - **Development**: When using `pnpm dev`, it needs to be run explicitly: ```bash pnpm migrate diff --git a/drizzle.config.pg.ts b/drizzle.config.pg.ts index af2aded..75b384e 100644 --- a/drizzle.config.pg.ts +++ b/drizzle.config.pg.ts @@ -3,9 +3,11 @@ import * as dotenv from 'dotenv'; dotenv.config(); -const url = process.env.POSTGRES_URL; +let url = process.env.POSTGRES_URL; if (!url) { - throw new Error('POSTGRES_URL is required for drizzle.config.pg.ts'); + //throw new Error('POSTGRES_URL is required for drizzle.config.pg.ts'); + console.warn('[drizzle.config.pg.ts] POSTGRES_URL is not set; using a placeholder URL.'); + url = 'postgresql://placeholder:placeholder@localhost:5432/placeholder'; } export default { diff --git a/drizzle/scripts/generate.mjs b/drizzle/scripts/generate.mjs index 448e8e6..e112795 100644 --- a/drizzle/scripts/generate.mjs +++ b/drizzle/scripts/generate.mjs @@ -20,18 +20,10 @@ function loadEnvFiles() { loadEnvFiles(); -const authEnabled = Boolean(process.env.BETTER_AUTH_SECRET && process.env.BETTER_AUTH_URL); - -if (!authEnabled) { - // When auth is disabled, the app must not touch sqlite/postgres at all. - console.log('[generate] Skipping (auth disabled). Missing BETTER_AUTH_SECRET and/or BETTER_AUTH_URL.'); - process.exit(0); -} - -if (!process.env.POSTGRES_URL) { - console.error('[generate] POSTGRES_URL is required to generate postgres migrations.'); - process.exit(1); -} +// if (!process.env.POSTGRES_URL) { +// console.error('[generate] POSTGRES_URL is required to generate postgres migrations.'); +// process.exit(1); +// } const extraArgs = process.argv.slice(2); diff --git a/drizzle/scripts/migrate-if-auth.mjs b/drizzle/scripts/migrate.mjs similarity index 72% rename from drizzle/scripts/migrate-if-auth.mjs rename to drizzle/scripts/migrate.mjs index c190068..bcc4154 100644 --- a/drizzle/scripts/migrate-if-auth.mjs +++ b/drizzle/scripts/migrate.mjs @@ -20,15 +20,6 @@ function loadEnvFiles() { loadEnvFiles(); -const authEnabled = Boolean(process.env.BETTER_AUTH_SECRET && process.env.BETTER_AUTH_URL); - -if (!authEnabled) { - // When auth is disabled, the app must not touch sqlite/postgres at all. - // That includes running migrations which can create/open DB files. - console.log('[migrate] Skipping (auth disabled). Missing BETTER_AUTH_SECRET and/or BETTER_AUTH_URL.'); - process.exit(0); -} - const extraArgs = process.argv.slice(2); const hasConfigArg = extraArgs.includes('--config'); diff --git a/package.json b/package.json index aa9db01..d376aa1 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,10 @@ "scripts": { "dev": "next dev --turbopack -p 3003", "build": "next build", - "start": "node drizzle/scripts/migrate-if-auth.mjs && next start -p 3003", + "start": "node drizzle/scripts/migrate.mjs && next start -p 3003", "lint": "next lint", "test": "playwright test", - "migrate": "node drizzle/scripts/migrate-if-auth.mjs", + "migrate": "node drizzle/scripts/migrate.mjs", "generate": "node drizzle/scripts/generate.mjs" }, "dependencies": { diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index 609d352..2c2dfbb 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -1,13 +1,15 @@ import { NextRequest, NextResponse } from 'next/server'; import { createReadStream, existsSync } from 'fs'; import { readdir, unlink } from 'fs/promises'; -import { join, resolve } from 'path'; -import { AUDIOBOOKS_V1_DIR, getUserAudiobookDir, UNCLAIMED_USER_ID, isAudiobooksV1Ready } from '@/lib/server/docstore'; +import { join } from 'path'; +import { AUDIOBOOKS_V1_DIR, getUserAudiobookDir, ensureAudiobooksV1Ready, isAudiobooksV1Ready } from '@/lib/server/docstore'; import { findStoredChapterByIndex } from '@/lib/server/audiobook'; import { db } from '@/db'; import { audiobooks, audiobookChapters } from '@/db/schema'; -import { and, eq, or } from 'drizzle-orm'; +import { and, eq, inArray } from 'drizzle-orm'; import { requireAuthContext } from '@/lib/server/auth'; +import { ensureDbIndexed } from '@/lib/server/db-indexing'; +import { applyOpenReaderTestNamespacePath, getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; export const dynamic = 'force-dynamic'; @@ -17,27 +19,14 @@ export const dynamic = 'force-dynamic'; * When auth is enabled, returns the user-specific directory. */ function getAudiobooksRootDir(request: NextRequest, userId: string | null, authEnabled: boolean): string { - const raw = request.headers.get('x-openreader-test-namespace')?.trim(); - - const getTestNamespacePath = (baseDir: string): string => { - if (!raw) return baseDir; - const safe = raw.replace(/[^a-zA-Z0-9._-]/g, ''); - if (!safe || safe === '.' || safe === '..' || safe.includes('..')) { - return baseDir; - } - const resolved = resolve(baseDir, safe); - if (!resolved.startsWith(resolve(baseDir) + '/')) { - return baseDir; - } - return resolved; - }; + const namespace = getOpenReaderTestNamespace(request.headers); if (!authEnabled || !userId) { - return getTestNamespacePath(AUDIOBOOKS_V1_DIR); + return applyOpenReaderTestNamespacePath(AUDIOBOOKS_V1_DIR, namespace); } const userDir = getUserAudiobookDir(userId); - return getTestNamespacePath(userDir); + return applyOpenReaderTestNamespacePath(userDir, namespace); } export async function GET(request: NextRequest) { @@ -60,6 +49,7 @@ export async function GET(request: NextRequest) { ); } + await ensureAudiobooksV1Ready(); if (!(await isAudiobooksV1Ready())) { return NextResponse.json( { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, @@ -70,30 +60,23 @@ export async function GET(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; const { userId, authEnabled } = ctxOrRes; + const testNamespace = getOpenReaderTestNamespace(request.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + const storageUserId = userId ?? unclaimedUserId; + const allowedUserIds = authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + + await ensureDbIndexed(); // Verify ownership with composite PK - allow access to user's own OR unclaimed audiobooks - if (authEnabled && db && userId) { - const [existingBook] = await db.select().from(audiobooks).where( - and( - eq(audiobooks.id, bookId), - or(eq(audiobooks.userId, userId), eq(audiobooks.userId, UNCLAIMED_USER_ID)) - ) - ); - if (!existingBook) { - return NextResponse.json({ error: 'Book not found' }, { status: 404 }); - } + const [existingBook] = await db + .select({ userId: audiobooks.userId }) + .from(audiobooks) + .where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds))); + if (!existingBook) { + return NextResponse.json({ error: 'Book not found' }, { status: 404 }); } - // Get the audiobook directory - check user's directory first, then unclaimed - let intermediateDir = join(getAudiobooksRootDir(request, userId, authEnabled), `${bookId}-audiobook`); - - // If not found in user's directory and auth is enabled, check unclaimed directory - if (!existsSync(intermediateDir) && authEnabled && userId) { - const unclaimedDir = join(getAudiobooksRootDir(request, UNCLAIMED_USER_ID, authEnabled), `${bookId}-audiobook`); - if (existsSync(unclaimedDir)) { - intermediateDir = unclaimedDir; - } - } + const intermediateDir = join(getAudiobooksRootDir(request, existingBook.userId, authEnabled), `${bookId}-audiobook`); const chapter = await findStoredChapterByIndex(intermediateDir, chapterIndex, request.signal); if (!chapter || !existsSync(chapter.filePath)) { @@ -160,6 +143,7 @@ export async function DELETE(request: NextRequest) { ); } + await ensureAudiobooksV1Ready(); if (!(await isAudiobooksV1Ready())) { return NextResponse.json( { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, @@ -170,27 +154,30 @@ export async function DELETE(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; const { userId, authEnabled } = ctxOrRes; + const testNamespace = getOpenReaderTestNamespace(request.headers); + const storageUserId = userId ?? getUnclaimedUserIdForNamespace(testNamespace); + + await ensureDbIndexed(); // Verify ownership and delete from DB with composite PK - if (authEnabled && db && userId) { - const [existingBook] = await db.select().from(audiobooks).where( - and(eq(audiobooks.id, bookId), eq(audiobooks.userId, userId)) - ); - if (!existingBook) { - return NextResponse.json({ error: 'Book not found' }, { status: 404 }); - } - - // Delete from DB - await db.delete(audiobookChapters).where( - and( - eq(audiobookChapters.bookId, bookId), - eq(audiobookChapters.userId, userId), - eq(audiobookChapters.chapterIndex, chapterIndex) - ), - ); + const [existingBook] = await db + .select({ userId: audiobooks.userId }) + .from(audiobooks) + .where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId))); + if (!existingBook) { + return NextResponse.json({ error: 'Book not found' }, { status: 404 }); } - const intermediateDir = join(getAudiobooksRootDir(request, userId, authEnabled), `${bookId}-audiobook`); + // Delete from DB + await db.delete(audiobookChapters).where( + and( + eq(audiobookChapters.bookId, bookId), + eq(audiobookChapters.userId, storageUserId), + eq(audiobookChapters.chapterIndex, chapterIndex), + ), + ); + + const intermediateDir = join(getAudiobooksRootDir(request, storageUserId, authEnabled), `${bookId}-audiobook`); const chapterPrefix = `${String(chapterIndex + 1).padStart(4, '0')}__`; const files = await readdir(intermediateDir).catch(() => []); for (const file of files) { diff --git a/src/app/api/audiobook/route.ts b/src/app/api/audiobook/route.ts index 5abfafe..ba57a95 100644 --- a/src/app/api/audiobook/route.ts +++ b/src/app/api/audiobook/route.ts @@ -2,17 +2,18 @@ import { NextRequest, NextResponse } from 'next/server'; import { spawn } from 'child_process'; import { readFile, writeFile, mkdir, unlink, rm, rename, readdir } from 'fs/promises'; import { existsSync, createReadStream } from 'fs'; -import { basename, join, resolve } from 'path'; +import { basename, join } from 'path'; import { randomUUID } from 'crypto'; -import { AUDIOBOOKS_V1_DIR, UNCLAIMED_USER_ID, isAudiobooksV1Ready, getUserAudiobookDir } from '@/lib/server/docstore'; +import { AUDIOBOOKS_V1_DIR, ensureAudiobooksV1Ready, isAudiobooksV1Ready, getUserAudiobookDir } from '@/lib/server/docstore'; import { encodeChapterFileName, encodeChapterTitleTag, listStoredChapters, ffprobeAudio, escapeFFMetadata } from '@/lib/server/audiobook'; import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; import { db } from '@/db'; import { audiobooks, audiobookChapters } from '@/db/schema'; -import { eq, and, or } from 'drizzle-orm'; -import { isAuthEnabled } from '@/lib/server/auth-config'; +import { eq, and, inArray } from 'drizzle-orm'; import { requireAuthContext } from '@/lib/server/auth'; +import { ensureDbIndexed } from '@/lib/server/db-indexing'; +import { applyOpenReaderTestNamespacePath, getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; export const dynamic = 'force-dynamic'; @@ -20,17 +21,8 @@ export const dynamic = 'force-dynamic'; * Apply test namespace to a directory path if present in request headers. */ function applyTestNamespace(baseDir: string, request: NextRequest): string { - const raw = request.headers.get('x-openreader-test-namespace')?.trim(); - if (!raw) return baseDir; - const safe = raw.replace(/[^a-zA-Z0-9._-]/g, ''); - if (!safe || safe === '.' || safe === '..' || safe.includes('..')) { - return baseDir; - } - const resolved = resolve(baseDir, safe); - if (!resolved.startsWith(resolve(baseDir) + '/')) { - return baseDir; - } - return resolved; + const namespace = getOpenReaderTestNamespace(request.headers); + return applyOpenReaderTestNamespacePath(baseDir, namespace); } /** @@ -183,6 +175,7 @@ export async function POST(request: NextRequest) { const data: ConversionRequest = await request.json(); const requestedFormat = data.format || 'm4b'; + await ensureAudiobooksV1Ready(); if (!(await isAudiobooksV1Ready())) { return NextResponse.json( { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, @@ -193,6 +186,8 @@ export async function POST(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; const userId = ctxOrRes.userId; + const testNamespace = getOpenReaderTestNamespace(request.headers); + const storageUserId = userId ?? getUnclaimedUserIdForNamespace(testNamespace); // Generate or use existing book ID const bookId = data.bookId || randomUUID(); @@ -202,19 +197,14 @@ export async function POST(request: NextRequest) { } // DB Check / Insert Audiobook - if (isAuthEnabled() && db && userId) { - const [existingBook] = await db.select().from(audiobooks).where( - and(eq(audiobooks.id, bookId), eq(audiobooks.userId, userId)) - ); - - if (!existingBook) { - await db.insert(audiobooks).values({ - id: bookId, - userId, - title: data.chapterTitle || 'Untitled Audiobook', - }); - } - } + await db + .insert(audiobooks) + .values({ + id: bookId, + userId: storageUserId, + title: data.chapterTitle || 'Untitled Audiobook', + }) + .onConflictDoNothing(); const intermediateDir = join(getAudiobooksRootDir(request, userId, ctxOrRes.authEnabled), `${bookId}-audiobook`); @@ -363,21 +353,22 @@ export async function POST(request: NextRequest) { await unlink(inputPath).catch(console.error); // Insert Chapter Record (Denormalized) - if (isAuthEnabled() && db && userId) { - await db.insert(audiobookChapters).values({ + await db + .insert(audiobookChapters) + .values({ id: `${bookId}-${chapterIndex}`, bookId, - userId, + userId: storageUserId, chapterIndex, title: data.chapterTitle, duration, format, - filePath: finalChapterName - }).onConflictDoUpdate({ + filePath: finalChapterName, + }) + .onConflictDoUpdate({ target: [audiobookChapters.id, audiobookChapters.userId], - set: { title: data.chapterTitle, duration, format, filePath: finalChapterName } + set: { title: data.chapterTitle, duration, format, filePath: finalChapterName }, }); - } return NextResponse.json({ index: chapterIndex, @@ -414,6 +405,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 }); } + await ensureAudiobooksV1Ready(); if (!(await isAudiobooksV1Ready())) { return NextResponse.json( { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, @@ -424,30 +416,26 @@ export async function GET(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; const { userId, authEnabled } = ctxOrRes; + const testNamespace = getOpenReaderTestNamespace(request.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + const storageUserId = userId ?? unclaimedUserId; + const allowedUserIds = authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + + await ensureDbIndexed(); // Check if audiobook exists for user OR is unclaimed (similar to documents) - if (authEnabled && db && userId) { - const [existingBook] = await db.select().from(audiobooks).where( - and( - eq(audiobooks.id, bookId), - or(eq(audiobooks.userId, userId), eq(audiobooks.userId, UNCLAIMED_USER_ID)) - ) - ); - if (!existingBook) { - return NextResponse.json({ error: 'Book not found' }, { status: 404 }); - } + const [existingBook] = await db + .select({ userId: audiobooks.userId }) + .from(audiobooks) + .where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds))); + if (!existingBook) { + return NextResponse.json({ error: 'Book not found' }, { status: 404 }); } - // Get the audiobook directory - check user's directory first, then unclaimed - let intermediateDir = join(getAudiobooksRootDir(request, userId, authEnabled), `${bookId}-audiobook`); - - // If not found in user's directory and auth is enabled, check unclaimed directory - if (!existsSync(intermediateDir) && authEnabled && userId) { - const unclaimedDir = join(getAudiobooksRootDir(request, UNCLAIMED_USER_ID, authEnabled), `${bookId}-audiobook`); - if (existsSync(unclaimedDir)) { - intermediateDir = unclaimedDir; - } - } + const intermediateDir = join( + getAudiobooksRootDir(request, existingBook.userId, authEnabled), + `${bookId}-audiobook`, + ); if (!existsSync(intermediateDir)) { return NextResponse.json({ error: 'Book not found' }, { status: 404 }); @@ -634,6 +622,7 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 }); } + await ensureAudiobooksV1Ready(); if (!(await isAudiobooksV1Ready())) { return NextResponse.json( { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, @@ -644,27 +633,28 @@ export async function DELETE(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; const { userId, authEnabled } = ctxOrRes; + const testNamespace = getOpenReaderTestNamespace(request.headers); + const storageUserId = userId ?? getUnclaimedUserIdForNamespace(testNamespace); + + await ensureDbIndexed(); // Delete from DB - with composite PK, we delete by both id and userId - if (authEnabled && db && userId) { - const [existingBook] = await db.select().from(audiobooks).where( - and(eq(audiobooks.id, bookId), eq(audiobooks.userId, userId)) - ); + const [existingBook] = await db + .select({ userId: audiobooks.userId }) + .from(audiobooks) + .where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId))); - if (!existingBook) { - return NextResponse.json({ error: 'Book not found' }, { status: 404 }); - } - - // Delete chapters first (no foreign key constraint with composite PK) - await db.delete(audiobookChapters).where( - and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, userId)) - ); - - await db.delete(audiobooks).where( - and(eq(audiobooks.id, bookId), eq(audiobooks.userId, userId)) - ); + if (!existingBook) { + return NextResponse.json({ error: 'Book not found' }, { status: 404 }); } + // Delete chapters first (no foreign key constraint with composite PK) + await db + .delete(audiobookChapters) + .where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, storageUserId))); + + await db.delete(audiobooks).where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId))); + const intermediateDir = join(getAudiobooksRootDir(request, userId, authEnabled), `${bookId}-audiobook`); // If directory doesn't exist, consider it already reset diff --git a/src/app/api/audiobook/status/route.ts b/src/app/api/audiobook/status/route.ts index 3b9f91d..733f6db 100644 --- a/src/app/api/audiobook/status/route.ts +++ b/src/app/api/audiobook/status/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { existsSync } from 'fs'; -import { join, resolve } from 'path'; -import { AUDIOBOOKS_V1_DIR, UNCLAIMED_USER_ID, getUserAudiobookDir, isAudiobooksV1Ready } from '@/lib/server/docstore'; +import { join } from 'path'; +import { AUDIOBOOKS_V1_DIR, getUserAudiobookDir, ensureAudiobooksV1Ready, isAudiobooksV1Ready } from '@/lib/server/docstore'; import { listStoredChapters } from '@/lib/server/audiobook'; import type { AudiobookGenerationSettings } from '@/types/client'; import type { TTSAudiobookFormat, TTSAudiobookChapter } from '@/types/tts'; @@ -9,7 +9,9 @@ import { readFile } from 'fs/promises'; import { requireAuthContext } from '@/lib/server/auth'; import { db } from '@/db'; import { audiobooks } from '@/db/schema'; -import { eq, and, or } from 'drizzle-orm'; +import { eq, and, inArray } from 'drizzle-orm'; +import { ensureDbIndexed } from '@/lib/server/db-indexing'; +import { applyOpenReaderTestNamespacePath, getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; export const dynamic = 'force-dynamic'; @@ -19,27 +21,14 @@ export const dynamic = 'force-dynamic'; * When auth is enabled, returns the user-specific directory. */ function getAudiobooksRootDir(request: NextRequest, userId: string | null, authEnabled: boolean): string { - const raw = request.headers.get('x-openreader-test-namespace')?.trim(); - - const applyTestNamespace = (baseDir: string): string => { - if (!raw) return baseDir; - const safe = raw.replace(/[^a-zA-Z0-9._-]/g, ''); - if (!safe || safe === '.' || safe === '..' || safe.includes('..')) { - return baseDir; - } - const resolved = resolve(baseDir, safe); - if (!resolved.startsWith(resolve(baseDir) + '/')) { - return baseDir; - } - return resolved; - }; + const namespace = getOpenReaderTestNamespace(request.headers); if (!authEnabled || !userId) { - return applyTestNamespace(AUDIOBOOKS_V1_DIR); + return applyOpenReaderTestNamespacePath(AUDIOBOOKS_V1_DIR, namespace); } const userDir = getUserAudiobookDir(userId); - return applyTestNamespace(userDir); + return applyOpenReaderTestNamespacePath(userDir, namespace); } const SAFE_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; @@ -55,6 +44,7 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 }); } + await ensureAudiobooksV1Ready(); if (!(await isAudiobooksV1Ready())) { return NextResponse.json( { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, @@ -66,37 +56,30 @@ export async function GET(request: NextRequest) { if (ctxOrRes instanceof Response) return ctxOrRes; const { userId, authEnabled } = ctxOrRes; + const testNamespace = getOpenReaderTestNamespace(request.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + const storageUserId = userId ?? unclaimedUserId; + const allowedUserIds = authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + + await ensureDbIndexed(); // Check if audiobook exists for user OR is unclaimed (similar to documents) - if (authEnabled && db && userId) { - const [existingBook] = await db.select().from(audiobooks).where( - and( - eq(audiobooks.id, bookId), - or(eq(audiobooks.userId, userId), eq(audiobooks.userId, UNCLAIMED_USER_ID)) - ) - ); - if (!existingBook) { - // Book doesn't exist for this user or unclaimed - return empty state - return NextResponse.json({ - chapters: [], - exists: false, - hasComplete: false, - bookId: null, - settings: null, - }); - } + const [existingBook] = await db + .select({ userId: audiobooks.userId }) + .from(audiobooks) + .where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds))); + if (!existingBook) { + // Book doesn't exist for this user or unclaimed - return empty state + return NextResponse.json({ + chapters: [], + exists: false, + hasComplete: false, + bookId: null, + settings: null, + }); } - // Get the audiobook directory - check user's directory first, then unclaimed - let intermediateDir = join(getAudiobooksRootDir(request, userId, authEnabled), `${bookId}-audiobook`); - - // If not found in user's directory and auth is enabled, check unclaimed directory - if (!existsSync(intermediateDir) && authEnabled && userId) { - const unclaimedDir = join(getAudiobooksRootDir(request, UNCLAIMED_USER_ID, authEnabled), `${bookId}-audiobook`); - if (existsSync(unclaimedDir)) { - intermediateDir = unclaimedDir; - } - } + const intermediateDir = join(getAudiobooksRootDir(request, existingBook.userId, authEnabled), `${bookId}-audiobook`); if (!existsSync(intermediateDir)) { return NextResponse.json({ diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 41e9af9..2ee2722 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -2,13 +2,13 @@ import { createHash } from 'crypto'; import { readFile, stat, unlink, utimes, writeFile } from 'fs/promises'; import { NextRequest, NextResponse } from 'next/server'; import path from 'path'; -import { DOCUMENTS_V1_DIR, isDocumentsV1Ready, scanDocumentsFS } from '@/lib/server/docstore'; +import { DOCUMENTS_V1_DIR, UNCLAIMED_USER_ID, ensureDocumentsV1Ready, isDocumentsV1Ready } from '@/lib/server/docstore'; import type { BaseDocument, DocumentType, SyncedDocument } from '@/types/documents'; import { db } from '@/db'; import { documents } from '@/db/schema'; -import { eq, or, and, inArray, count } from 'drizzle-orm'; -import { isAuthEnabled } from '@/lib/server/auth-config'; +import { eq, and, inArray, count } from 'drizzle-orm'; import { requireAuthContext } from '@/lib/server/auth'; +import { ensureDbIndexed } from '@/lib/server/db-indexing'; export const dynamic = 'force-dynamic'; @@ -36,6 +36,7 @@ function toDocumentTypeFromName(name: string): DocumentType { export async function POST(req: NextRequest) { try { + await ensureDocumentsV1Ready(); if (!(await isDocumentsV1Ready())) { return NextResponse.json( { error: 'Documents storage is not migrated; run /api/migrations/v1 first.' }, @@ -45,6 +46,7 @@ export async function POST(req: NextRequest) { const ctxOrRes = await requireAuthContext(req); if (ctxOrRes instanceof Response) return ctxOrRes; + const storageUserId = ctxOrRes.userId ?? UNCLAIMED_USER_ID; const data = await req.json(); const documentsData = data.documents as SyncedDocument[]; @@ -73,28 +75,18 @@ export async function POST(req: NextRequest) { // DB Upsert // With composite PK (id, userId), we check if THIS user already has this document - if (isAuthEnabled() && db) { - const userId = ctxOrRes.userId; - if (!userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const [existing] = await db.select().from(documents).where( - and(eq(documents.id, id), eq(documents.userId, userId)) - ); - - if (!existing) { - await db.insert(documents).values({ - id, - userId, - name: safeName, - type: doc.type, - size: content.length, - lastModified: doc.lastModified, - filePath: targetFileName - }); - } - } + await db + .insert(documents) + .values({ + id, + userId: storageUserId, + name: safeName, + type: doc.type, + size: content.length, + lastModified: doc.lastModified, + filePath: targetFileName, + }) + .onConflictDoNothing(); stored.push({ oldId: doc.id, id, name: safeName }); } @@ -108,6 +100,7 @@ export async function POST(req: NextRequest) { export async function GET(req: NextRequest) { try { + await ensureDocumentsV1Ready(); if (!(await isDocumentsV1Ready())) { return NextResponse.json( { error: 'Documents storage is not migrated; run /api/migrations/v1 first.' }, @@ -117,6 +110,10 @@ export async function GET(req: NextRequest) { const ctxOrRes = await requireAuthContext(req); if (ctxOrRes instanceof Response) return ctxOrRes; + const storageUserId = ctxOrRes.userId ?? UNCLAIMED_USER_ID; + const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, UNCLAIMED_USER_ID] : [UNCLAIMED_USER_ID]; + + await ensureDbIndexed(); const url = new URL(req.url); const list = url.searchParams.get('list') === 'true'; @@ -130,30 +127,15 @@ export async function GET(req: NextRequest) { const targetIds = idsParam ? idsParam.split(',').filter(Boolean) : null; - // Query database (or filesystem) for documents the user is allowed to access + // Query database for documents the user is allowed to access let allowedDocs: { id: string; name: string; type: string; size: number; lastModified: number; filePath: string }[] = []; - if (!isAuthEnabled()) { - const fsDocs = await scanDocumentsFS(); - allowedDocs = fsDocs.map(d => ({ ...d })); - if (targetIds) { - allowedDocs = allowedDocs.filter(d => targetIds!.includes(d.id)); - } - } else { - const userId = ctxOrRes.userId; - if (!userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - if (!db) throw new Error("DB not initialized"); - const rows = await db.select().from(documents).where( - and( - or(eq(documents.userId, userId), eq(documents.userId, 'unclaimed')), - targetIds ? inArray(documents.id, targetIds) : undefined - ) - ); - allowedDocs = rows as unknown as { id: string; name: string; type: string; size: number; lastModified: number; filePath: string }[]; - } + const conditions = [ + inArray(documents.userId, allowedUserIds), + ...(targetIds ? [inArray(documents.id, targetIds)] : []), + ]; + const rows = await db.select().from(documents).where(and(...conditions)); + allowedDocs = rows as unknown as { id: string; name: string; type: string; size: number; lastModified: number; filePath: string }[]; const results: (BaseDocument | SyncedDocument)[] = []; @@ -200,6 +182,7 @@ export async function GET(req: NextRequest) { export async function DELETE(req: NextRequest) { try { + await ensureDocumentsV1Ready(); if (!(await isDocumentsV1Ready())) { return NextResponse.json( { error: 'Documents storage is not migrated; run /api/migrations/v1 first.' }, @@ -210,6 +193,9 @@ export async function DELETE(req: NextRequest) { // Auth check - require session const ctxOrRes = await requireAuthContext(req); if (ctxOrRes instanceof Response) return ctxOrRes; + const storageUserId = ctxOrRes.userId ?? UNCLAIMED_USER_ID; + + await ensureDbIndexed(); const url = new URL(req.url); const idsParam = url.searchParams.get('ids'); @@ -220,20 +206,9 @@ export async function DELETE(req: NextRequest) { if (idsParam) { targetIds = idsParam.split(',').filter(Boolean); } else { - if (!isAuthEnabled()) { - const fsDocs = await scanDocumentsFS(); - targetIds = fsDocs.map((d) => d.id); - } else { - const userId = ctxOrRes.userId; - if (!userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - // Existing behavior was "nuke everything"; keep it scoped to "my" docs. - if (!db) throw new Error("DB not initialized"); - const userDocs = await db.select({ id: documents.id }).from(documents).where(eq(documents.userId, userId)); - targetIds = userDocs.map((d: { id: string | null }) => d.id!).filter(Boolean) as string[]; - } + // Existing behavior was "nuke everything"; keep it scoped to "my" docs. + const userDocs = await db.select({ id: documents.id }).from(documents).where(eq(documents.userId, storageUserId)); + targetIds = userDocs.map((d: { id: string | null }) => d.id!).filter(Boolean) as string[]; } if (targetIds.length === 0) { @@ -242,32 +217,12 @@ export async function DELETE(req: NextRequest) { const deletedRows: { id: string; filePath: string }[] = []; - if (!isAuthEnabled()) { - // FS cleanup only - // Since we don't track ownership, we just delete the files requested. - // This implies in no-auth mode, any user can delete any file if they know the ID. - const fsDocs = await scanDocumentsFS(); - for (const doc of fsDocs) { - if (targetIds.includes(doc.id)) { - deletedRows.push({ id: doc.id, filePath: doc.filePath }); - } - } - } else { - const userId = ctxOrRes.userId; - if (!userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - if (!db) throw new Error("DB not initialized"); - const rows = await db.delete(documents) - .where(and( - eq(documents.userId, userId), - inArray(documents.id, targetIds) - )) - .returning({ id: documents.id, filePath: documents.filePath }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - rows.forEach((r: any) => deletedRows.push({ id: r.id!, filePath: r.filePath })); - } + const rows = await db + .delete(documents) + .where(and(eq(documents.userId, storageUserId), inArray(documents.id, targetIds))) + .returning({ id: documents.id, filePath: documents.filePath }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + rows.forEach((r: any) => deletedRows.push({ id: r.id!, filePath: r.filePath })); // If driver doesn't support returning (e.g. older SQLite without properly configured returning), we might fallback. // But Drizzle usually handles this. @@ -279,10 +234,8 @@ export async function DELETE(req: NextRequest) { // Chech reference count for this ID // If 0 remaining, delete file let refCount = 0; - if (isAuthEnabled() && db) { - const [ref] = await db.select({ count: count() }).from(documents).where(eq(documents.id, row.id!)); - refCount = ref?.count ?? 0; - } + const [ref] = await db.select({ count: count() }).from(documents).where(eq(documents.id, row.id!)); + refCount = Number(ref?.count ?? 0); if (refCount === 0) { const filePath = path.join(SYNC_DIR, row.filePath); diff --git a/src/app/api/user/claim/route.ts b/src/app/api/user/claim/route.ts index 3be0c30..1011282 100644 --- a/src/app/api/user/claim/route.ts +++ b/src/app/api/user/claim/route.ts @@ -1,6 +1,23 @@ import { NextRequest, NextResponse } from 'next/server'; -import { claimAnonymousData, scanAndPopulateDB } from '@/lib/server/claim-data'; +import { claimAnonymousData } from '@/lib/server/claim-data'; import { auth } from '@/lib/server/auth'; +import { ensureDbIndexed, getUnclaimedCounts } from '@/lib/server/db-indexing'; + +export async function GET(req: NextRequest) { + try { + const session = await auth?.api.getSession({ headers: req.headers }); + if (!session || !session.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + await ensureDbIndexed(); + const counts = await getUnclaimedCounts(); + return NextResponse.json({ success: true, ...counts }); + } catch (error) { + console.error('Error checking claimable data:', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} export async function POST(req: NextRequest) { try { @@ -10,14 +27,8 @@ export async function POST(req: NextRequest) { } const userId = session.user.id; - const { action } = await req.json(); // "claim" or "scan" or "start_fresh" (optional logic) - if (action === 'scan') { - const counts = await scanAndPopulateDB(); - return NextResponse.json({ success: true, message: 'Scanned file system', ...counts }); - } - - // Default action: Claim + await ensureDbIndexed(); const result = await claimAnonymousData(userId); return NextResponse.json({ diff --git a/src/components/auth/ClaimDataModal.tsx b/src/components/auth/ClaimDataModal.tsx index 0539ca6..84fa66e 100644 --- a/src/components/auth/ClaimDataModal.tsx +++ b/src/components/auth/ClaimDataModal.tsx @@ -25,9 +25,7 @@ export default function ClaimDataModal() { try { const res = await fetch('/api/user/claim', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'scan' }) + method: 'GET', }); if (res.ok) { const data = await res.json(); @@ -52,8 +50,6 @@ export default function ClaimDataModal() { try { const res = await fetch('/api/user/claim', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'claim' }) }); if (res.ok) { const data = await res.json(); diff --git a/src/db/index.ts b/src/db/index.ts index 1ca9d1d..d405189 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -5,7 +5,6 @@ import Database from 'better-sqlite3'; import path from 'path'; import fs from 'fs'; import * as schema from './schema'; -import { isAuthEnabled } from '@/lib/server/auth-config'; // Singleton logic not strictly needed if Next.js handles module caching, but good for safety // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -14,11 +13,6 @@ let dbInstance: any = null; export function getDrizzleDB() { if (dbInstance) return dbInstance; - // If auth is NOT enabled, we do not want to connect to any database - if (!isAuthEnabled()) { - return null; - } - if (process.env.POSTGRES_URL) { const pool = new Pool({ connectionString: process.env.POSTGRES_URL, diff --git a/src/lib/server/claim-data.ts b/src/lib/server/claim-data.ts index 2620c48..6a8782d 100644 --- a/src/lib/server/claim-data.ts +++ b/src/lib/server/claim-data.ts @@ -1,191 +1,18 @@ import { db } from '@/db'; import { documents, audiobooks, audiobookChapters } from '@/db/schema'; -import { eq, and, count } from 'drizzle-orm'; +import { eq, and } from 'drizzle-orm'; import fs from 'fs/promises'; -import { existsSync } from 'fs'; -import path from 'path'; -import { - DOCUMENTS_V1_DIR, - AUDIOBOOKS_V1_DIR, +import { UNCLAIMED_USER_ID, - getUnclaimedAudiobookDir, getUserAudiobookDir, moveAudiobookToUser, - listUserAudiobookIds + listUserAudiobookIds, } from './docstore'; -import { listStoredChapters } from './audiobook'; import { isAuthEnabled } from '@/lib/server/auth-config'; -// Helper to check if a document is already indexed -async function isDocumentIndexed(id: string) { - if (!isAuthEnabled() || !db) return false; - const result = await db.select({ id: documents.id }).from(documents).where(eq(documents.id, id)); - return result.length > 0; -} - -// Helper to check if an audiobook is indexed for a specific user (composite PK) -async function isAudiobookIndexedForUser(id: string, userId: string) { - if (!isAuthEnabled() || !db) return false; - const result = await db.select({ id: audiobooks.id }).from(audiobooks).where( - and(eq(audiobooks.id, id), eq(audiobooks.userId, userId)) - ); - return result.length > 0; -} - -/** - * Returns count of unclaimed documents and audiobooks in the DB (userId IS 'unclaimed') - */ -export async function getUnclaimedCounts() { - if (!isAuthEnabled() || !db) return { documents: 0, audiobooks: 0 }; - const [docCount] = await db.select({ count: count() }).from(documents).where(eq(documents.userId, UNCLAIMED_USER_ID)); - const [bookCount] = await db.select({ count: count() }).from(audiobooks).where(eq(audiobooks.userId, UNCLAIMED_USER_ID)); - - return { - documents: docCount?.count ?? 0, - audiobooks: bookCount?.count ?? 0 - }; -} - -/** - * Migrate legacy audiobooks from AUDIOBOOKS_V1_DIR to the unclaimed user folder. - * This handles the case where audiobooks were created before the per-user storage refactor. - */ -async function migrateLegacyAudiobooksToUnclaimed(): Promise { - if (!existsSync(AUDIOBOOKS_V1_DIR)) return 0; - - const unclaimedDir = getUnclaimedAudiobookDir(); - await fs.mkdir(unclaimedDir, { recursive: true }); - - const entries = await fs.readdir(AUDIOBOOKS_V1_DIR, { withFileTypes: true }); - let migrated = 0; - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (!entry.name.endsWith('-audiobook')) continue; - - const sourceDir = path.join(AUDIOBOOKS_V1_DIR, entry.name); - const targetDir = path.join(unclaimedDir, entry.name); - - // Skip if already exists in unclaimed - if (existsSync(targetDir)) continue; - - try { - await fs.rename(sourceDir, targetDir); - migrated++; - console.log(`Migrated legacy audiobook to unclaimed: ${entry.name}`); - } catch (err) { - console.error(`Error migrating legacy audiobook ${entry.name}:`, err); - } - } - - return migrated; -} - -export async function scanAndPopulateDB() { - if (!isAuthEnabled() || !db) { - console.log('Skipping DB population (Auth/DB disabled)'); - return { documents: 0, audiobooks: 0 }; - } - - console.log('Scanning file system for un-indexed content...'); - - // 0. Migrate legacy audiobooks from AUDIOBOOKS_V1_DIR to unclaimed folder - await migrateLegacyAudiobooksToUnclaimed(); - - // 1. Scan Documents (unchanged - documents use shared storage) - if (existsSync(DOCUMENTS_V1_DIR)) { - const files = await fs.readdir(DOCUMENTS_V1_DIR); - for (const file of files) { - const match = /^([a-f0-9]{64})__(.+)$/i.exec(file); - if (!match) continue; - - const id = match[1]; - const encodedName = match[2]; - if (await isDocumentIndexed(id)) continue; - - let name: string; - try { - name = decodeURIComponent(encodedName); - } catch { - continue; - } - - const filePath = path.join(DOCUMENTS_V1_DIR, file); - const stats = await fs.stat(filePath); - - const ext = path.extname(name).toLowerCase().replace('.', ''); - - await db.insert(documents).values({ - id, - userId: UNCLAIMED_USER_ID, - name, - type: ext, - size: stats.size, - lastModified: Math.floor(stats.mtimeMs), - filePath: file, - }); - console.log(`Indexed document: ${name} (${id})`); - } - } - - // 2. Scan Audiobooks from unclaimed folder - const unclaimedDir = getUnclaimedAudiobookDir(); - if (existsSync(unclaimedDir)) { - const entries = await fs.readdir(unclaimedDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (!entry.name.endsWith('-audiobook')) continue; - - const bookId = entry.name.replace('-audiobook', ''); - if (await isAudiobookIndexedForUser(bookId, UNCLAIMED_USER_ID)) continue; - - const dirPath = path.join(unclaimedDir, entry.name); - - let title = `Unknown Title`; - - try { - const metaPath = path.join(dirPath, 'audiobook.meta.json'); - const metaContent = await fs.readFile(metaPath, 'utf8'); - JSON.parse(metaContent); // validating json - } catch { } - - const chapters = await listStoredChapters(dirPath); - const totalDuration = chapters.reduce((acc, c) => acc + (c.durationSec || 0), 0); - - if (chapters.length > 0) { - title = chapters[0].title || title; - } - - await db.insert(audiobooks).values({ - id: bookId, - userId: UNCLAIMED_USER_ID, - title: title, - duration: totalDuration, - }); - console.log(`Indexed audiobook: ${bookId}`); - - for (const chapter of chapters) { - await db.insert(audiobookChapters).values({ - id: `${bookId}-${chapter.index}`, - bookId: bookId, - userId: UNCLAIMED_USER_ID, - chapterIndex: chapter.index, - title: chapter.title, - duration: chapter.durationSec || 0, - filePath: chapter.filePath, - format: chapter.format - }); - } - } - } - - // Return current unclaimed counts (includes newly indexed + previously unclaimed) - return getUnclaimedCounts(); -} - export async function claimAnonymousData(userId: string) { - if (!isAuthEnabled() || !db || !userId) return { documents: 0, audiobooks: 0 }; + if (!isAuthEnabled() || !userId) return { documents: 0, audiobooks: 0 }; // Get list of unclaimed audiobook IDs before updating DB const unclaimedBookIds = await listUserAudiobookIds(UNCLAIMED_USER_ID); @@ -262,7 +89,7 @@ export async function claimAnonymousData(userId: string) { * @returns number of audiobooks transferred */ export async function transferUserAudiobooks(fromUserId: string, toUserId: string): Promise { - if (!isAuthEnabled() || !db || !fromUserId || !toUserId) return 0; + if (!isAuthEnabled() || !fromUserId || !toUserId) return 0; const bookIds = await listUserAudiobookIds(fromUserId); let transferred = 0; diff --git a/src/lib/server/db-indexing.ts b/src/lib/server/db-indexing.ts new file mode 100644 index 0000000..9f1c479 --- /dev/null +++ b/src/lib/server/db-indexing.ts @@ -0,0 +1,254 @@ +import fs from 'fs/promises'; +import { existsSync } from 'fs'; +import path from 'path'; +import { isAuthEnabled } from '@/lib/server/auth-config'; +import { db } from '@/db'; +import { audiobookChapters, audiobooks, documents } from '@/db/schema'; +import { and, count, eq } from 'drizzle-orm'; +import { listStoredChapters } from '@/lib/server/audiobook'; +import { AUDIOBOOKS_V1_DIR, DOCUMENTS_V1_DIR, UNCLAIMED_USER_ID, getUnclaimedAudiobookDir } from '@/lib/server/docstore'; + +const DOCSTORE_DIR = path.join(process.cwd(), 'docstore'); +const MIGRATIONS_DIR = path.join(DOCSTORE_DIR, '.migrations'); +const STATE_PATH = path.join(MIGRATIONS_DIR, 'db-index.json'); + +type DbIndexState = { + indexedAt: number; + mode: 'auth' | 'noauth'; +}; + +let inflight: Promise | null = null; +let memoryIndexedMode: DbIndexState['mode'] | null = null; + +async function readState(): Promise { + try { + const raw = await fs.readFile(STATE_PATH, 'utf8'); + return JSON.parse(raw) as DbIndexState; + } catch { + return null; + } +} + +async function writeState(): Promise { + await fs.mkdir(MIGRATIONS_DIR, { recursive: true }); + const state: DbIndexState = { + indexedAt: Date.now(), + mode: isAuthEnabled() ? 'auth' : 'noauth', + }; + await fs.writeFile(STATE_PATH, JSON.stringify(state, null, 2)); +} + +async function hasUnclaimedDbRows(): Promise { + const [docCount] = await db + .select({ count: count() }) + .from(documents) + .where(eq(documents.userId, UNCLAIMED_USER_ID)); + const [bookCount] = await db + .select({ count: count() }) + .from(audiobooks) + .where(eq(audiobooks.userId, UNCLAIMED_USER_ID)); + + return Number(docCount?.count ?? 0) > 0 || Number(bookCount?.count ?? 0) > 0; +} + +async function hasFilesystemContent(mode: DbIndexState['mode']): Promise { + // Documents are always stored under documents_v1. + try { + const entries = await fs.readdir(DOCUMENTS_V1_DIR); + if (entries.some((name) => /^[a-f0-9]{64}__.+$/i.test(name))) return true; + } catch { + // ignore + } + + // Audiobooks differ by auth-mode layout. + const audiobookDir = mode === 'auth' ? getUnclaimedAudiobookDir() : AUDIOBOOKS_V1_DIR; + try { + const entries = await fs.readdir(audiobookDir, { withFileTypes: true }); + if (entries.some((e) => e.isDirectory() && e.name.endsWith('-audiobook'))) return true; + } catch { + // ignore + } + + return false; +} + +async function isDocumentIndexed(id: string): Promise { + const result = await db.select({ id: documents.id }).from(documents).where(eq(documents.id, id)); + return result.length > 0; +} + +async function isAudiobookIndexedForUser(id: string, userId: string): Promise { + const result = await db + .select({ id: audiobooks.id }) + .from(audiobooks) + .where(and(eq(audiobooks.id, id), eq(audiobooks.userId, userId))); + return result.length > 0; +} + +async function migrateLegacyAudiobooksToUnclaimed(): Promise { + if (!existsSync(AUDIOBOOKS_V1_DIR)) return 0; + + const unclaimedDir = getUnclaimedAudiobookDir(); + await fs.mkdir(unclaimedDir, { recursive: true }); + + const entries = await fs.readdir(AUDIOBOOKS_V1_DIR, { withFileTypes: true }); + let migrated = 0; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (!entry.name.endsWith('-audiobook')) continue; + + const sourceDir = path.join(AUDIOBOOKS_V1_DIR, entry.name); + const targetDir = path.join(unclaimedDir, entry.name); + + if (existsSync(targetDir)) continue; + + try { + await fs.rename(sourceDir, targetDir); + migrated++; + console.log(`Migrated legacy audiobook to unclaimed: ${entry.name}`); + } catch (err) { + console.error(`Error migrating legacy audiobook ${entry.name}:`, err); + } + } + + return migrated; +} + +export async function getUnclaimedCounts(): Promise<{ documents: number; audiobooks: number }> { + const [docCount] = await db.select({ count: count() }).from(documents).where(eq(documents.userId, UNCLAIMED_USER_ID)); + const [bookCount] = await db.select({ count: count() }).from(audiobooks).where(eq(audiobooks.userId, UNCLAIMED_USER_ID)); + + return { + documents: Number(docCount?.count ?? 0), + audiobooks: Number(bookCount?.count ?? 0), + }; +} + +async function scanAndPopulateDb(): Promise<{ documents: number; audiobooks: number }> { + const authEnabled = isAuthEnabled(); + + console.log('Scanning file system for un-indexed content...'); + + if (authEnabled) { + await migrateLegacyAudiobooksToUnclaimed(); + } + + if (existsSync(DOCUMENTS_V1_DIR)) { + const files = await fs.readdir(DOCUMENTS_V1_DIR); + for (const file of files) { + const match = /^([a-f0-9]{64})__(.+)$/i.exec(file); + if (!match) continue; + + const id = match[1]; + const encodedName = match[2]; + if (await isDocumentIndexed(id)) continue; + + let name: string; + try { + name = decodeURIComponent(encodedName); + } catch { + continue; + } + + const filePath = path.join(DOCUMENTS_V1_DIR, file); + const stats = await fs.stat(filePath); + const ext = path.extname(name).toLowerCase().replace('.', ''); + + await db.insert(documents).values({ + id, + userId: UNCLAIMED_USER_ID, + name, + type: ext, + size: stats.size, + lastModified: Math.floor(stats.mtimeMs), + filePath: file, + }); + console.log(`Indexed document: ${name} (${id})`); + } + } + + const audiobookScanDir = authEnabled ? getUnclaimedAudiobookDir() : AUDIOBOOKS_V1_DIR; + if (existsSync(audiobookScanDir)) { + const entries = await fs.readdir(audiobookScanDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (!entry.name.endsWith('-audiobook')) continue; + + const bookId = entry.name.replace('-audiobook', ''); + if (await isAudiobookIndexedForUser(bookId, UNCLAIMED_USER_ID)) continue; + + const dirPath = path.join(audiobookScanDir, entry.name); + + let title = 'Unknown Title'; + try { + const metaPath = path.join(dirPath, 'audiobook.meta.json'); + const metaContent = await fs.readFile(metaPath, 'utf8'); + JSON.parse(metaContent); + } catch { + // ignore + } + + const chapters = await listStoredChapters(dirPath); + const totalDuration = chapters.reduce((acc, c) => acc + (c.durationSec || 0), 0); + if (chapters.length > 0) title = chapters[0].title || title; + + await db.insert(audiobooks).values({ + id: bookId, + userId: UNCLAIMED_USER_ID, + title, + duration: totalDuration, + }); + console.log(`Indexed audiobook: ${bookId}`); + + for (const chapter of chapters) { + await db.insert(audiobookChapters).values({ + id: `${bookId}-${chapter.index}`, + bookId, + userId: UNCLAIMED_USER_ID, + chapterIndex: chapter.index, + title: chapter.title, + duration: chapter.durationSec || 0, + filePath: chapter.filePath, + format: chapter.format, + }); + } + } + } + + return getUnclaimedCounts(); +} + +/** + * Ensure DB has rows for existing filesystem content (documents/audiobooks). + * + * This is intentionally safe to call from routes: + * - Runs at most once per process (memoryIndexed) + * - Uses a persisted state file under docstore/.migrations to avoid rescanning every boot + */ +export async function ensureDbIndexed(): Promise { + const mode: DbIndexState['mode'] = isAuthEnabled() ? 'auth' : 'noauth'; + if (memoryIndexedMode === mode) return; + + inflight ??= (async () => { + const hasState = existsSync(STATE_PATH) ? await readState() : null; + if (hasState && hasState.mode === mode) { + // If the DB was reset but the state file survived, don't get stuck "indexed" forever. + // Only skip if the DB has rows OR there is no content on disk to index. + const [dbHasRows, fsHasRows] = await Promise.all([hasUnclaimedDbRows(), hasFilesystemContent(mode)]); + if (dbHasRows || !fsHasRows) { + memoryIndexedMode = mode; + return; + } + } + + await fs.mkdir(DOCSTORE_DIR, { recursive: true }); + await scanAndPopulateDb(); + await writeState(); + memoryIndexedMode = mode; + })().finally(() => { + inflight = null; + }); + + await inflight; +} diff --git a/src/lib/server/docstore.ts b/src/lib/server/docstore.ts index 07ebc4b..8ade6a3 100644 --- a/src/lib/server/docstore.ts +++ b/src/lib/server/docstore.ts @@ -8,13 +8,13 @@ import { decodeChapterTitleTag, encodeChapterFileName, encodeChapterTitleTag, ff export const DOCSTORE_DIR = path.join(process.cwd(), 'docstore'); export const DOCUMENTS_V1_DIR = path.join(DOCSTORE_DIR, 'documents_v1'); export const AUDIOBOOKS_V1_DIR = path.join(DOCSTORE_DIR, 'audiobooks_v1'); -export const AUDIOBOOKS_USERS_DIR = path.join(DOCSTORE_DIR, 'audiobooks_users'); +export const AUDIOBOOKS_USERS_DIR = path.join(DOCSTORE_DIR, 'audiobooks_users_v1'); export const UNCLAIMED_USER_ID = 'unclaimed'; /** * Get the audiobook directory for a specific user when auth is enabled. - * Returns path like: docstore/audiobooks_users/{userId} + * Returns path like: docstore/audiobooks_users_v1/{userId} */ export function getUserAudiobookDir(userId: string): string { // Sanitize userId to prevent path traversal @@ -27,7 +27,7 @@ export function getUserAudiobookDir(userId: string): string { /** * Get the unclaimed audiobooks directory for pre-auth content. - * Returns path like: docstore/audiobooks_users/unclaimed + * Returns path like: docstore/audiobooks_users_v1/unclaimed */ export function getUnclaimedAudiobookDir(): string { return path.join(AUDIOBOOKS_USERS_DIR, UNCLAIMED_USER_ID); @@ -36,7 +36,7 @@ export function getUnclaimedAudiobookDir(): string { /** * Get the full path to a specific audiobook directory. * - When auth is disabled: docstore/audiobooks_v1/{bookId}-audiobook - * - When auth is enabled: docstore/audiobooks_users/{userId}/{bookId}-audiobook + * - When auth is enabled: docstore/audiobooks_users_v1/{userId}/{bookId}-audiobook */ export function getAudiobookPath(bookId: string, userId: string | null, authEnabled: boolean): string { if (!authEnabled || !userId) { @@ -219,50 +219,6 @@ export type FSDocument = { filePath: string; }; -export async function scanDocumentsFS(): Promise { - if (!existsSync(DOCUMENTS_V1_DIR)) return []; - - const results: FSDocument[] = []; - let files: string[] = []; - try { - files = await readdir(DOCUMENTS_V1_DIR); - } catch { - return []; - } - - for (const file of files) { - // Expected format: id__filename or id.ext (legacy fallback?) - // Actually current format is id__encodedName - const match = /^([a-f0-9]{64})__(.+)$/i.exec(file); - if (!match) continue; - - const id = match[1]; - const encodedName = match[2]; - const name = decodeURIComponent(encodedName); - const ext = path.extname(name).toLowerCase().replace('.', ''); - - // Validate file exists and get stats - try { - const filePath = path.join(DOCUMENTS_V1_DIR, file); - const stats = await stat(filePath); - if (!stats.isFile()) continue; - - results.push({ - id, - name, - type: ext, - size: stats.size, - lastModified: Math.floor(stats.mtimeMs), - filePath: file, - }); - } catch { - continue; - } - } - - return results; -} - export async function ensureDocumentsV1Ready(): Promise { await mkdir(DOCSTORE_DIR, { recursive: true }); await mkdir(DOCUMENTS_V1_DIR, { recursive: true }); diff --git a/src/lib/server/migration-manager.ts b/src/lib/server/migration-manager.ts deleted file mode 100644 index 4810833..0000000 --- a/src/lib/server/migration-manager.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { db } from '@/db'; -import { documents, audiobooks, audiobookChapters } from '@/db/schema'; -import { eq, isNull, count } from 'drizzle-orm'; -import fs from 'fs/promises'; -import { existsSync } from 'fs'; -import path from 'path'; -import { DOCUMENTS_V1_DIR, AUDIOBOOKS_V1_DIR } from './docstore'; -import { listStoredChapters } from './audiobook'; - -import { isAuthEnabled } from '@/lib/server/auth-config'; - -// Helper to check if a file is already indexed -async function isDocumentIndexed(id: string) { - if (!isAuthEnabled() || !db) return false; // If no DB, assume not indexed or handle differently? - // Actually if no DB, we don't index into DB. So returning false is fine, - // but scanAndPopulateDB should probably return early if no DB. - - const result = await db.select({ id: documents.id }).from(documents).where(eq(documents.id, id)); - return result.length > 0; -} - -async function isAudiobookIndexed(id: string) { - if (!isAuthEnabled() || !db) return false; - const result = await db.select({ id: audiobooks.id }).from(audiobooks).where(eq(audiobooks.id, id)); - return result.length > 0; -} - -const UNCLAIMED_ID = 'unclaimed'; - -/** - * Returns count of unclaimed documents and audiobooks in the DB (userId IS 'unclaimed') - */ -export async function getUnclaimedCounts() { - if (!isAuthEnabled() || !db) return { documents: 0, audiobooks: 0 }; - const [docCount] = await db.select({ count: count() }).from(documents).where(eq(documents.userId, UNCLAIMED_ID)); - const [bookCount] = await db.select({ count: count() }).from(audiobooks).where(eq(audiobooks.userId, UNCLAIMED_ID)); - - return { - documents: docCount?.count ?? 0, - audiobooks: bookCount?.count ?? 0 - }; -} - -export async function scanAndPopulateDB() { - if (!isAuthEnabled() || !db) { - console.log('Skipping DB population (Auth/DB disabled)'); - return { documents: 0, audiobooks: 0 }; - } - - console.log('Scanning file system for un-indexed content...'); - - // 0. Fix legacy NULL userIds - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (db as any).update(documents).set({ userId: UNCLAIMED_ID }).where(isNull(documents.userId)); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (db as any).update(audiobooks).set({ userId: UNCLAIMED_ID }).where(isNull(audiobooks.userId)); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (db as any).update(audiobookChapters).set({ userId: UNCLAIMED_ID }).where(isNull(audiobookChapters.userId)); - } catch (err) { - console.error('Error migrating legacy NULL userIds:', err); - } - - // 1. Scan Documents - if (existsSync(DOCUMENTS_V1_DIR)) { - const files = await fs.readdir(DOCUMENTS_V1_DIR); - for (const file of files) { - const match = /^([a-f0-9]{64})__(.+)$/i.exec(file); - if (!match) continue; - - const id = match[1]; - const encodedName = match[2]; - if (await isDocumentIndexed(id)) continue; - - let name: string; - try { - name = decodeURIComponent(encodedName); - } catch { - continue; - } - - const filePath = path.join(DOCUMENTS_V1_DIR, file); - const stats = await fs.stat(filePath); - - const ext = path.extname(name).toLowerCase().replace('.', ''); - - await db.insert(documents).values({ - id, - userId: UNCLAIMED_ID, - name, - type: ext, - size: stats.size, - lastModified: Math.floor(stats.mtimeMs), - filePath: file, - }); - console.log(`Indexed document: ${name} (${id})`); - } - } - - // 2. Scan Audiobooks - if (existsSync(AUDIOBOOKS_V1_DIR)) { - const entries = await fs.readdir(AUDIOBOOKS_V1_DIR, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (!entry.name.endsWith('-audiobook')) continue; - - const bookId = entry.name.replace('-audiobook', ''); - if (await isAudiobookIndexed(bookId)) continue; - - const dirPath = path.join(AUDIOBOOKS_V1_DIR, entry.name); - - let title = `Unknown Title`; - - try { - const metaPath = path.join(dirPath, 'audiobook.meta.json'); - const metaContent = await fs.readFile(metaPath, 'utf8'); - JSON.parse(metaContent); // validating json - } catch { } - - const chapters = await listStoredChapters(dirPath); - const totalDuration = chapters.reduce((acc, c) => acc + (c.durationSec || 0), 0); - - if (chapters.length > 0) { - title = chapters[0].title || title; - } - - await db.insert(audiobooks).values({ - id: bookId, - userId: UNCLAIMED_ID, - title: title, - duration: totalDuration, - }); - console.log(`Indexed audiobook: ${bookId}`); - - for (const chapter of chapters) { - await db.insert(audiobookChapters).values({ - id: `${bookId}-${chapter.index}`, - bookId: bookId, - userId: UNCLAIMED_ID, - chapterIndex: chapter.index, - title: chapter.title, - duration: chapter.durationSec || 0, - filePath: chapter.filePath, - format: chapter.format - }) - } - } - } - - // Return current unclaimed counts (includes newly indexed + previously unclaimed) - return getUnclaimedCounts(); -} - -export async function claimAnonymousData(userId: string) { - if (!isAuthEnabled() || !db || !userId) return { documents: 0, audiobooks: 0 }; - - // Update Documents - const docResult = await db.update(documents) - .set({ userId }) - .where(eq(documents.userId, UNCLAIMED_ID)) - .returning({ id: documents.id }); // If supported by driver, otherwise use run and check changes - - // Update Audiobooks - const bookResult = await db.update(audiobooks) - .set({ userId }) - .where(eq(audiobooks.userId, UNCLAIMED_ID)) - .returning({ id: audiobooks.id }); - - // Update Chapters (denormalized userId) - await db.update(audiobookChapters) - .set({ userId }) - .where(eq(audiobookChapters.userId, UNCLAIMED_ID)); // Or match by bookId join - - return { - documents: docResult.length, - audiobooks: bookResult.length - }; -} diff --git a/src/lib/server/rate-limiter.ts b/src/lib/server/rate-limiter.ts index 62c7999..2cf097a 100644 --- a/src/lib/server/rate-limiter.ts +++ b/src/lib/server/rate-limiter.ts @@ -146,8 +146,6 @@ export class RateLimiter { } try { - if (!db) throw new Error("DB not initialized"); - const updatedAt = this.getUpdatedAtValue() as unknown as UserTtsCharsUpdatedAtValue; // Use a DB transaction to avoid partial increments across buckets and to avoid @@ -254,13 +252,6 @@ export class RateLimiter { } const bucketResults: Array<{ currentCount: number; limit: number }> = []; - if (!db) { - const effective = pickEffectiveResult([]); - return { - ...effective, - resetTime: this.getResetTime(), - }; - } for (const bucket of buckets) { const result = await safeDb().select({ charCount: userTtsChars.charCount }) @@ -289,8 +280,6 @@ export class RateLimiter { async transferAnonymousUsage(anonymousUserId: string, authenticatedUserId: string): Promise { if (!isAuthEnabled()) return; - if (!db) return; - const today = new Date().toISOString().split('T')[0]; const dateValue = today as unknown as UserTtsCharsDateValue; const updatedAt = this.getUpdatedAtValue() as unknown as UserTtsCharsUpdatedAtValue; @@ -332,7 +321,6 @@ export class RateLimiter { const cutoffDateStr = cutoffDate.toISOString().split('T')[0]; const cutoffDateValue = cutoffDateStr as unknown as UserTtsCharsDateValue; - if (!db) return; // Assuming string comparison works for YYYY-MM-DD await safeDb().delete(userTtsChars).where(lt(userTtsChars.date, cutoffDateValue)); } diff --git a/src/lib/server/test-namespace.ts b/src/lib/server/test-namespace.ts new file mode 100644 index 0000000..5fe4671 --- /dev/null +++ b/src/lib/server/test-namespace.ts @@ -0,0 +1,30 @@ +import path from 'path'; +import { UNCLAIMED_USER_ID } from '@/lib/server/docstore'; + +const TEST_NAMESPACE_HEADER = 'x-openreader-test-namespace'; +const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; + +export function getOpenReaderTestNamespace(headers: Headers): string | null { + const raw = headers.get(TEST_NAMESPACE_HEADER)?.trim(); + if (!raw) return null; + + const safe = raw.replace(/[^a-zA-Z0-9._-]/g, ''); + if (!safe || safe === '.' || safe === '..' || safe.includes('..')) return null; + if (!SAFE_NAMESPACE_REGEX.test(safe)) return null; + + return safe; +} + +export function applyOpenReaderTestNamespacePath(baseDir: string, namespace: string | null): string { + if (!namespace) return baseDir; + + const resolved = path.resolve(baseDir, namespace); + if (!resolved.startsWith(path.resolve(baseDir) + path.sep)) return baseDir; + return resolved; +} + +export function getUnclaimedUserIdForNamespace(namespace: string | null): string { + if (!namespace) return UNCLAIMED_USER_ID; + return `${UNCLAIMED_USER_ID}::${namespace}`; +} + diff --git a/tests/export.spec.ts b/tests/export.spec.ts index 1e297ec..c767c40 100644 --- a/tests/export.spec.ts +++ b/tests/export.spec.ts @@ -1,5 +1,7 @@ import { test, expect, Page } from '@playwright/test'; import fs from 'fs'; +import os from 'os'; +import path from 'path'; import util from 'util'; import { execFile } from 'child_process'; import { setupTest, uploadAndDisplay } from './helpers'; @@ -39,19 +41,52 @@ async function waitForChaptersHeading(page: Page) { await expect(page.getByRole('heading', { name: 'Chapters' })).toBeVisible({ timeout: 60_000 }); } -async function downloadFullAudiobook(page: Page, timeoutMs = 60_000) { +type DownloadedAudiobook = { + filePath: string; + suggestedFilename: string; + cleanup: () => Promise; +}; + +async function downloadFullAudiobook(page: Page, timeoutMs = 60_000): Promise { const fullDownloadButton = page.getByRole('button', { name: /Full Download/i }); await expect(fullDownloadButton).toBeVisible({ timeout: timeoutMs }); const [download] = await Promise.all([ page.waitForEvent('download', { timeout: timeoutMs }), fullDownloadButton.click(), ]); - const tempFilePath = `./tmp_download_${Date.now()}.mp3`; - await download.saveAs(tempFilePath); - expect(fs.existsSync(tempFilePath)).toBeTruthy(); - const stats = fs.statSync(tempFilePath); + const failure = await download.failure(); + expect(failure).toBeNull(); + + const suggestedFilename = download.suggestedFilename(); + let createdTempDir: string | null = null; + let filePath = await download.path(); + + // Some environments/browsers may not expose a stable download path; fall back to saving + // into a temp directory outside the repo (and clean up after assertions). + if (!filePath) { + createdTempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'openreader-audiobook-')); + const name = suggestedFilename || `download_${Date.now()}.mp3`; + filePath = path.join(createdTempDir, name); + await download.saveAs(filePath); + } + + expect(fs.existsSync(filePath)).toBeTruthy(); + const stats = fs.statSync(filePath); expect(stats.size).toBeGreaterThan(0); - return tempFilePath; + return { + filePath, + suggestedFilename, + cleanup: async () => { + try { + await fs.promises.unlink(filePath); + } catch { + // ignore + } + if (createdTempDir) { + await fs.promises.rm(createdTempDir, { recursive: true, force: true }); + } + }, + }; } async function getAudioDurationSeconds(filePath: string) { @@ -74,6 +109,19 @@ async function expectChaptersBackendState(page: Page, bookId: string) { return json; } +async function withDownloadedFullAudiobook( + page: Page, + fn: (args: { filePath: string; suggestedFilename: string }) => Promise, + timeoutMs = 60_000 +): Promise { + const dl = await downloadFullAudiobook(page, timeoutMs); + try { + return await fn({ filePath: dl.filePath, suggestedFilename: dl.suggestedFilename }); + } finally { + await dl.cleanup(); + } +} + /** * Poll the backend until the chapter count stops changing for `stableMs` milliseconds. * This helps avoid race conditions where in-flight TTS requests complete after cancellation. @@ -160,16 +208,16 @@ test('exports full MP3 audiobook for PDF using mocked 10s TTS sample', async ({ // Trigger full download from the FRONTEND button and capture via Playwright's download API. // The button label can be "Full Download (MP3)" or "Full Download (M4B)" depending on // the server-side detected format, so match more loosely on the accessible name. - const downloadedPath = await downloadFullAudiobook(page); - - // Use ffprobe (same toolchain as the server) to validate the combined audio duration. - // The TTS route is mocked to return a 10s sample.mp3 for each page, so with at least - // two chapters we should be close to ~20 seconds of audio. - const durationSeconds = await getAudioDurationSeconds(downloadedPath); - // Duration must be within a reasonable window around 20 seconds to allow - // for encoding variations and container overhead. - expect(durationSeconds).toBeGreaterThan(18); - expect(durationSeconds).toBeLessThan(22); + await withDownloadedFullAudiobook(page, async ({ filePath }) => { + // Use ffprobe (same toolchain as the server) to validate the combined audio duration. + // The TTS route is mocked to return a 10s sample.mp3 for each page, so with at least + // two chapters we should be close to ~20 seconds of audio. + const durationSeconds = await getAudioDurationSeconds(filePath); + // Duration must be within a reasonable window around 20 seconds to allow + // for encoding variations and container overhead. + expect(durationSeconds).toBeGreaterThan(18); + expect(durationSeconds).toBeLessThan(22); + }); // Also check the chapter metadata API for consistency const json = await expectChaptersBackendState(page, bookId); @@ -238,11 +286,11 @@ test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async await expect(chapterActionsButtons).toHaveCount(chapterCountAfterCancel, { timeout: 60_000 }); // The Full Download button should still be available for the partially generated audiobook - const downloadedPath = await downloadFullAudiobook(page); - - const durationSeconds = await getAudioDurationSeconds(downloadedPath); - expect(durationSeconds).toBeGreaterThan(25); - expect(durationSeconds).toBeLessThan(300); + await withDownloadedFullAudiobook(page, async ({ filePath }) => { + const durationSeconds = await getAudioDurationSeconds(filePath); + expect(durationSeconds).toBeGreaterThan(25); + expect(durationSeconds).toBeLessThan(300); + }); // Backend should still reflect the same number of chapters as when we first // observed the stabilized post-cancellation state. @@ -272,12 +320,12 @@ test('exports a single MP3 audiobook PDF page via chapters menu', async ({ page await expect(chapterActionsButtons.first()).toBeVisible({ timeout: 90_000 }); // Download via frontend button - const downloadedPath = await downloadFullAudiobook(page); - - const durationSeconds = await getAudioDurationSeconds(downloadedPath); - // For EPUB we just assert a sane non-trivial duration; at least one 10s mocked chapter. - expect(durationSeconds).toBeGreaterThan(9); - expect(durationSeconds).toBeLessThan(300); + await withDownloadedFullAudiobook(page, async ({ filePath }) => { + const durationSeconds = await getAudioDurationSeconds(filePath); + // For EPUB we just assert a sane non-trivial duration; at least one 10s mocked chapter. + expect(durationSeconds).toBeGreaterThan(9); + expect(durationSeconds).toBeLessThan(300); + }); await resetAudiobookIfPresent(page); }); @@ -372,12 +420,12 @@ test('regenerates a single MP3 audiobook PDF page and exports full audiobook', a await expect(chapterActionsButtons).toHaveCount(chapterCountBefore, { timeout: 20_000 }); // Full Download should still work and produce a valid combined audiobook - const downloadedPath = await downloadFullAudiobook(page); - - const durationSeconds = await getAudioDurationSeconds(downloadedPath); - // With two mocked 10s chapters we expect roughly 20s; allow a small window. - expect(durationSeconds).toBeGreaterThan(18); - expect(durationSeconds).toBeLessThan(22); + await withDownloadedFullAudiobook(page, async ({ filePath }) => { + const durationSeconds = await getAudioDurationSeconds(filePath); + // With two mocked 10s chapters we expect roughly 20s; allow a small window. + expect(durationSeconds).toBeGreaterThan(18); + expect(durationSeconds).toBeLessThan(22); + }); // Backend should still report the same number of chapters and valid durations const json = await expectChaptersBackendState(page, bookId); @@ -448,10 +496,11 @@ test('resumes audiobook when a chapter is missing and full download succeeds (PD // UI should also stop showing a missing placeholder after resume completes. await expect(page.getByText(/Missing •/)).toHaveCount(0, { timeout: 120_000 }); - const downloadedPath = await downloadFullAudiobook(page); - const durationSeconds = await getAudioDurationSeconds(downloadedPath); - expect(durationSeconds).toBeGreaterThan(18); - expect(durationSeconds).toBeLessThan(22); + await withDownloadedFullAudiobook(page, async ({ filePath }) => { + const durationSeconds = await getAudioDurationSeconds(filePath); + expect(durationSeconds).toBeGreaterThan(18); + expect(durationSeconds).toBeLessThan(22); + }); const jsonAfterResume = await expectChaptersBackendState(page, bookId); expect(jsonAfterResume.exists).toBe(true); From 4a5f3060f2714636123129635a45019ab38ffdcd Mon Sep 17 00:00:00 2001 From: Richard R Date: Sun, 8 Feb 2026 11:15:57 -0700 Subject: [PATCH 15/55] refactor(documents): migrate from IndexedDB to server-backed storage with caching - Replace client-side IndexedDB with server-first document storage - Add document caching layer for offline capability and performance - Implement document transfer during anonymous-to-authenticated account linking - Add database indexes for improved query performance - Update document contexts to use new caching system - Refactor document upload and deletion APIs - Add audiobook pruning for missing files - Improve test isolation with namespace support - Add unit tests for document cache and transfer functions --- ...ucky_zarek.sql => 0000_perfect_risque.sql} | 4 +- drizzle/postgres/meta/0000_snapshot.json | 43 ++- drizzle/postgres/meta/_journal.json | 4 +- ...teep_shaman.sql => 0000_lively_korvac.sql} | 4 +- drizzle/sqlite/meta/0000_snapshot.json | 22 +- drizzle/sqlite/meta/_journal.json | 4 +- playwright.config.ts | 1 + src/app/api/audiobook/chapter/route.ts | 10 +- src/app/api/audiobook/route.ts | 2 + src/app/api/audiobook/status/route.ts | 7 + src/app/api/documents/content/route.ts | 126 ++++++++ src/app/api/documents/docx-to-pdf/route.ts | 144 --------- .../api/documents/docx-to-pdf/upload/route.ts | 158 ++++++++++ src/app/api/documents/route.ts | 122 +++++--- src/app/api/documents/upload/route.ts | 95 ++++++ src/app/layout.tsx | 21 +- src/app/providers.tsx | 2 + src/components/DocumentSkeleton.tsx | 6 +- src/components/DocumentUploader.tsx | 20 +- src/components/SettingsModal.tsx | 292 +++++++++--------- src/components/doclist/DocumentList.tsx | 37 +-- src/components/doclist/DocumentListItem.tsx | 46 +-- src/components/doclist/DocumentPreview.tsx | 78 ++--- .../documents/DexieMigrationModal.tsx | 254 +++++++++++++++ src/contexts/ConfigContext.tsx | 4 - src/contexts/DocumentContext.tsx | 131 +++++--- src/contexts/EPUBContext.tsx | 36 ++- src/contexts/HTMLContext.tsx | 26 +- src/contexts/PDFContext.tsx | 24 +- src/contexts/TTSContext.tsx | 32 -- src/db/schema_postgres.ts | 4 +- src/db/schema_sqlite.ts | 4 +- src/lib/client-documents.ts | 126 ++++++++ src/lib/client.ts | 27 -- src/lib/document-cache.ts | 148 +++++++++ src/lib/documentPreview.ts | 38 +-- src/lib/server/audiobook-prune.ts | 45 +++ src/lib/server/auth.ts | 15 +- src/lib/server/claim-data.ts | 32 ++ src/lib/server/db-indexing.ts | 36 +-- src/lib/server/documents-utils.ts | 33 ++ src/lib/text-snippets.ts | 40 +++ src/types/config.ts | 2 + src/types/documents.ts | 1 + tests/accessibility.spec.ts | 6 +- tests/delete.spec.ts | 6 +- tests/export.spec.ts | 24 +- tests/folders.spec.ts | 13 +- tests/global-teardown.ts | 29 ++ tests/helpers.ts | 71 ++++- tests/navigation.spec.ts | 14 +- tests/play.spec.ts | 6 +- tests/unit/document-cache.spec.ts | 95 ++++++ tests/unit/transfer-user-documents.spec.ts | 75 +++++ tests/upload.spec.ts | 12 +- 55 files changed, 1988 insertions(+), 669 deletions(-) rename drizzle/postgres/{0000_lucky_zarek.sql => 0000_perfect_risque.sql} (92%) rename drizzle/sqlite/{0000_steep_shaman.sql => 0000_lively_korvac.sql} (92%) create mode 100644 src/app/api/documents/content/route.ts delete mode 100644 src/app/api/documents/docx-to-pdf/route.ts create mode 100644 src/app/api/documents/docx-to-pdf/upload/route.ts create mode 100644 src/app/api/documents/upload/route.ts create mode 100644 src/components/documents/DexieMigrationModal.tsx create mode 100644 src/lib/client-documents.ts create mode 100644 src/lib/document-cache.ts create mode 100644 src/lib/server/audiobook-prune.ts create mode 100644 src/lib/server/documents-utils.ts create mode 100644 src/lib/text-snippets.ts create mode 100644 tests/global-teardown.ts create mode 100644 tests/unit/document-cache.spec.ts create mode 100644 tests/unit/transfer-user-documents.spec.ts diff --git a/drizzle/postgres/0000_lucky_zarek.sql b/drizzle/postgres/0000_perfect_risque.sql similarity index 92% rename from drizzle/postgres/0000_lucky_zarek.sql rename to drizzle/postgres/0000_perfect_risque.sql index 2c24b6d..25c0a97 100644 --- a/drizzle/postgres/0000_lucky_zarek.sql +++ b/drizzle/postgres/0000_perfect_risque.sql @@ -39,7 +39,7 @@ CREATE TABLE "audiobooks" ( ); --> statement-breakpoint CREATE TABLE "documents" ( - "id" text, + "id" text NOT NULL, "user_id" text NOT NULL, "name" text NOT NULL, "type" text NOT NULL, @@ -92,4 +92,6 @@ CREATE TABLE "verification" ( --> statement-breakpoint ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_documents_user_id" ON "documents" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "idx_documents_user_id_last_modified" ON "documents" USING btree ("user_id","last_modified");--> statement-breakpoint CREATE INDEX "idx_user_tts_chars_date" ON "user_tts_chars" USING btree ("date"); \ No newline at end of file diff --git a/drizzle/postgres/meta/0000_snapshot.json b/drizzle/postgres/meta/0000_snapshot.json index 04b9ece..1493370 100644 --- a/drizzle/postgres/meta/0000_snapshot.json +++ b/drizzle/postgres/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "63a00f5b-69ff-4d95-a48a-9e84cc331445", + "id": "03395f16-5c34-409a-9dc1-69329d67bcd9", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -258,7 +258,7 @@ "name": "id", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, "user_id": { "name": "user_id", @@ -304,7 +304,44 @@ "default": "now()" } }, - "indexes": {}, + "indexes": { + "idx_documents_user_id": { + "name": "idx_documents_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_documents_user_id_last_modified": { + "name": "idx_documents_user_id_last_modified", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_modified", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, "foreignKeys": {}, "compositePrimaryKeys": { "documents_id_user_id_pk": { diff --git a/drizzle/postgres/meta/_journal.json b/drizzle/postgres/meta/_journal.json index 7b112b7..c0cfceb 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "7", - "when": 1769641576464, - "tag": "0000_lucky_zarek", + "when": 1770191341206, + "tag": "0000_perfect_risque", "breakpoints": true } ] diff --git a/drizzle/sqlite/0000_steep_shaman.sql b/drizzle/sqlite/0000_lively_korvac.sql similarity index 92% rename from drizzle/sqlite/0000_steep_shaman.sql rename to drizzle/sqlite/0000_lively_korvac.sql index 190de61..ac57079 100644 --- a/drizzle/sqlite/0000_steep_shaman.sql +++ b/drizzle/sqlite/0000_lively_korvac.sql @@ -40,7 +40,7 @@ CREATE TABLE `audiobooks` ( ); --> statement-breakpoint CREATE TABLE `documents` ( - `id` text, + `id` text NOT NULL, `user_id` text NOT NULL, `name` text NOT NULL, `type` text NOT NULL, @@ -51,6 +51,8 @@ CREATE TABLE `documents` ( PRIMARY KEY(`id`, `user_id`) ); --> statement-breakpoint +CREATE INDEX `idx_documents_user_id` ON `documents` (`user_id`);--> statement-breakpoint +CREATE INDEX `idx_documents_user_id_last_modified` ON `documents` (`user_id`,`last_modified`);--> statement-breakpoint CREATE TABLE `session` ( `id` text PRIMARY KEY NOT NULL, `expires_at` integer NOT NULL, diff --git a/drizzle/sqlite/meta/0000_snapshot.json b/drizzle/sqlite/meta/0000_snapshot.json index ab33249..0a7d123 100644 --- a/drizzle/sqlite/meta/0000_snapshot.json +++ b/drizzle/sqlite/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "af57dff3-a6ec-418e-9fa1-8197d6839e48", + "id": "9c9e61fc-1f90-463a-99dc-232397f316e7", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "account": { @@ -277,7 +277,7 @@ "name": "id", "type": "text", "primaryKey": false, - "notNull": false, + "notNull": true, "autoincrement": false }, "user_id": { @@ -331,7 +331,23 @@ "default": "(cast(strftime('%s','now') as int) * 1000)" } }, - "indexes": {}, + "indexes": { + "idx_documents_user_id": { + "name": "idx_documents_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_documents_user_id_last_modified": { + "name": "idx_documents_user_id_last_modified", + "columns": [ + "user_id", + "last_modified" + ], + "isUnique": false + } + }, "foreignKeys": {}, "compositePrimaryKeys": { "documents_id_user_id_pk": { diff --git a/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json index cb28033..4cf4829 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1769641566451, - "tag": "0000_steep_shaman", + "when": 1770191340954, + "tag": "0000_lively_korvac", "breakpoints": true } ] diff --git a/playwright.config.ts b/playwright.config.ts index c36eec1..c7aa815 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ testDir: './tests', timeout: 30 * 1000, outputDir: './tests/results', + globalTeardown: './tests/global-teardown.ts', // fullyParallel: false, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index 2c2dfbb..4da59ee 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -10,6 +10,7 @@ import { and, eq, inArray } from 'drizzle-orm'; import { requireAuthContext } from '@/lib/server/auth'; import { ensureDbIndexed } from '@/lib/server/db-indexing'; import { applyOpenReaderTestNamespacePath, getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; +import { pruneAudiobookChapterIfMissingFile, pruneAudiobookIfMissingDir } from '@/lib/server/audiobook-prune'; export const dynamic = 'force-dynamic'; @@ -77,9 +78,16 @@ export async function GET(request: NextRequest) { } const intermediateDir = join(getAudiobooksRootDir(request, existingBook.userId, authEnabled), `${bookId}-audiobook`); + const dirExists = existsSync(intermediateDir); + if (!dirExists) { + await pruneAudiobookIfMissingDir(bookId, existingBook.userId, false); + return NextResponse.json({ error: 'Chapter not found' }, { status: 404 }); + } const chapter = await findStoredChapterByIndex(intermediateDir, chapterIndex, request.signal); - if (!chapter || !existsSync(chapter.filePath)) { + const chapterFileExists = !!chapter?.filePath && existsSync(chapter.filePath); + if (!chapterFileExists) { + await pruneAudiobookChapterIfMissingFile(bookId, existingBook.userId, chapterIndex, false); return NextResponse.json({ error: 'Chapter not found' }, { status: 404 }); } diff --git a/src/app/api/audiobook/route.ts b/src/app/api/audiobook/route.ts index ba57a95..254f704 100644 --- a/src/app/api/audiobook/route.ts +++ b/src/app/api/audiobook/route.ts @@ -14,6 +14,7 @@ import { eq, and, inArray } from 'drizzle-orm'; import { requireAuthContext } from '@/lib/server/auth'; import { ensureDbIndexed } from '@/lib/server/db-indexing'; import { applyOpenReaderTestNamespacePath, getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; +import { pruneAudiobookIfMissingDir } from '@/lib/server/audiobook-prune'; export const dynamic = 'force-dynamic'; @@ -438,6 +439,7 @@ export async function GET(request: NextRequest) { ); if (!existsSync(intermediateDir)) { + await pruneAudiobookIfMissingDir(bookId, existingBook.userId, false); return NextResponse.json({ error: 'Book not found' }, { status: 404 }); } diff --git a/src/app/api/audiobook/status/route.ts b/src/app/api/audiobook/status/route.ts index 733f6db..78d558a 100644 --- a/src/app/api/audiobook/status/route.ts +++ b/src/app/api/audiobook/status/route.ts @@ -12,6 +12,7 @@ import { audiobooks } from '@/db/schema'; import { eq, and, inArray } from 'drizzle-orm'; import { ensureDbIndexed } from '@/lib/server/db-indexing'; import { applyOpenReaderTestNamespacePath, getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; +import { pruneAudiobookChaptersNotOnDisk, pruneAudiobookIfMissingDir } from '@/lib/server/audiobook-prune'; export const dynamic = 'force-dynamic'; @@ -82,6 +83,7 @@ export async function GET(request: NextRequest) { const intermediateDir = join(getAudiobooksRootDir(request, existingBook.userId, authEnabled), `${bookId}-audiobook`); if (!existsSync(intermediateDir)) { + await pruneAudiobookIfMissingDir(bookId, existingBook.userId, false); return NextResponse.json({ chapters: [], exists: false, @@ -92,6 +94,11 @@ export async function GET(request: NextRequest) { } const stored = await listStoredChapters(intermediateDir, request.signal); + await pruneAudiobookChaptersNotOnDisk( + bookId, + existingBook.userId, + stored.map((c) => c.index), + ); const chapters: TTSAudiobookChapter[] = stored.map((chapter) => ({ index: chapter.index, title: chapter.title, diff --git a/src/app/api/documents/content/route.ts b/src/app/api/documents/content/route.ts new file mode 100644 index 0000000..ed6c0fe --- /dev/null +++ b/src/app/api/documents/content/route.ts @@ -0,0 +1,126 @@ +import { open, readFile } from 'fs/promises'; +import path from 'path'; +import { NextRequest, NextResponse } from 'next/server'; +import { and, eq, inArray } from 'drizzle-orm'; +import { db } from '@/db'; +import { documents } from '@/db/schema'; +import { ensureDocumentsV1Ready, isDocumentsV1Ready, DOCUMENTS_V1_DIR } from '@/lib/server/docstore'; +import { requireAuthContext } from '@/lib/server/auth'; +import { ensureDbIndexed } from '@/lib/server/db-indexing'; +import { contentTypeForName } from '@/lib/server/library'; +import { extractRawTextSnippet } from '@/lib/text-snippets'; +import { isEnoent } from '@/lib/server/documents-utils'; +import { applyOpenReaderTestNamespacePath, getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; + +export const dynamic = 'force-dynamic'; + +function clampInt(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; + return Math.max(min, Math.min(max, Math.trunc(value))); +} + +async function readHeadBuffer(filePath: string, maxBytes: number): Promise { + const handle = await open(filePath, 'r'); + try { + const buf = Buffer.allocUnsafe(maxBytes); + const result = await handle.read(buf, 0, maxBytes, 0); + return buf.subarray(0, result.bytesRead); + } finally { + await handle.close(); + } +} + +export async function GET(req: NextRequest) { + try { + await ensureDocumentsV1Ready(); + if (!(await isDocumentsV1Ready())) { + return NextResponse.json( + { error: 'Documents storage is not migrated; run /api/migrations/v1 first.' }, + { status: 409 }, + ); + } + + const ctxOrRes = await requireAuthContext(req); + if (ctxOrRes instanceof Response) return ctxOrRes; + + const testNamespace = getOpenReaderTestNamespace(req.headers); + const documentsDir = applyOpenReaderTestNamespacePath(DOCUMENTS_V1_DIR, testNamespace); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + + const storageUserId = ctxOrRes.userId ?? unclaimedUserId; + const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + + await ensureDbIndexed(); + + const url = new URL(req.url); + const id = url.searchParams.get('id'); + const format = (url.searchParams.get('format') || '').toLowerCase().trim(); + if (!id) { + return NextResponse.json({ error: 'Missing id' }, { status: 400 }); + } + + const docs = await db + .select({ id: documents.id, userId: documents.userId, name: documents.name, filePath: documents.filePath }) + .from(documents) + .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds))); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const doc = docs.find((d: any) => d.userId === storageUserId) ?? docs[0]; + + if (!doc?.filePath) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + const filePath = path.join(documentsDir, doc.filePath); + const filename = doc.name || doc.filePath; + + if (format === 'snippet') { + const maxChars = clampInt(Number.parseInt(url.searchParams.get('maxChars') || '1600', 10), 100, 8000); + const maxBytes = clampInt(Number.parseInt(url.searchParams.get('maxBytes') || '131072', 10), 4096, 1024 * 1024); + + try { + const head = await readHeadBuffer(filePath, maxBytes); + const decoded = new TextDecoder().decode(new Uint8Array(head)); + const snippet = extractRawTextSnippet(decoded, maxChars); + return NextResponse.json( + { snippet }, + { headers: { 'Cache-Control': 'no-store' } }, + ); + } catch (error) { + if (isEnoent(error)) { + await db + .delete(documents) + .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds))); + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + throw error; + } + } + + let content: ArrayBuffer; + try { + const buf = await readFile(filePath); + content = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); + } catch (error) { + if (isEnoent(error)) { + // The DB can become stale if a file is deleted manually from the docstore. + // Prune rows so the client stops showing ghost documents. + await db + .delete(documents) + .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds))); + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + throw error; + } + + return new NextResponse(content, { + headers: { + 'Content-Type': contentTypeForName(filename), + 'Cache-Control': 'no-store', + }, + }); + } catch (error) { + console.error('Error loading document content:', error); + return NextResponse.json({ error: 'Failed to load document content' }, { status: 500 }); + } +} diff --git a/src/app/api/documents/docx-to-pdf/route.ts b/src/app/api/documents/docx-to-pdf/route.ts deleted file mode 100644 index b8ac32a..0000000 --- a/src/app/api/documents/docx-to-pdf/route.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { writeFile, mkdir, readFile, readdir, rm, stat } from 'fs/promises'; -import { spawn } from 'child_process'; -import path from 'path'; -import { existsSync } from 'fs'; -import { randomUUID } from 'crypto'; -import { pathToFileURL } from 'url'; -import { auth } from '@/lib/server/auth'; - -const DOCSTORE_DIR = path.join(process.cwd(), 'docstore'); -const TEMP_DIR = path.join(DOCSTORE_DIR, 'tmp'); - -async function ensureTempDir() { - if (!existsSync(DOCSTORE_DIR)) { - await mkdir(DOCSTORE_DIR, { recursive: true }); - } - if (!existsSync(TEMP_DIR)) { - await mkdir(TEMP_DIR, { recursive: true }); - } -} - -async function convertDocxToPdf(inputPath: string, outputDir: string, profileDir?: string): Promise { - return new Promise((resolve, reject) => { - const args: string[] = []; - if (profileDir) { - // Ensure a per-job profile to isolate concurrent soffice instances - // Note: mkdir is async; we prepare the directory before calling this in POST, but safe to include here too - // (we avoid awaiting here; POST ensures creation) - args.push(`-env:UserInstallation=${pathToFileURL(profileDir).toString()}`); - } - args.push( - '--headless', - '--nologo', - '--convert-to', 'pdf', - '--outdir', outputDir, - inputPath - ); - const process = spawn('soffice', args); - - process.on('error', (error) => { - reject(error); - }); - - process.on('close', (code) => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`LibreOffice conversion failed with code ${code}`)); - } - }); - }); -} - -async function waitForPdfReady(dir: string, timeoutMs = 20000, intervalMs = 100): Promise { - const end = Date.now() + timeoutMs; - while (Date.now() < end) { - const files = await readdir(dir); - const pdf = files.find(f => f.toLowerCase().endsWith('.pdf')); - if (pdf) { - const pdfPath = path.join(dir, pdf); - try { - const first = await stat(pdfPath); - await new Promise((res) => setTimeout(res, intervalMs)); - const second = await stat(pdfPath); - if (second.size > 0 && second.size === first.size) { - return pdfPath; - } - } catch { - // If stat fails (transient), continue polling - } - } - await new Promise((res) => setTimeout(res, intervalMs)); - } - throw new Error(`PDF not ready in ${dir} after ${timeoutMs}ms`); -} - -export async function POST(req: NextRequest) { - try { - // Auth check - require session - const session = await auth?.api.getSession({ headers: req.headers }); - if (auth && !session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - await ensureTempDir(); - - const formData = await req.formData(); - const file = formData.get('file') as File; - - if (!file) { - return NextResponse.json( - { error: 'No file provided' }, - { status: 400 } - ); - } - - if (!file.name.toLowerCase().endsWith('.docx')) { - return NextResponse.json( - { error: 'File must be a .docx document' }, - { status: 400 } - ); - } - - const buffer = Buffer.from(await file.arrayBuffer()); - const tempId = randomUUID(); - const jobDir = path.join(TEMP_DIR, tempId); - await mkdir(jobDir, { recursive: true }); - const profileDir = path.join(jobDir, 'lo-profile'); - await mkdir(profileDir, { recursive: true }); - const inputPath = path.join(jobDir, 'input.docx'); - - // Write the uploaded file - await writeFile(inputPath, buffer); - - try { - // Convert the file - await convertDocxToPdf(inputPath, jobDir, profileDir); - - // Return the PDF file - const pdfPath = await waitForPdfReady(jobDir); - const pdfContent = await readFile(pdfPath); - - // Clean up temp files - await rm(jobDir, { recursive: true, force: true }).catch(console.error); - - return new NextResponse(pdfContent, { - headers: { - 'Content-Type': 'application/pdf', - 'Content-Disposition': `attachment; filename="${path.parse(file.name).name}.pdf"` - } - }); - } catch (error) { - // Clean up temp files on error - await rm(jobDir, { recursive: true, force: true }).catch(console.error); - throw error; - } - } catch (error) { - console.error('Error converting DOCX to PDF:', error); - return NextResponse.json( - { error: 'Failed to convert document' }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/src/app/api/documents/docx-to-pdf/upload/route.ts b/src/app/api/documents/docx-to-pdf/upload/route.ts new file mode 100644 index 0000000..2075b90 --- /dev/null +++ b/src/app/api/documents/docx-to-pdf/upload/route.ts @@ -0,0 +1,158 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { writeFile, mkdir, readFile, readdir, rm, stat } from 'fs/promises'; +import { spawn } from 'child_process'; +import path from 'path'; +import { existsSync } from 'fs'; +import { randomUUID, createHash } from 'crypto'; +import { pathToFileURL } from 'url'; +import { requireAuthContext } from '@/lib/server/auth'; +import { ensureDocumentsV1Ready, isDocumentsV1Ready, DOCUMENTS_V1_DIR } from '@/lib/server/docstore'; +import { db } from '@/db'; +import { documents } from '@/db/schema'; +import { safeDocumentName, trySetFileMtime } from '@/lib/server/documents-utils'; +import { applyOpenReaderTestNamespacePath, getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; + +const DOCSTORE_DIR = path.join(process.cwd(), 'docstore'); +const TEMP_DIR = path.join(DOCSTORE_DIR, 'tmp'); + +async function ensureTempDir() { + if (!existsSync(DOCSTORE_DIR)) { + await mkdir(DOCSTORE_DIR, { recursive: true }); + } + if (!existsSync(TEMP_DIR)) { + await mkdir(TEMP_DIR, { recursive: true }); + } +} + +async function convertDocxToPdf(inputPath: string, outputDir: string, profileDir?: string): Promise { + return new Promise((resolve, reject) => { + const args: string[] = []; + if (profileDir) { + args.push(`-env:UserInstallation=${pathToFileURL(profileDir).toString()}`); + } + args.push('--headless', '--nologo', '--convert-to', 'pdf', '--outdir', outputDir, inputPath); + const proc = spawn('soffice', args); + + proc.on('error', (error) => reject(error)); + proc.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`LibreOffice conversion failed with code ${code}`)); + }); + }); +} + +async function waitForPdfReady(dir: string, timeoutMs = 20000, intervalMs = 100): Promise { + const end = Date.now() + timeoutMs; + while (Date.now() < end) { + const files = await readdir(dir); + const pdf = files.find((f) => f.toLowerCase().endsWith('.pdf')); + if (pdf) { + const pdfPath = path.join(dir, pdf); + try { + const first = await stat(pdfPath); + await new Promise((res) => setTimeout(res, intervalMs)); + const second = await stat(pdfPath); + if (second.size > 0 && second.size === first.size) { + return pdfPath; + } + } catch { + // ignore transient errors + } + } + await new Promise((res) => setTimeout(res, intervalMs)); + } + throw new Error(`PDF not ready in ${dir} after ${timeoutMs}ms`); +} + +export async function POST(req: NextRequest) { + try { + await ensureDocumentsV1Ready(); + if (!(await isDocumentsV1Ready())) { + return NextResponse.json( + { error: 'Documents storage is not migrated; run /api/migrations/v1 first.' }, + { status: 409 }, + ); + } + + const testNamespace = getOpenReaderTestNamespace(req.headers); + const documentsDir = applyOpenReaderTestNamespacePath(DOCUMENTS_V1_DIR, testNamespace); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + await mkdir(documentsDir, { recursive: true }); + + const ctxOrRes = await requireAuthContext(req); + if (ctxOrRes instanceof Response) return ctxOrRes; + const storageUserId = ctxOrRes.userId ?? unclaimedUserId; + + await ensureTempDir(); + + const formData = await req.formData(); + const file = formData.get('file'); + if (!(file instanceof File)) { + return NextResponse.json({ error: 'No file provided' }, { status: 400 }); + } + + if (!file.name.toLowerCase().endsWith('.docx')) { + return NextResponse.json({ error: 'File must be a .docx document' }, { status: 400 }); + } + + const docxBytes = Buffer.from(await file.arrayBuffer()); + // IMPORTANT: use sha of the source DOCX bytes for a stable ID across conversions. + const id = createHash('sha256').update(docxBytes).digest('hex'); + + const tempId = randomUUID(); + const jobDir = path.join(TEMP_DIR, tempId); + await mkdir(jobDir, { recursive: true }); + const profileDir = path.join(jobDir, 'lo-profile'); + await mkdir(profileDir, { recursive: true }); + const inputPath = path.join(jobDir, 'input.docx'); + + await writeFile(inputPath, docxBytes); + + try { + await convertDocxToPdf(inputPath, jobDir, profileDir); + const pdfPath = await waitForPdfReady(jobDir); + const pdfContent = await readFile(pdfPath); + + const derivedName = safeDocumentName(`${path.parse(file.name).name}.pdf`, `${id}.pdf`); + const targetFileName = `${id}__${encodeURIComponent(derivedName)}`; + const targetPath = path.join(documentsDir, targetFileName); + + try { + await stat(targetPath); + } catch { + await writeFile(targetPath, pdfContent); + } + + const lastModified = Number.isFinite(file.lastModified) ? file.lastModified : Date.now(); + await trySetFileMtime(targetPath, lastModified); + + await db + .insert(documents) + .values({ + id, + userId: storageUserId, + name: derivedName, + type: 'pdf', + size: pdfContent.length, + lastModified, + filePath: targetFileName, + }) + .onConflictDoNothing(); + + return NextResponse.json({ + stored: { + id, + name: derivedName, + type: 'pdf', + size: pdfContent.length, + lastModified, + }, + }); + } finally { + await rm(jobDir, { recursive: true, force: true }).catch(() => {}); + } + } catch (error) { + console.error('Error converting/uploading DOCX:', error); + return NextResponse.json({ error: 'Failed to convert document' }, { status: 500 }); + } +} diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 2ee2722..5d3d7ef 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -1,39 +1,20 @@ import { createHash } from 'crypto'; -import { readFile, stat, unlink, utimes, writeFile } from 'fs/promises'; +import { mkdir, readFile, stat, unlink, writeFile } from 'fs/promises'; +import { existsSync } from 'fs'; import { NextRequest, NextResponse } from 'next/server'; import path from 'path'; -import { DOCUMENTS_V1_DIR, UNCLAIMED_USER_ID, ensureDocumentsV1Ready, isDocumentsV1Ready } from '@/lib/server/docstore'; +import { DOCUMENTS_V1_DIR, ensureDocumentsV1Ready, isDocumentsV1Ready } from '@/lib/server/docstore'; import type { BaseDocument, DocumentType, SyncedDocument } from '@/types/documents'; import { db } from '@/db'; import { documents } from '@/db/schema'; import { eq, and, inArray, count } from 'drizzle-orm'; import { requireAuthContext } from '@/lib/server/auth'; import { ensureDbIndexed } from '@/lib/server/db-indexing'; +import { isEnoent, toDocumentTypeFromName, trySetFileMtime } from '@/lib/server/documents-utils'; +import { applyOpenReaderTestNamespacePath, getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; export const dynamic = 'force-dynamic'; -const SYNC_DIR = DOCUMENTS_V1_DIR; - -async function trySetFileMtime(filePath: string, lastModifiedMs: number): Promise { - if (!Number.isFinite(lastModifiedMs)) return; - const mtime = new Date(lastModifiedMs); - if (Number.isNaN(mtime.getTime())) return; - - try { - await utimes(filePath, mtime, mtime); - } catch (error) { - console.warn('Failed to set document mtime:', filePath, error); - } -} - -function toDocumentTypeFromName(name: string): DocumentType { - const ext = path.extname(name).toLowerCase(); - if (ext === '.pdf') return 'pdf'; - if (ext === '.epub') return 'epub'; - if (ext === '.docx') return 'docx'; - return 'html'; -} - export async function POST(req: NextRequest) { try { await ensureDocumentsV1Ready(); @@ -44,9 +25,14 @@ export async function POST(req: NextRequest) { ); } + const testNamespace = getOpenReaderTestNamespace(req.headers); + const syncDir = applyOpenReaderTestNamespacePath(DOCUMENTS_V1_DIR, testNamespace); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + await mkdir(syncDir, { recursive: true }); + const ctxOrRes = await requireAuthContext(req); if (ctxOrRes instanceof Response) return ctxOrRes; - const storageUserId = ctxOrRes.userId ?? UNCLAIMED_USER_ID; + const storageUserId = ctxOrRes.userId ?? unclaimedUserId; const data = await req.json(); const documentsData = data.documents as SyncedDocument[]; @@ -63,7 +49,7 @@ export async function POST(req: NextRequest) { const safeName = baseName.replaceAll('\u0000', '').slice(0, 240) || `${id}.${doc.type}`; const targetFileName = `${id}__${encodeURIComponent(safeName)}`; - const targetPath = path.join(SYNC_DIR, targetFileName); + const targetPath = path.join(syncDir, targetFileName); // Write file if not exists try { @@ -110,8 +96,13 @@ export async function GET(req: NextRequest) { const ctxOrRes = await requireAuthContext(req); if (ctxOrRes instanceof Response) return ctxOrRes; - const storageUserId = ctxOrRes.userId ?? UNCLAIMED_USER_ID; - const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, UNCLAIMED_USER_ID] : [UNCLAIMED_USER_ID]; + + const testNamespace = getOpenReaderTestNamespace(req.headers); + const syncDir = applyOpenReaderTestNamespacePath(DOCUMENTS_V1_DIR, testNamespace); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + + const storageUserId = ctxOrRes.userId ?? unclaimedUserId; + const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; await ensureDbIndexed(); @@ -128,14 +119,14 @@ export async function GET(req: NextRequest) { const targetIds = idsParam ? idsParam.split(',').filter(Boolean) : null; // Query database for documents the user is allowed to access - let allowedDocs: { id: string; name: string; type: string; size: number; lastModified: number; filePath: string }[] = []; + let allowedDocs: { id: string; userId: string; name: string; type: string; size: number; lastModified: number; filePath: string }[] = []; const conditions = [ inArray(documents.userId, allowedUserIds), ...(targetIds ? [inArray(documents.id, targetIds)] : []), ]; const rows = await db.select().from(documents).where(and(...conditions)); - allowedDocs = rows as unknown as { id: string; name: string; type: string; size: number; lastModified: number; filePath: string }[]; + allowedDocs = rows as unknown as { id: string; userId: string; name: string; type: string; size: number; lastModified: number; filePath: string }[]; const results: (BaseDocument | SyncedDocument)[] = []; @@ -145,12 +136,23 @@ export async function GET(req: NextRequest) { ? (doc.type as DocumentType) : toDocumentTypeFromName(doc.name); + // If the underlying file was deleted manually, keep the API self-healing: + // prune the DB row so clients stop listing ghost documents. + const absolutePath = doc.filePath ? path.join(syncDir, doc.filePath) : ''; + if (!absolutePath || !existsSync(absolutePath)) { + await db + .delete(documents) + .where(and(eq(documents.id, doc.id), eq(documents.userId, doc.userId))); + continue; + } + const metadata: BaseDocument = { id: doc.id!, name: doc.name, size: doc.size, lastModified: doc.lastModified, type, + scope: doc.userId === unclaimedUserId ? 'unclaimed' : 'user', }; if (!includeData) { @@ -159,16 +161,19 @@ export async function GET(req: NextRequest) { } try { - const filePath = path.join(SYNC_DIR, doc.filePath); - const content = await readFile(filePath); + const content = await readFile(absolutePath); results.push({ ...metadata, data: Array.from(new Uint8Array(content)), }); } catch (err) { + if (isEnoent(err)) { + await db + .delete(documents) + .where(and(eq(documents.id, doc.id), eq(documents.userId, doc.userId))); + continue; + } console.warn(`Failed to read content for document ${doc.id} at ${doc.filePath}`, err); - // Skip adding data if file is missing, or just skip item? - // Best to skip item to avoid client errors on partial data } } @@ -193,12 +198,46 @@ export async function DELETE(req: NextRequest) { // Auth check - require session const ctxOrRes = await requireAuthContext(req); if (ctxOrRes instanceof Response) return ctxOrRes; - const storageUserId = ctxOrRes.userId ?? UNCLAIMED_USER_ID; + + const testNamespace = getOpenReaderTestNamespace(req.headers); + const syncDir = applyOpenReaderTestNamespacePath(DOCUMENTS_V1_DIR, testNamespace); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + + const storageUserId = ctxOrRes.userId ?? unclaimedUserId; await ensureDbIndexed(); const url = new URL(req.url); const idsParam = url.searchParams.get('ids'); + const scopeParam = (url.searchParams.get('scope') || '').toLowerCase().trim(); + + const wantsUnclaimed = scopeParam === 'unclaimed'; + const wantsUser = scopeParam === '' || scopeParam === 'user'; + + if (!wantsUser && !wantsUnclaimed) { + return NextResponse.json( + { error: "Invalid scope. Expected 'user' (default) or 'unclaimed'." }, + { status: 400 }, + ); + } + + // Deleting the global unclaimed pool is a privileged operation when auth is enabled. + if (ctxOrRes.authEnabled && wantsUnclaimed && ctxOrRes.user?.isAnonymous) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const targetUserIds = Array.from( + new Set( + [ + ...(wantsUser ? [storageUserId] : []), + ...(wantsUnclaimed ? [unclaimedUserId] : []), + ].filter(Boolean), + ), + ); + + if (targetUserIds.length === 0) { + return NextResponse.json({ success: true, deleted: 0 }); + } // Determine which IDs to try to delete let targetIds: string[] = []; @@ -206,9 +245,12 @@ export async function DELETE(req: NextRequest) { if (idsParam) { targetIds = idsParam.split(',').filter(Boolean); } else { - // Existing behavior was "nuke everything"; keep it scoped to "my" docs. - const userDocs = await db.select({ id: documents.id }).from(documents).where(eq(documents.userId, storageUserId)); - targetIds = userDocs.map((d: { id: string | null }) => d.id!).filter(Boolean) as string[]; + // Existing behavior was "nuke everything"; keep it scoped to the selected user buckets. + const rows = await db + .select({ id: documents.id }) + .from(documents) + .where(inArray(documents.userId, targetUserIds)); + targetIds = rows.map((d: { id: string | null }) => d.id!).filter(Boolean) as string[]; } if (targetIds.length === 0) { @@ -219,7 +261,7 @@ export async function DELETE(req: NextRequest) { const rows = await db .delete(documents) - .where(and(eq(documents.userId, storageUserId), inArray(documents.id, targetIds))) + .where(and(inArray(documents.userId, targetUserIds), inArray(documents.id, targetIds))) .returning({ id: documents.id, filePath: documents.filePath }); // eslint-disable-next-line @typescript-eslint/no-explicit-any rows.forEach((r: any) => deletedRows.push({ id: r.id!, filePath: r.filePath })); @@ -238,7 +280,7 @@ export async function DELETE(req: NextRequest) { refCount = Number(ref?.count ?? 0); if (refCount === 0) { - const filePath = path.join(SYNC_DIR, row.filePath); + const filePath = path.join(syncDir, row.filePath); try { await unlink(filePath); } catch { diff --git a/src/app/api/documents/upload/route.ts b/src/app/api/documents/upload/route.ts new file mode 100644 index 0000000..4491a33 --- /dev/null +++ b/src/app/api/documents/upload/route.ts @@ -0,0 +1,95 @@ +import { createHash } from 'crypto'; +import { mkdir, stat, writeFile } from 'fs/promises'; +import path from 'path'; +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/db'; +import { documents } from '@/db/schema'; +import { ensureDocumentsV1Ready, isDocumentsV1Ready, DOCUMENTS_V1_DIR } from '@/lib/server/docstore'; +import { requireAuthContext } from '@/lib/server/auth'; +import { safeDocumentName, toDocumentTypeFromName, trySetFileMtime } from '@/lib/server/documents-utils'; +import { applyOpenReaderTestNamespacePath, getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; + +export const dynamic = 'force-dynamic'; + +export async function POST(req: NextRequest) { + try { + await ensureDocumentsV1Ready(); + if (!(await isDocumentsV1Ready())) { + return NextResponse.json( + { error: 'Documents storage is not migrated; run /api/migrations/v1 first.' }, + { status: 409 }, + ); + } + + const testNamespace = getOpenReaderTestNamespace(req.headers); + const documentsDir = applyOpenReaderTestNamespacePath(DOCUMENTS_V1_DIR, testNamespace); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + await mkdir(documentsDir, { recursive: true }); + + const ctxOrRes = await requireAuthContext(req); + if (ctxOrRes instanceof Response) return ctxOrRes; + const storageUserId = ctxOrRes.userId ?? unclaimedUserId; + + const form = await req.formData(); + const files = form.getAll('files').filter((value): value is File => value instanceof File); + + if (files.length === 0) { + return NextResponse.json({ error: 'Missing files' }, { status: 400 }); + } + + const stored: Array<{ + id: string; + name: string; + type: 'pdf' | 'epub' | 'docx' | 'html'; + size: number; + lastModified: number; + }> = []; + + for (const file of files) { + const arrayBuffer = await file.arrayBuffer(); + const content = Buffer.from(new Uint8Array(arrayBuffer)); + const id = createHash('sha256').update(content).digest('hex'); + + const safeName = safeDocumentName(file.name, `${id}.${toDocumentTypeFromName(file.name)}`); + const targetFileName = `${id}__${encodeURIComponent(safeName)}`; + const targetPath = path.join(documentsDir, targetFileName); + + try { + await stat(targetPath); + } catch { + await writeFile(targetPath, content); + } + + const lastModified = Number.isFinite(file.lastModified) ? file.lastModified : Date.now(); + await trySetFileMtime(targetPath, lastModified); + + const type = toDocumentTypeFromName(safeName); + + await db + .insert(documents) + .values({ + id, + userId: storageUserId, + name: safeName, + type, + size: content.length, + lastModified, + filePath: targetFileName, + }) + .onConflictDoNothing(); + + stored.push({ + id, + name: safeName, + type, + size: content.length, + lastModified, + }); + } + + return NextResponse.json({ stored }); + } catch (error) { + console.error('Error uploading documents:', error); + return NextResponse.json({ error: 'Failed to upload documents' }, { status: 500 }); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0fc698b..b10bb15 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -76,7 +76,26 @@ export default function RootLayout({ children }: { children: ReactNode }) {
)}
- + diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 591346b..46c7867 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -10,6 +10,7 @@ import { HTMLProvider } from '@/contexts/HTMLContext'; import { AuthRateLimitProvider } from '@/contexts/AuthRateLimitContext'; import { PrivacyModal } from '@/components/PrivacyModal'; import { AuthLoader } from '@/components/auth/AuthLoader'; +import { DexieMigrationModal } from '@/components/documents/DexieMigrationModal'; interface ProvidersProps { children: ReactNode; @@ -31,6 +32,7 @@ export function Providers({ children, authEnabled, authBaseUrl }: ProvidersProps <> {children} + diff --git a/src/components/DocumentSkeleton.tsx b/src/components/DocumentSkeleton.tsx index 5e0471e..0adccda 100644 --- a/src/components/DocumentSkeleton.tsx +++ b/src/components/DocumentSkeleton.tsx @@ -6,10 +6,6 @@ export function DocumentSkeleton() { const timer = setTimeout(() => { toast('There might be an issue with the file import. Please try again.', { icon: '⚠️', - style: { - background: 'var(--background)', - color: 'var(--accent)', - }, duration: 5000, }); }, 3000); @@ -24,4 +20,4 @@ export function DocumentSkeleton() {
); -} \ No newline at end of file +} diff --git a/src/components/DocumentUploader.tsx b/src/components/DocumentUploader.tsx index cd35696..df7cce1 100644 --- a/src/components/DocumentUploader.tsx +++ b/src/components/DocumentUploader.tsx @@ -4,7 +4,7 @@ import { useState, useCallback } from 'react'; import { useDropzone } from 'react-dropzone'; import { UploadIcon } from '@/components/icons/Icons'; import { useDocuments } from '@/contexts/DocumentContext'; -import { convertDocxToPdf as convertDocxToPdfClient } from '@/lib/client'; +import { uploadDocxAsPdf } from '@/lib/client-documents'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; @@ -17,19 +17,13 @@ export function DocumentUploader({ className = '', variant = 'default' }: Docume const { addPDFDocument: addPDF, addEPUBDocument: addEPUB, - addHTMLDocument: addHTML + addHTMLDocument: addHTML, + refreshDocuments, } = useDocuments(); const [isUploading, setIsUploading] = useState(false); const [isConverting, setIsConverting] = useState(false); const [error, setError] = useState(null); - const convertDocxToPdf = async (file: File): Promise => { - const pdfBlob = await convertDocxToPdfClient(file); - return new File([pdfBlob], file.name.replace(/\.docx$/, '.pdf'), { - type: 'application/pdf', - }); - }; - const onDrop = useCallback(async (acceptedFiles: File[]) => { if (!acceptedFiles || acceptedFiles.length === 0) return; @@ -45,10 +39,12 @@ export function DocumentUploader({ className = '', variant = 'default' }: Docume } else if (file.type === 'text/plain' || file.type === 'text/markdown' || file.name.endsWith('.md')) { await addHTML(file); } else if (isDev && file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') { + // Preserve prior UX: show "Converting DOCX..." state rather than generic uploading. setIsUploading(false); setIsConverting(true); - const pdfFile = await convertDocxToPdf(file); - await addPDF(pdfFile); + // Convert+upload directly on the server. Use sha(docx) as stable ID to avoid duplicates. + await uploadDocxAsPdf(file); + await refreshDocuments(); setIsConverting(false); setIsUploading(true); } @@ -60,7 +56,7 @@ export function DocumentUploader({ className = '', variant = 'default' }: Docume setIsUploading(false); setIsConverting(false); } - }, [addHTML, addPDF, addEPUB]); + }, [addHTML, addPDF, addEPUB, refreshDocuments]); const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 83c262e..58c9386 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -23,13 +23,12 @@ import Link from 'next/link'; import { useTheme } from '@/contexts/ThemeContext'; import { useConfig } from '@/contexts/ConfigContext'; import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons'; -import { syncSelectedDocumentsToServer, loadSelectedDocumentsFromServer, importSelectedDocuments, getAppConfig, getFirstVisit, setFirstVisit, getAllPdfDocuments, getAllEpubDocuments, getAllHtmlDocuments } from '@/lib/dexie'; +import { getAppConfig, getFirstVisit, setFirstVisit } from '@/lib/dexie'; import { useDocuments } from '@/contexts/DocumentContext'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ProgressPopup } from '@/components/ProgressPopup'; import { useTimeEstimation } from '@/hooks/useTimeEstimation'; import { THEMES } from '@/contexts/ThemeContext'; -import { deleteServerDocuments } from '@/lib/client'; import { DocumentSelectionModal } from '@/components/DocumentSelectionModal'; import { BaseDocument } from '@/types/documents'; import { getAuthClient } from '@/lib/auth-client'; @@ -37,6 +36,8 @@ import { useAuthSession } from '@/hooks/useAuthSession'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { useRouter } from 'next/navigation'; import { showPrivacyModal } from '@/components/PrivacyModal'; +import { deleteDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client-documents'; +import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/document-cache'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; @@ -50,43 +51,39 @@ export function SettingsModal({ className = '' }: { className?: string }) { const { theme, setTheme } = useTheme(); const { apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, updateConfig, updateConfigKey } = useConfig(); - const { clearPDFs, clearEPUBs, clearHTML } = useDocuments(); + const { refreshDocuments } = useDocuments(); const [localApiKey, setLocalApiKey] = useState(apiKey); const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl); const [localTTSProvider, setLocalTTSProvider] = useState(ttsProvider); const [modelValue, setModelValue] = useState(ttsModel); const [customModelInput, setCustomModelInput] = useState(''); const [localTTSInstructions, setLocalTTSInstructions] = useState(ttsInstructions); - const [isSyncing, setIsSyncing] = useState(false); - const [isLoading, setIsLoading] = useState(false); const [isImportingLibrary, setIsImportingLibrary] = useState(false); const [isSelectionModalOpen, setIsSelectionModalOpen] = useState(false); const [selectionModalProps, setSelectionModalProps] = useState<{ title: string; confirmLabel: string; - mode: 'library' | 'load' | 'save'; defaultSelected: boolean; initialFiles?: BaseDocument[]; fetcher?: () => Promise; }>({ title: '', confirmLabel: '', - mode: 'library', defaultSelected: false }); const [showProgress, setShowProgress] = useState(false); const [statusMessage, setStatusMessage] = useState(''); - const [operationType, setOperationType] = useState<'sync' | 'load' | 'library'>('sync'); const [abortController, setAbortController] = useState(null); const selectedTheme = themes.find(t => t.id === theme) || themes[0]; - const [showClearLocalConfirm, setShowClearLocalConfirm] = useState(false); - const [showClearServerConfirm, setShowClearServerConfirm] = useState(false); + const [showDeleteDocsConfirm, setShowDeleteDocsConfirm] = useState(false); + const [deleteDocsMode, setDeleteDocsMode] = useState<'user' | 'unclaimed'>('user'); const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false); const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation(); const { authEnabled, baseUrl: authBaseUrl } = useAuthConfig(); const { data: session } = useAuthSession(); const router = useRouter(); + const isBusy = isImportingLibrary; const ttsProviders = useMemo(() => [ { id: 'custom-openai', name: 'Custom OpenAI-Like' }, @@ -189,51 +186,26 @@ export function SettingsModal({ className = '' }: { className?: string }) { } }, [modelValue, ttsModels]); - const handleSync = async () => { - // Collect local documents - const pdfs = await getAllPdfDocuments(); - const epubs = await getAllEpubDocuments(); - const htmls = await getAllHtmlDocuments(); - - const allDocs: BaseDocument[] = [ - ...pdfs.map(d => ({ ...d, type: 'pdf' as const })), - ...epubs.map(d => ({ ...d, type: 'epub' as const })), - ...htmls.map(d => ({ ...d, type: 'html' as const })) - ]; - - setSelectionModalProps({ - title: 'Save to Server', - confirmLabel: 'Save', - mode: 'save', - defaultSelected: true, - initialFiles: allDocs - }); - setIsSelectionModalOpen(true); + const handleRefresh = async () => { + try { + await refreshDocuments(); + } catch (error) { + console.error('Failed to refresh documents:', error); + } }; - const handleLoad = async () => { - setSelectionModalProps({ - title: 'Load from Server', - confirmLabel: 'Load', - mode: 'load', - defaultSelected: true, - fetcher: async () => { - const res = await fetch('/api/documents?list=true'); - if (!res.ok) throw new Error('Failed to list server documents'); - const data = await res.json(); - // Handle case where API might return error object - if (data.error) throw new Error(data.error); - return data.documents || []; - } - }); - setIsSelectionModalOpen(true); + const handleClearCache = async () => { + try { + await clearDocumentCache(); + } catch (error) { + console.error('Failed to clear cache:', error); + } }; const handleImportLibrary = async () => { setSelectionModalProps({ title: 'Import from Library', confirmLabel: 'Import', - mode: 'library', defaultSelected: false, fetcher: async () => { const res = await fetch('/api/documents/library?limit=10000'); @@ -249,8 +221,6 @@ export function SettingsModal({ className = '' }: { className?: string }) { const controller = new AbortController(); setAbortController(controller); - const mode = selectionModalProps.mode; - // Close modal? Maybe keep open until started? // Let's close it here, process starts. // Actually we keep it open if we want to show loading state INSIDE modal? @@ -262,49 +232,53 @@ export function SettingsModal({ className = '' }: { className?: string }) { setShowProgress(true); setProgress(0); - if (mode === 'save') { - setIsSyncing(true); - setOperationType('sync'); - setStatusMessage('Preparing documents...'); - await syncSelectedDocumentsToServer(selectedFiles, (progress, status) => { - if (controller.signal.aborted) return; - setProgress(progress); - if (status) setStatusMessage(status); - }, controller.signal); - } else if (mode === 'load') { - setIsLoading(true); - setOperationType('load'); - setStatusMessage('Downloading documents...'); - // Need ids - const ids = selectedFiles.map(f => f.id); - await loadSelectedDocumentsFromServer(ids, (progress, status) => { - if (controller.signal.aborted) return; - setProgress(progress); - if (status) setStatusMessage(status); - }, controller.signal); - if (!controller.signal.aborted) setStatusMessage('Documents loaded'); - } else if (mode === 'library') { - setIsImportingLibrary(true); - setOperationType('library'); - setStatusMessage('Importing selected documents...'); - await importSelectedDocuments(selectedFiles, (progress, status) => { - if (controller.signal.aborted) return; - setProgress(progress); - if (status) setStatusMessage(status); - }, controller.signal); + setIsImportingLibrary(true); + + for (let i = 0; i < selectedFiles.length; i++) { + if (controller.signal.aborted) break; + const doc = selectedFiles[i]; + setStatusMessage(`Importing ${i + 1}/${selectedFiles.length}: ${doc.name}`); + setProgress((i / Math.max(1, selectedFiles.length)) * 90); + + const contentResponse = await fetch(`/api/documents/library/content?id=${encodeURIComponent(doc.id)}`, { + signal: controller.signal, + }); + if (!contentResponse.ok) { + console.warn(`Failed to download library document: ${doc.name}`); + continue; + } + + const bytes = await contentResponse.arrayBuffer(); + const file = new File([bytes], doc.name, { + type: mimeTypeForDoc(doc), + lastModified: doc.lastModified, + }); + + const uploaded = await uploadDocuments([file], { signal: controller.signal }); + const stored = uploaded[0]; + if (stored) { + await cacheStoredDocumentFromBytes(stored, bytes).catch((err) => { + console.warn('Failed to cache imported document:', stored.id, err); + }); + } + } + + if (!controller.signal.aborted) { + setProgress(95); + await refreshDocuments(); + setProgress(100); + setStatusMessage('Import complete'); } } catch (error) { if (controller.signal.aborted) { - console.log(`${mode} operation cancelled`); + console.log('library import cancelled'); setStatusMessage('Operation cancelled'); } else { - console.error(`${mode} failed:`, error); - setStatusMessage(`${mode} failed. Please try again.`); + console.error('library import failed:', error); + setStatusMessage('Import failed. Please try again.'); } } finally { - setIsSyncing(false); - setIsLoading(false); setIsImportingLibrary(false); setShowProgress(false); setProgress(0); @@ -313,20 +287,20 @@ export function SettingsModal({ className = '' }: { className?: string }) { } }; - const handleClearLocal = async () => { - await clearPDFs(); - await clearEPUBs(); - await clearHTML(); - setShowClearLocalConfirm(false); - }; - - const handleClearServer = async () => { + const handleDeleteDocs = async () => { try { - await deleteServerDocuments(); + if (deleteDocsMode === 'user') { + await deleteDocuments(); + await refreshDocuments().catch(() => {}); + } else if (deleteDocsMode === 'unclaimed') { + await deleteDocuments({ scope: 'unclaimed' }); + await refreshDocuments().catch(() => {}); + } } catch (error) { console.error('Delete failed:', error); + } finally { + setShowDeleteDocsConfirm(false); } - setShowClearServerConfirm(false); }; @@ -386,6 +360,35 @@ export function SettingsModal({ className = '' }: { className?: string }) { ...(authEnabled ? [{ name: 'User', icon: '👤' }] : []) ]; + const isAnonymous = Boolean(session?.user?.isAnonymous); + + const userDeleteLabel = authEnabled ? (isAnonymous ? 'Delete anonymous docs' : 'Delete all user docs') : 'Delete server docs'; + const userDeleteTitle = authEnabled ? (isAnonymous ? 'Delete Anonymous Docs' : 'Delete All User Docs') : 'Delete Server Docs'; + const userDeleteMessage = authEnabled + ? (isAnonymous + ? 'Are you sure you want to delete all anonymous-session documents? This action cannot be undone.' + : 'Are you sure you want to delete all of your documents from the server? This action cannot be undone.') + : 'Are you sure you want to delete all documents from the server? This action cannot be undone.'; + + const [unclaimedCounts, setUnclaimedCounts] = useState<{ documents: number; audiobooks: number } | null>(null); + const canDeleteUnclaimed = + authEnabled && session?.user && !isAnonymous && (unclaimedCounts?.documents ?? 0) > 0; + + useEffect(() => { + if (!authEnabled) return; + if (!session?.user) return; + if (session.user.isAnonymous) return; + // fetch claimable counts (unclaimed docs/audiobooks) + fetch('/api/user/claim') + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data && typeof data.documents === 'number' && typeof data.audiobooks === 'number') { + setUnclaimedCounts({ documents: data.documents, audiobooks: data.audiobooks }); + } + }) + .catch(() => {}); + }, [authEnabled, session?.user]); + return ( <> -
-
} - -
- -
- - {isDev && + {canDeleteUnclaimed && ( + } + > + Delete unclaimed docs + + )} +
+ )}
@@ -895,21 +904,19 @@ export function SettingsModal({ className = '' }: { className?: string }) { setShowClearLocalConfirm(false)} - onConfirm={handleClearLocal} - title="Delete Local Documents" - message="Are you sure you want to delete all local documents? This action cannot be undone." - confirmText="Delete" - isDangerous={true} - /> - - setShowClearServerConfirm(false)} - onConfirm={handleClearServer} - title="Delete Server Documents" - message="Are you sure you want to delete all documents from the server? This action cannot be undone." + isOpen={showDeleteDocsConfirm} + onClose={() => setShowDeleteDocsConfirm(false)} + onConfirm={handleDeleteDocs} + title={ + deleteDocsMode === 'unclaimed' + ? 'Delete Unclaimed Docs' + : userDeleteTitle + } + message={ + deleteDocsMode === 'unclaimed' + ? 'Are you sure you want to delete all unclaimed documents? This action cannot be undone.' + : userDeleteMessage + } confirmText="Delete" isDangerous={true} /> @@ -934,20 +941,17 @@ export function SettingsModal({ className = '' }: { className?: string }) { } setShowProgress(false); setProgress(0); - setIsSyncing(false); - setIsLoading(false); setIsImportingLibrary(false); setStatusMessage(''); - setOperationType('sync'); setAbortController(null); }} statusMessage={statusMessage} - operationType={operationType} + operationType="library" cancelText="Cancel" /> !isImportingLibrary && !isSyncing && !isLoading && setIsSelectionModalOpen(false)} + onClose={() => !isBusy && setIsSelectionModalOpen(false)} onConfirm={handleModalConfirm} title={selectionModalProps.title} confirmLabel={selectionModalProps.confirmLabel} diff --git a/src/components/doclist/DocumentList.tsx b/src/components/doclist/DocumentList.tsx index 1300c1f..3fc68ed 100644 --- a/src/components/doclist/DocumentList.tsx +++ b/src/components/doclist/DocumentList.tsx @@ -105,28 +105,23 @@ export function DocumentList() { } }, [sortBy, sortDirection, folders, collapsedFolders, showHint, viewMode, isInitialized]); + // Reconcile folder state against the current server-backed document list. + // If a document no longer exists on the server, drop it from folders to avoid stale UI. + useEffect(() => { + if (!isInitialized) return; + const ids = new Set([...pdfDocs, ...epubDocs, ...htmlDocs].map((d) => d.id)); + setFolders((prev) => + prev.map((folder) => ({ + ...folder, + documents: folder.documents.filter((d) => ids.has(d.id)), + })), + ); + }, [isInitialized, pdfDocs, epubDocs, htmlDocs]); + const allDocuments: DocumentListDocument[] = [ - ...pdfDocs.map(doc => ({ - id: doc.id, - name: doc.name, - size: doc.size, - lastModified: doc.lastModified, - type: 'pdf' as const, - })), - ...epubDocs.map(doc => ({ - id: doc.id, - name: doc.name, - size: doc.size, - lastModified: doc.lastModified, - type: 'epub' as const, - })), - ...htmlDocs.map(doc => ({ - id: doc.id, - name: doc.name, - size: doc.size, - lastModified: doc.lastModified, - type: 'html' as const, - })), + ...pdfDocs.map((doc) => ({ ...doc, type: 'pdf' as const })), + ...epubDocs.map((doc) => ({ ...doc, type: 'epub' as const })), + ...htmlDocs.map((doc) => ({ ...doc, type: 'html' as const })), ]; const sortDocuments = useCallback((docs: DocumentListDocument[]) => { diff --git a/src/components/doclist/DocumentListItem.tsx b/src/components/doclist/DocumentListItem.tsx index 062f3ae..ab5270a 100644 --- a/src/components/doclist/DocumentListItem.tsx +++ b/src/components/doclist/DocumentListItem.tsx @@ -5,6 +5,8 @@ import { Button } from '@headlessui/react'; import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; import { DocumentListDocument } from '@/types/documents'; import { DocumentPreview } from '@/components/doclist/DocumentPreview'; +import { useAuthSession } from '@/hooks/useAuthSession'; +import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; interface DocumentListItemProps { doc: DocumentListDocument; @@ -33,10 +35,14 @@ export function DocumentListItem({ }: DocumentListItemProps) { const [loading, setLoading] = useState(false); const router = useRouter(); + const { authEnabled } = useAuthConfig(); + const { data: session } = useAuthSession(); // Only allow drag and drop interactions for documents not in folders const isDraggable = dragEnabled && !doc.folderId; const allowDropTarget = !doc.folderId; + const isAnonymousAuthed = Boolean(authEnabled && session?.user?.isAnonymous); + const showDeleteButton = !(isAnonymousAuthed && doc.scope === 'unclaimed'); const handleDocumentClick = (e: React.MouseEvent) => { e.preventDefault(); @@ -107,15 +113,17 @@ export function DocumentListItem({

- + {showDeleteButton && ( + + )} ) : ( @@ -144,15 +152,17 @@ export function DocumentListItem({

- + {showDeleteButton && ( + + )} )} diff --git a/src/components/doclist/DocumentPreview.tsx b/src/components/doclist/DocumentPreview.tsx index 5bc8eed..1a17429 100644 --- a/src/components/doclist/DocumentPreview.tsx +++ b/src/components/doclist/DocumentPreview.tsx @@ -1,12 +1,11 @@ import { DocumentListDocument } from '@/types/documents'; import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; import { useEffect, useMemo, useRef, useState } from 'react'; -import { getEpubDocument, getHtmlDocument, getPdfDocument } from '@/lib/dexie'; import { extractEpubCoverToDataUrl, - extractRawTextSnippet, renderPdfFirstPageToDataUrl, } from '@/lib/documentPreview'; +import { downloadDocumentContent, getDocumentContentSnippet } from '@/lib/client-documents'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -74,45 +73,49 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) { } let cancelled = false; + const controller = new AbortController(); - const run = async () => { - setIsGenerating(true); - try { - const targetWidth = 240; + const run = async () => { + setIsGenerating(true); + try { + const targetWidth = 240; - if (doc.type === 'pdf') { - const pdfDoc = await getPdfDocument(doc.id); - if (!pdfDoc?.data) return; - const dataUrl = await renderPdfFirstPageToDataUrl(pdfDoc.data, targetWidth); - if (cancelled) return; - imagePreviewCache.set(previewKey, dataUrl); - setImagePreview(dataUrl); - setTextPreview(null); - return; - } + if (doc.type === 'pdf') { + const data = await downloadDocumentContent(doc.id, { signal: controller.signal }); + if (cancelled) return; + const dataUrl = await renderPdfFirstPageToDataUrl(data, targetWidth); + if (cancelled) return; + imagePreviewCache.set(previewKey, dataUrl); + setImagePreview(dataUrl); + setTextPreview(null); + return; + } - if (doc.type === 'epub') { - const epubDoc = await getEpubDocument(doc.id); - if (!epubDoc?.data) return; - const cover = await extractEpubCoverToDataUrl(epubDoc.data, targetWidth); - if (cancelled) return; - if (cover) { - imagePreviewCache.set(previewKey, cover); - setImagePreview(cover); - setTextPreview(null); - } - return; - } + if (doc.type === 'epub') { + const data = await downloadDocumentContent(doc.id, { signal: controller.signal }); + if (cancelled) return; + const cover = await extractEpubCoverToDataUrl(data, targetWidth); + if (cancelled) return; + if (cover) { + imagePreviewCache.set(previewKey, cover); + setImagePreview(cover); + setTextPreview(null); + } + return; + } - if (doc.type === 'html') { - const htmlDoc = await getHtmlDocument(doc.id); - if (cancelled) return; - const snippet = extractRawTextSnippet(htmlDoc?.data ?? ''); - textPreviewCache.set(previewKey, snippet); - setTextPreview(snippet); - setImagePreview(null); - return; - } + if (doc.type === 'html') { + const snippet = await getDocumentContentSnippet(doc.id, { + maxChars: 1600, + maxBytes: 128 * 1024, + signal: controller.signal, + }); + if (cancelled) return; + textPreviewCache.set(previewKey, snippet); + setTextPreview(snippet); + setImagePreview(null); + return; + } } catch { // fall back to icon } finally { @@ -125,6 +128,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) { run(); return () => { cancelled = true; + controller.abort(); }; }, [doc.id, doc.type, isVisible, previewKey]); diff --git a/src/components/documents/DexieMigrationModal.tsx b/src/components/documents/DexieMigrationModal.tsx new file mode 100644 index 0000000..ff9a493 --- /dev/null +++ b/src/components/documents/DexieMigrationModal.tsx @@ -0,0 +1,254 @@ +'use client'; + +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Button } from '@headlessui/react'; +import { getAppConfig, getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, updateAppConfig } from '@/lib/dexie'; +import { listDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client-documents'; +import { useDocuments } from '@/contexts/DocumentContext'; +import type { BaseDocument } from '@/types/documents'; +import { cacheStoredDocumentFromBytes } from '@/lib/document-cache'; + +export function DexieMigrationModal() { + const { refreshDocuments } = useDocuments(); + const [isOpen, setIsOpen] = useState(false); + const [localCount, setLocalCount] = useState(0); + const [missingCount, setMissingCount] = useState(0); + const [isUploading, setIsUploading] = useState(false); + const [progress, setProgress] = useState(0); + const [status, setStatus] = useState(''); + const checkedRef = useRef(false); + + const closeDisabled = isUploading; + + const loadLocalDexieDocs = useCallback(async (): Promise<{ + docs: BaseDocument[]; + pdfById: Map; + epubById: Map; + htmlById: Map; + }> => { + const [pdfs, epubs, htmls] = await Promise.all([getAllPdfDocuments(), getAllEpubDocuments(), getAllHtmlDocuments()]); + const docs: BaseDocument[] = [ + ...pdfs.map((d) => ({ id: d.id, name: d.name, size: d.size, lastModified: d.lastModified, type: 'pdf' as const })), + ...epubs.map((d) => ({ id: d.id, name: d.name, size: d.size, lastModified: d.lastModified, type: 'epub' as const })), + ...htmls.map((d) => ({ id: d.id, name: d.name, size: d.size, lastModified: d.lastModified, type: 'html' as const })), + ]; + const pdfById = new Map(pdfs.map((d) => [d.id, d] as const)); + const epubById = new Map(epubs.map((d) => [d.id, d] as const)); + const htmlById = new Map(htmls.map((d) => [d.id, d] as const)); + return { docs, pdfById, epubById, htmlById }; + }, []); + + const checkAndMaybePrompt = useCallback(async () => { + if (checkedRef.current) return; + checkedRef.current = true; + + const cfg = await getAppConfig(); + if (!cfg?.privacyAccepted) { + // Wait for privacy acceptance before prompting. + checkedRef.current = false; + return; + } + + if (cfg.documentsMigrationPrompted) return; + + const { docs } = await loadLocalDexieDocs(); + const count = docs.length; + setLocalCount(count); + + if (count === 0) return; + + const serverDocs = await listDocuments().catch(() => null); + if (serverDocs) { + const serverIds = new Set(serverDocs.map((d) => d.id)); + const missing = docs.filter((d) => !serverIds.has(d.id)); + setMissingCount(missing.length); + if (missing.length === 0) return; + } else { + // If the server list fails, still prompt so the user can attempt upload. + setMissingCount(count); + } + + setIsOpen(true); + }, [loadLocalDexieDocs]); + + useEffect(() => { + checkAndMaybePrompt().catch((err) => { + console.error('Dexie migration check failed:', err); + }); + }, [checkAndMaybePrompt]); + + useEffect(() => { + const handler = () => { + checkedRef.current = false; + checkAndMaybePrompt().catch((err) => console.error('Dexie migration check failed:', err)); + }; + window.addEventListener('openreader:privacyAccepted', handler as EventListener); + return () => window.removeEventListener('openreader:privacyAccepted', handler as EventListener); + }, [checkAndMaybePrompt]); + + const title = useMemo(() => `Upload your local documents?`, []); + + const handleSkip = useCallback(async () => { + await updateAppConfig({ documentsMigrationPrompted: true }); + setIsOpen(false); + }, []); + + const handleUpload = useCallback(async () => { + setIsUploading(true); + setProgress(0); + setStatus('Preparing upload...'); + + try { + const { docs, pdfById, epubById, htmlById } = await loadLocalDexieDocs(); + + const serverDocs = await listDocuments().catch(() => null); + const serverIds = serverDocs ? new Set(serverDocs.map((d) => d.id)) : null; + const toUpload = serverIds ? docs.filter((d) => !serverIds.has(d.id)) : docs; + setMissingCount(toUpload.length); + + const encoder = new TextEncoder(); + for (let i = 0; i < toUpload.length; i++) { + const doc = toUpload[i]; + setStatus(`Uploading ${i + 1}/${toUpload.length}: ${doc.name}`); + setProgress((i / Math.max(1, toUpload.length)) * 100); + + // Pull raw data from Dexie for this doc + if (doc.type === 'pdf') { + const full = pdfById.get(doc.id) ?? null; + if (!full) continue; + const bytes = full.data.slice(0); + const file = new File([full.data], full.name, { + type: mimeTypeForDoc(doc), + lastModified: full.lastModified, + }); + const uploaded = await uploadDocuments([file]); + const stored = uploaded[0] ?? null; + if (stored) { + await cacheStoredDocumentFromBytes(stored, bytes).catch(() => {}); + } + } else if (doc.type === 'epub') { + const full = epubById.get(doc.id) ?? null; + if (!full) continue; + const bytes = full.data.slice(0); + const file = new File([full.data], full.name, { + type: mimeTypeForDoc(doc), + lastModified: full.lastModified, + }); + const uploaded = await uploadDocuments([file]); + const stored = uploaded[0] ?? null; + if (stored) { + await cacheStoredDocumentFromBytes(stored, bytes).catch(() => {}); + } + } else { + const full = htmlById.get(doc.id) ?? null; + if (!full) continue; + const bytes = encoder.encode(full.data).buffer; + const file = new File([full.data], full.name, { + type: mimeTypeForDoc(doc), + lastModified: full.lastModified, + }); + const uploaded = await uploadDocuments([file]); + const stored = uploaded[0] ?? null; + if (stored) { + await cacheStoredDocumentFromBytes(stored, bytes).catch(() => {}); + } + } + } + + setProgress(100); + setStatus('Refreshing...'); + await refreshDocuments(); + await updateAppConfig({ documentsMigrationPrompted: true }); + setIsOpen(false); + } catch (err) { + console.error('Dexie migration upload failed:', err); + setStatus('Upload failed. You can retry or skip.'); + checkedRef.current = false; + } finally { + setIsUploading(false); + } + }, [loadLocalDexieDocs, refreshDocuments]); + + if (!isOpen) return null; + + return ( + + (closeDisabled ? null : setIsOpen(false))}> + +
+ + +
+
+ + + + {title} + +
+

+ Found {localCount} document{localCount === 1 ? '' : 's'} stored locally from an older version. + {missingCount > 0 ? ( + <> {missingCount} {missingCount === 1 ? 'is' : 'are'} not here yet. + ) : null} + {' '}This app now stores documents on the server and keeps a local cache for speed. +

+ {isUploading && ( +
+

{status}

+
+
+
+
+ )} + {!isUploading && status ?

{status}

: null} +
+ +
+ + +
+ + +
+
+
+
+ ); +} diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index f8443a2..3622679 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -121,10 +121,6 @@ export function ConfigProvider({ children }: { children: ReactNode }) { toast.success('Library migration complete', { duration: 5000, icon: '📦', - style: { - background: 'var(--offbase)', - color: 'var(--foreground)', - }, }); } } diff --git a/src/contexts/DocumentContext.tsx b/src/contexts/DocumentContext.tsx index 597b235..126f918 100644 --- a/src/contexts/DocumentContext.tsx +++ b/src/contexts/DocumentContext.tsx @@ -1,30 +1,31 @@ 'use client'; -import { createContext, useContext, ReactNode } from 'react'; -import { usePDFDocuments } from '@/hooks/pdf/usePDFDocuments'; -import { useEPUBDocuments } from '@/hooks/epub/useEPUBDocuments'; -import { useHTMLDocuments } from '@/hooks/html/useHTMLDocuments'; -import { PDFDocument, EPUBDocument, HTMLDocument } from '@/types/documents'; +import { createContext, useCallback, useContext, useEffect, useMemo, useState, ReactNode } from 'react'; +import type { BaseDocument, DocumentType } from '@/types/documents'; +import { listDocuments, uploadDocuments, deleteDocuments } from '@/lib/client-documents'; +import { putCachedEpub, putCachedHtml, putCachedPdf, evictCachedEpub, evictCachedHtml, evictCachedPdf } from '@/lib/document-cache'; interface DocumentContextType { // PDF Documents - pdfDocs: PDFDocument[]; + pdfDocs: Array; addPDFDocument: (file: File) => Promise; removePDFDocument: (id: string) => Promise; isPDFLoading: boolean; // EPUB Documents - epubDocs: EPUBDocument[]; + epubDocs: Array; addEPUBDocument: (file: File) => Promise; removeEPUBDocument: (id: string) => Promise; isEPUBLoading: boolean; // HTML Documents - htmlDocs: HTMLDocument[]; + htmlDocs: Array; addHTMLDocument: (file: File) => Promise; removeHTMLDocument: (id: string) => Promise; isHTMLLoading: boolean; + refreshDocuments: () => Promise; + clearPDFs: () => Promise; clearEPUBs: () => Promise; clearHTML: () => Promise; @@ -33,47 +34,105 @@ interface DocumentContextType { const DocumentContext = createContext(undefined); export function DocumentProvider({ children }: { children: ReactNode }) { - const { - documents: pdfDocs, - addDocument: addPDFDocument, - removeDocument: removePDFDocument, - isLoading: isPDFLoading, - clearDocuments: clearPDFs - } = usePDFDocuments(); + const [docs, setDocs] = useState(null); + const isLoading = docs === null; - const { - documents: epubDocs, - addDocument: addEPUBDocument, - removeDocument: removeEPUBDocument, - isLoading: isEPUBLoading, - clearDocuments: clearEPUBs - } = useEPUBDocuments(); + const refreshDocuments = useCallback(async () => { + const serverDocs = await listDocuments(); + // Keep only viewer-supported types + setDocs(serverDocs.filter((d) => d.type === 'pdf' || d.type === 'epub' || d.type === 'html')); + }, []); - const { - documents: htmlDocs, - addDocument: addHTMLDocument, - removeDocument: removeHTMLDocument, - isLoading: isHTMLLoading, - clearDocuments: clearHTML - } = useHTMLDocuments(); + useEffect(() => { + refreshDocuments().catch((err) => { + console.error('Failed to load documents from server:', err); + setDocs([]); + }); + }, [refreshDocuments]); + + const docsByType = useMemo(() => { + const pdfDocs = (docs ?? []).filter((d) => d.type === 'pdf') as Array; + const epubDocs = (docs ?? []).filter((d) => d.type === 'epub') as Array; + const htmlDocs = (docs ?? []).filter((d) => d.type === 'html') as Array; + return { pdfDocs, epubDocs, htmlDocs }; + }, [docs]); + + const cacheUploaded = useCallback(async (stored: BaseDocument, file: File) => { + try { + if (stored.type === 'pdf') { + await putCachedPdf(stored, await file.arrayBuffer()); + } else if (stored.type === 'epub') { + await putCachedEpub(stored, await file.arrayBuffer()); + } else if (stored.type === 'html') { + const buf = await file.arrayBuffer(); + const decoded = new TextDecoder().decode(new Uint8Array(buf)); + await putCachedHtml(stored, decoded); + } + } catch (err) { + // Cache failures should not block uploads. + console.warn('Failed to cache uploaded document:', stored.id, err); + } + }, []); + + const addDocument = useCallback(async (file: File): Promise => { + const [stored] = await uploadDocuments([file]); + if (!stored) throw new Error('Upload succeeded but returned no document'); + await cacheUploaded(stored, file); + setDocs((prev) => { + const current = prev ?? []; + // Replace if same id exists (e.g. re-upload) + const next = current.filter((d) => d.id !== stored.id); + return [stored, ...next]; + }); + return stored.id; + }, [cacheUploaded]); + + const addPDFDocument = useCallback(async (file: File) => addDocument(file), [addDocument]); + const addEPUBDocument = useCallback(async (file: File) => addDocument(file), [addDocument]); + const addHTMLDocument = useCallback(async (file: File) => addDocument(file), [addDocument]); + + const removeById = useCallback(async (id: string) => { + await deleteDocuments({ ids: [id] }); + await Promise.allSettled([evictCachedPdf(id), evictCachedEpub(id), evictCachedHtml(id)]); + setDocs((prev) => (prev ?? []).filter((d) => d.id !== id)); + }, []); + + const removePDFDocument = useCallback(async (id: string) => removeById(id), [removeById]); + const removeEPUBDocument = useCallback(async (id: string) => removeById(id), [removeById]); + const removeHTMLDocument = useCallback(async (id: string) => removeById(id), [removeById]); + + const clearByType = useCallback(async (type: DocumentType) => { + const toDelete = (docs ?? []).filter((d) => d.type === type).map((d) => d.id); + if (toDelete.length) { + await deleteDocuments({ ids: toDelete }); + } + // Evict cache and update local list + await Promise.allSettled(toDelete.map((id) => Promise.all([evictCachedPdf(id), evictCachedEpub(id), evictCachedHtml(id)]))); + setDocs((prev) => (prev ?? []).filter((d) => d.type !== type)); + }, [docs]); + + const clearPDFs = useCallback(async () => clearByType('pdf'), [clearByType]); + const clearEPUBs = useCallback(async () => clearByType('epub'), [clearByType]); + const clearHTML = useCallback(async () => clearByType('html'), [clearByType]); return ( {children} diff --git a/src/contexts/EPUBContext.tsx b/src/contexts/EPUBContext.tsx index 37e5919..9a0d6c0 100644 --- a/src/contexts/EPUBContext.tsx +++ b/src/contexts/EPUBContext.tsx @@ -16,7 +16,9 @@ import type { NavItem } from 'epubjs'; import type { SpineItem } from 'epubjs/types/section'; import type { Book, Rendition } from 'epubjs'; -import { getEpubDocument, setLastDocumentLocation } from '@/lib/dexie'; +import { setLastDocumentLocation } from '@/lib/dexie'; +import { getDocumentMetadata } from '@/lib/client-documents'; +import { ensureCachedDocument } from '@/lib/document-cache'; import { useTTS } from '@/contexts/TTSContext'; import { createRangeCfi } from '@/lib/epub'; import { useParams } from 'next/navigation'; @@ -223,21 +225,25 @@ export function EPUBProvider({ children }: { children: ReactNode }) { */ const setCurrentDocument = useCallback(async (id: string): Promise => { try { - const doc = await getEpubDocument(id); - if (doc) { - console.log('Retrieved document size:', doc.size); - console.log('Retrieved ArrayBuffer size:', doc.data.byteLength); - - if (doc.data.byteLength === 0) { - console.error('Retrieved ArrayBuffer is empty'); - throw new Error('Empty document data'); - } - - setCurrDocName(doc.name); - setCurrDocData(doc.data); // Store ArrayBuffer directly - } else { - console.error('Document not found in IndexedDB'); + const meta = await getDocumentMetadata(id); + if (!meta) { + console.error('Document not found on server'); + return; } + + const doc = await ensureCachedDocument(meta); + if (doc.type !== 'epub') { + console.error('Document is not an EPUB'); + return; + } + + if (doc.data.byteLength === 0) { + console.error('Retrieved ArrayBuffer is empty'); + throw new Error('Empty document data'); + } + + setCurrDocName(doc.name); + setCurrDocData(doc.data); // Store ArrayBuffer directly } catch (error) { console.error('Failed to get EPUB document:', error); clearCurrDoc(); // Clean up on error diff --git a/src/contexts/HTMLContext.tsx b/src/contexts/HTMLContext.tsx index 8893859..401ed89 100644 --- a/src/contexts/HTMLContext.tsx +++ b/src/contexts/HTMLContext.tsx @@ -8,7 +8,8 @@ import { useCallback, useMemo, } from 'react'; -import { getHtmlDocument } from '@/lib/dexie'; +import { getDocumentMetadata } from '@/lib/client-documents'; +import { ensureCachedDocument } from '@/lib/document-cache'; import { useTTS } from '@/contexts/TTSContext'; interface HTMLContextType { @@ -53,15 +54,22 @@ export function HTMLProvider({ children }: { children: ReactNode }) { */ const setCurrentDocument = useCallback(async (id: string): Promise => { try { - const doc = await getHtmlDocument(id); - if (doc) { - setCurrDocName(doc.name); - setCurrDocData(doc.data); - setCurrDocText(doc.data); // Use the same text for TTS - setTTSText(doc.data); - } else { - console.error('Document not found in IndexedDB'); + const meta = await getDocumentMetadata(id); + if (!meta) { + console.error('Document not found on server'); + return; } + + const doc = await ensureCachedDocument(meta); + if (doc.type !== 'html') { + console.error('Document is not an HTML/TXT/MD document'); + return; + } + + setCurrDocName(doc.name); + setCurrDocData(doc.data); + setCurrDocText(doc.data); // Use the same text for TTS + setTTSText(doc.data); } catch (error) { console.error('Failed to get HTML document:', error); clearCurrDoc(); diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx index d3e0cb1..8221216 100644 --- a/src/contexts/PDFContext.tsx +++ b/src/contexts/PDFContext.tsx @@ -27,7 +27,8 @@ import { import type { PDFDocumentProxy } from 'pdfjs-dist'; -import { getPdfDocument } from '@/lib/dexie'; +import { getDocumentMetadata } from '@/lib/client-documents'; +import { ensureCachedDocument } from '@/lib/document-cache'; import { useTTS } from '@/contexts/TTSContext'; import { useConfig } from '@/contexts/ConfigContext'; import { normalizeTextForTts } from '@/lib/nlp'; @@ -334,13 +335,22 @@ export function PDFProvider({ children }: { children: ReactNode }) { setCurrDocName(undefined); setCurrDocData(undefined); - const doc = await getPdfDocument(id); - if (doc) { - setCurrDocName(doc.name); - // IMPORTANT: keep an immutable copy. pdf.js may transfer/detach the - // buffer passed into the worker; we always pass clones to react-pdf. - setCurrDocData(doc.data.slice(0)); + const meta = await getDocumentMetadata(id); + if (!meta) { + console.error('Document not found on server'); + return; } + + const doc = await ensureCachedDocument(meta); + if (doc.type !== 'pdf') { + console.error('Document is not a PDF'); + return; + } + + setCurrDocName(doc.name); + // IMPORTANT: keep an immutable copy. pdf.js may transfer/detach the + // buffer passed into the worker; we always pass clones to react-pdf. + setCurrDocData(doc.data.slice(0)); } catch (error) { console.error('Failed to get document:', error); } diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index fb7c19f..cde2375 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -603,10 +603,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement toast.success(isEPUB ? 'Skipping blank section' : `Skipping blank page ${currDocPageNumber}`, { id: isEPUB ? `epub-section-skip` : `page-${currDocPageNumber}`, - iconTheme: { - primary: 'var(--accent)', - secondary: 'var(--background)', - }, style: { background: 'var(--background)', color: 'var(--accent)', @@ -761,10 +757,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement console.warn('Error processing text:', error); setIsProcessing(false); toast.error('Failed to process text', { - style: { - background: 'var(--background)', - color: 'var(--accent)', - }, duration: 3000, }); }); @@ -1028,10 +1020,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement if (!preload) { toast.error('Daily TTS limit reached.', { id: 'tts-limit-error', - style: { - background: 'var(--background)', - color: 'var(--accent)', - }, duration: 5000, }); } @@ -1045,10 +1033,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement if (!preload) { toast.error('TTS failed. Skipped sentence and paused.', { id: 'tts-api-error', - style: { - background: 'var(--background)', - color: 'var(--accent)', - }, duration: 7000, }); } @@ -1224,10 +1208,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement toast.error('Playback was blocked by your browser. Tap play again to start.', { id: 'tts-playback-blocked', - style: { - background: 'var(--background)', - color: 'var(--accent)', - }, duration: 4000, }); return; @@ -1245,10 +1225,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement toast.error('Audio playback failed. Skipped sentence and paused.', { id: 'tts-playback-error', - style: { - background: 'var(--background)', - color: 'var(--accent)', - }, duration: 4000, }); @@ -1302,10 +1278,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement toast.error('Audio loading failed after retries. Moving to next sentence...', { id: 'audio-load-error', - style: { - background: 'var(--background)', - color: 'var(--accent)', - }, duration: 2000, }); @@ -1320,10 +1292,6 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement toast.error('Audio loading failed after retries. Moving to next sentence...', { id: 'audio-load-error', - style: { - background: 'var(--background)', - color: 'var(--accent)', - }, duration: 2000, }); diff --git a/src/db/schema_postgres.ts b/src/db/schema_postgres.ts index d5fd7ab..ad979f5 100644 --- a/src/db/schema_postgres.ts +++ b/src/db/schema_postgres.ts @@ -1,7 +1,7 @@ import { pgTable, text, integer, real, boolean, timestamp, date, bigint, primaryKey, index } from 'drizzle-orm/pg-core'; export const documents = pgTable('documents', { - id: text('id'), + id: text('id').notNull(), userId: text('user_id').notNull(), name: text('name').notNull(), type: text('type').notNull(), // pdf, epub, docx, html @@ -11,6 +11,8 @@ export const documents = pgTable('documents', { createdAt: timestamp('created_at').defaultNow(), }, (table) => ({ pk: primaryKey({ columns: [table.id, table.userId] }), + userIdIdx: index('idx_documents_user_id').on(table.userId), + userIdLastModifiedIdx: index('idx_documents_user_id_last_modified').on(table.userId, table.lastModified), })); export const audiobooks = pgTable('audiobooks', { diff --git a/src/db/schema_sqlite.ts b/src/db/schema_sqlite.ts index e369796..06a2147 100644 --- a/src/db/schema_sqlite.ts +++ b/src/db/schema_sqlite.ts @@ -2,7 +2,7 @@ import { sqliteTable, text, integer, real, primaryKey, index } from 'drizzle-orm import { sql } from 'drizzle-orm'; export const documents = sqliteTable('documents', { - id: text('id'), + id: text('id').notNull(), userId: text('user_id').notNull(), name: text('name').notNull(), type: text('type').notNull(), // pdf, epub, docx, html @@ -12,6 +12,8 @@ export const documents = sqliteTable('documents', { createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`), }, (table) => ({ pk: primaryKey({ columns: [table.id, table.userId] }), + userIdIdx: index('idx_documents_user_id').on(table.userId), + userIdLastModifiedIdx: index('idx_documents_user_id_last_modified').on(table.userId, table.lastModified), })); export const audiobooks = sqliteTable('audiobooks', { diff --git a/src/lib/client-documents.ts b/src/lib/client-documents.ts new file mode 100644 index 0000000..df58c33 --- /dev/null +++ b/src/lib/client-documents.ts @@ -0,0 +1,126 @@ +import type { BaseDocument } from '@/types/documents'; + +export function mimeTypeForDoc(doc: Pick): string { + if (doc.type === 'pdf') return 'application/pdf'; + if (doc.type === 'epub') return 'application/epub+zip'; + + const lower = doc.name.toLowerCase(); + if (lower.endsWith('.md') || lower.endsWith('.markdown') || lower.endsWith('.mdown') || lower.endsWith('.mkd')) { + return 'text/markdown'; + } + return 'text/plain'; +} + +export async function listDocuments(options?: { ids?: string[]; signal?: AbortSignal }): Promise { + const params = new URLSearchParams(); + params.set('list', 'true'); + if (options?.ids?.length) { + params.set('ids', options.ids.join(',')); + } + + const res = await fetch(`/api/documents?${params.toString()}`, { signal: options?.signal }); + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || 'Failed to list documents'); + } + + const data = (await res.json()) as { documents: BaseDocument[] }; + return data.documents || []; +} + +export async function getDocumentMetadata(id: string, options?: { signal?: AbortSignal }): Promise { + const docs = await listDocuments({ ids: [id], signal: options?.signal }); + return docs[0] ?? null; +} + +export async function uploadDocuments(files: File[], options?: { signal?: AbortSignal }): Promise { + const form = new FormData(); + for (const file of files) { + form.append('files', file); + } + + const res = await fetch('/api/documents/upload', { + method: 'POST', + body: form, + signal: options?.signal, + }); + + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || 'Failed to upload documents'); + } + + const data = (await res.json()) as { stored: BaseDocument[] }; + return data.stored || []; +} + +export async function deleteDocuments(options?: { ids?: string[]; scope?: 'user' | 'unclaimed'; signal?: AbortSignal }): Promise { + const params = new URLSearchParams(); + if (options?.ids?.length) { + params.set('ids', options.ids.join(',')); + } + if (options?.scope) { + params.set('scope', options.scope); + } + + const url = params.toString() ? `/api/documents?${params.toString()}` : '/api/documents'; + const res = await fetch(url, { method: 'DELETE', signal: options?.signal }); + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || 'Failed to delete documents'); + } +} + +export async function downloadDocumentContent(id: string, options?: { signal?: AbortSignal }): Promise { + const res = await fetch(`/api/documents/content?id=${encodeURIComponent(id)}`, { signal: options?.signal }); + if (!res.ok) { + const contentType = res.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || `Failed to download document (status ${res.status})`); + } + throw new Error(`Failed to download document (status ${res.status})`); + } + + return res.arrayBuffer(); +} + +export async function getDocumentContentSnippet( + id: string, + options?: { maxChars?: number; maxBytes?: number; signal?: AbortSignal }, +): Promise { + const params = new URLSearchParams(); + params.set('id', id); + params.set('format', 'snippet'); + if (typeof options?.maxChars === 'number') params.set('maxChars', String(options.maxChars)); + if (typeof options?.maxBytes === 'number') params.set('maxBytes', String(options.maxBytes)); + + const res = await fetch(`/api/documents/content?${params.toString()}`, { signal: options?.signal }); + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || `Failed to load content snippet (status ${res.status})`); + } + + const data = (await res.json()) as { snippet?: string }; + return data?.snippet || ''; +} + +export async function uploadDocxAsPdf(file: File, options?: { signal?: AbortSignal }): Promise { + const form = new FormData(); + form.append('file', file); + + const res = await fetch('/api/documents/docx-to-pdf/upload', { + method: 'POST', + body: form, + signal: options?.signal, + }); + + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || 'Failed to convert DOCX'); + } + + const data = (await res.json()) as { stored: BaseDocument }; + if (!data?.stored) throw new Error('DOCX conversion succeeded but returned no document'); + return data.stored; +} diff --git a/src/lib/client.ts b/src/lib/client.ts index 032cdc1..2065b28 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -79,33 +79,6 @@ export const withRetry = async ( throw lastError || new Error('Operation failed after retries'); }; -// --- Documents API --- - -export const convertDocxToPdf = async (file: File): Promise => { - const formData = new FormData(); - formData.append('file', file); - - const response = await fetch('/api/documents/docx-to-pdf', { - method: 'POST', - body: formData, - }); - - if (!response.ok) { - throw new Error('Failed to convert DOCX to PDF'); - } - - return await response.blob(); -}; - -export const deleteServerDocuments = async (): Promise => { - const response = await fetch('/api/documents', { - method: 'DELETE', - }); - if (!response.ok) { - throw new Error('Failed to delete server documents'); - } -}; - // --- Audiobook API --- diff --git a/src/lib/document-cache.ts b/src/lib/document-cache.ts new file mode 100644 index 0000000..00e6eea --- /dev/null +++ b/src/lib/document-cache.ts @@ -0,0 +1,148 @@ +import type { BaseDocument, EPUBDocument, HTMLDocument, PDFDocument } from '@/types/documents'; +import { downloadDocumentContent } from '@/lib/client-documents'; + +export type DocumentCacheBackend = { + get: (meta: BaseDocument) => Promise; + putPdf: (meta: BaseDocument, data: ArrayBuffer) => Promise; + putEpub: (meta: BaseDocument, data: ArrayBuffer) => Promise; + putHtml: (meta: BaseDocument, data: string) => Promise; + download: (id: string, options?: { signal?: AbortSignal }) => Promise; + decodeText: (buffer: ArrayBuffer) => string; +}; + +export async function ensureCachedDocumentCore( + meta: BaseDocument, + backend: DocumentCacheBackend, + options?: { signal?: AbortSignal }, +): Promise { + const cached = await backend.get(meta); + if (cached) return cached; + + const buffer = await backend.download(meta.id, { signal: options?.signal }); + + if (meta.type === 'pdf') { + await backend.putPdf(meta, buffer); + const after = await backend.get(meta); + if (!after || after.type !== 'pdf') throw new Error('Failed to cache PDF'); + return after; + } + + if (meta.type === 'epub') { + await backend.putEpub(meta, buffer); + const after = await backend.get(meta); + if (!after || after.type !== 'epub') throw new Error('Failed to cache EPUB'); + return after; + } + + const decoded = backend.decodeText(buffer); + await backend.putHtml(meta, decoded); + const after = await backend.get(meta); + if (!after || after.type !== 'html') throw new Error('Failed to cache HTML'); + return after; +} + +export async function getCachedPdf(id: string): Promise { + const { getPdfDocument } = await import('@/lib/dexie'); + return (await getPdfDocument(id)) ?? null; +} + +export async function putCachedPdf(meta: BaseDocument, data: ArrayBuffer): Promise { + const { addPdfDocument } = await import('@/lib/dexie'); + await addPdfDocument({ + id: meta.id, + type: 'pdf', + name: meta.name, + size: meta.size, + lastModified: meta.lastModified, + data, + }); +} + +export async function evictCachedPdf(id: string): Promise { + const { removePdfDocument } = await import('@/lib/dexie'); + await removePdfDocument(id); +} + +export async function getCachedEpub(id: string): Promise { + const { getEpubDocument } = await import('@/lib/dexie'); + return (await getEpubDocument(id)) ?? null; +} + +export async function putCachedEpub(meta: BaseDocument, data: ArrayBuffer): Promise { + const { addEpubDocument } = await import('@/lib/dexie'); + await addEpubDocument({ + id: meta.id, + type: 'epub', + name: meta.name, + size: meta.size, + lastModified: meta.lastModified, + data, + }); +} + +export async function evictCachedEpub(id: string): Promise { + const { removeEpubDocument } = await import('@/lib/dexie'); + await removeEpubDocument(id); +} + +export async function getCachedHtml(id: string): Promise { + const { getHtmlDocument } = await import('@/lib/dexie'); + return (await getHtmlDocument(id)) ?? null; +} + +export async function putCachedHtml(meta: BaseDocument, data: string): Promise { + const { addHtmlDocument } = await import('@/lib/dexie'); + await addHtmlDocument({ + id: meta.id, + type: 'html', + name: meta.name, + size: meta.size, + lastModified: meta.lastModified, + data, + }); +} + +export async function evictCachedHtml(id: string): Promise { + const { removeHtmlDocument } = await import('@/lib/dexie'); + await removeHtmlDocument(id); +} + +export async function clearDocumentCache(): Promise { + const { clearPdfDocuments, clearEpubDocuments, clearHtmlDocuments } = await import('@/lib/dexie'); + await Promise.all([clearPdfDocuments(), clearEpubDocuments(), clearHtmlDocuments()]); +} + +export async function cacheStoredDocumentFromBytes(stored: BaseDocument, bytes: ArrayBuffer): Promise { + if (stored.type === 'pdf') { + await putCachedPdf(stored, bytes); + return; + } + if (stored.type === 'epub') { + await putCachedEpub(stored, bytes); + return; + } + if (stored.type === 'html') { + const decoded = new TextDecoder().decode(new Uint8Array(bytes)); + await putCachedHtml(stored, decoded); + } +} + +export async function ensureCachedDocument(meta: BaseDocument, options?: { signal?: AbortSignal }): Promise { + return ensureCachedDocumentCore( + meta, + { + get: async (m) => { + const { getPdfDocument, getEpubDocument, getHtmlDocument } = await import('@/lib/dexie'); + if (m.type === 'pdf') return (await getPdfDocument(m.id)) ?? null; + if (m.type === 'epub') return (await getEpubDocument(m.id)) ?? null; + return (await getHtmlDocument(m.id)) ?? null; + }, + putPdf: putCachedPdf, + putEpub: putCachedEpub, + putHtml: putCachedHtml, + download: downloadDocumentContent, + decodeText: (buffer) => new TextDecoder().decode(new Uint8Array(buffer)), + }, + options, + ); +} diff --git a/src/lib/documentPreview.ts b/src/lib/documentPreview.ts index cc64f4e..d515012 100644 --- a/src/lib/documentPreview.ts +++ b/src/lib/documentPreview.ts @@ -1,5 +1,6 @@ import { pdfjs } from 'react-pdf'; import ePub from 'epubjs'; +export { extractRawTextSnippet, extractTextSnippet } from '@/lib/text-snippets'; function shouldUseLegacyPdfWorker(): boolean { if (typeof window === 'undefined') return false; @@ -146,39 +147,4 @@ export async function extractEpubCoverToDataUrl( } } -export function extractTextSnippet(source: string, maxChars = 220): string { - const strippedHtml = source - .replace(/[\s\S]*?<\/script>/gi, ' ') - .replace(/[\s\S]*?<\/style>/gi, ' ') - .replace(/<[^>]+>/g, ' '); - - const normalizedMarkdown = strippedHtml - .replace(/```[\s\S]*?```/g, ' ') - .replace(/`[^`]+`/g, ' ') - .replace(/!\[[^\]]*?\]\([^)]+\)/g, ' ') - .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') - .replace(/[#>*_~]/g, ' '); - - const normalized = normalizedMarkdown - .replace(/\r\n/g, '\n') - .replace(/\n{3,}/g, '\n\n') - .replace(/[ \t]{2,}/g, ' ') - .trim(); - - const paragraphs = normalized.split(/\n\s*\n/).map(p => p.trim()).filter(Boolean); - const first = paragraphs[0] ?? normalized; - - if (first.length <= maxChars) return first; - return `${first.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`; -} - -export function extractRawTextSnippet(source: string, maxChars = 1600): string { - const strippedHtml = source - .replace(/[\s\S]*?<\/script>/gi, '') - .replace(/[\s\S]*?<\/style>/gi, ''); - - const normalized = strippedHtml.replace(/\r\n/g, '\n').trim(); - - if (normalized.length <= maxChars) return normalized; - return `${normalized.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`; -} +// Note: text snippet helpers live in `src/lib/text-snippets.ts` to stay server-safe. diff --git a/src/lib/server/audiobook-prune.ts b/src/lib/server/audiobook-prune.ts new file mode 100644 index 0000000..35d6669 --- /dev/null +++ b/src/lib/server/audiobook-prune.ts @@ -0,0 +1,45 @@ +import { and, eq, notInArray } from 'drizzle-orm'; + +import { db } from '@/db'; +import { audiobooks, audiobookChapters } from '@/db/schema'; + +export async function pruneAudiobookIfMissingDir(bookId: string, userId: string, intermediateDirExists: boolean): Promise { + if (intermediateDirExists) return; + await db.delete(audiobookChapters).where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, userId))); + await db.delete(audiobooks).where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, userId))); +} + +export async function pruneAudiobookChaptersNotOnDisk( + bookId: string, + userId: string, + presentChapterIndexes: number[], +): Promise { + if (presentChapterIndexes.length === 0) { + await db + .delete(audiobookChapters) + .where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, userId))); + return; + } + await db + .delete(audiobookChapters) + .where( + and( + eq(audiobookChapters.bookId, bookId), + eq(audiobookChapters.userId, userId), + notInArray(audiobookChapters.chapterIndex, presentChapterIndexes), + ), + ); +} + +export async function pruneAudiobookChapterIfMissingFile(bookId: string, userId: string, chapterIndex: number, chapterFileExists: boolean): Promise { + if (chapterFileExists) return; + await db + .delete(audiobookChapters) + .where( + and( + eq(audiobookChapters.bookId, bookId), + eq(audiobookChapters.userId, userId), + eq(audiobookChapters.chapterIndex, chapterIndex), + ), + ); +} diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 8753b47..1339dc4 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -7,7 +7,7 @@ import type { NextRequest } from 'next/server'; import { db } from "@/db"; import { rateLimiter } from "@/lib/server/rate-limiter"; import { isAuthEnabled } from "@/lib/server/auth-config"; -import { transferUserAudiobooks } from "@/lib/server/claim-data"; +import { transferUserAudiobooks, transferUserDocuments } from "@/lib/server/claim-data"; import * as schema from "@/db/schema"; // Import the dynamic schema @@ -86,6 +86,17 @@ const createAuth = () => betterAuth({ console.error("Error transferring audiobooks during account linking:", error); // Don't throw here to prevent blocking the account linking process } + + // Transfer documents from anonymous user to new authenticated user + try { + const transferred = await transferUserDocuments(anonymousUser.user.id, newUser.user.id); + if (transferred > 0) { + console.log(`Successfully transferred ${transferred} document(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`); + } + } catch (error) { + console.error("Error transferring documents during account linking:", error); + // Don't throw here to prevent blocking the account linking process + } } catch (error) { console.error("Error in onLinkAccount callback:", error); // Don't throw here to prevent blocking the account linking process @@ -142,4 +153,4 @@ export async function requireAuthContext( } return ctx; -} \ No newline at end of file +} diff --git a/src/lib/server/claim-data.ts b/src/lib/server/claim-data.ts index 6a8782d..f9b3dbd 100644 --- a/src/lib/server/claim-data.ts +++ b/src/lib/server/claim-data.ts @@ -83,6 +83,38 @@ export async function claimAnonymousData(userId: string) { }; } +/** + * Transfer documents from one userId to another. + * + * This is used when an anonymous user upgrades to an authenticated account. + * The underlying blob storage is shared (by sha), so this only moves metadata rows. + * + * @returns number of document rows transferred + */ +export async function transferUserDocuments( + fromUserId: string, + toUserId: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + options?: { db?: any }, +): Promise { + if (!isAuthEnabled() || !fromUserId || !toUserId) return 0; + if (fromUserId === toUserId) return 0; + + const database = options?.db ?? db; + + const rows = await database.select().from(documents).where(eq(documents.userId, fromUserId)); + if (rows.length === 0) return 0; + + await database + .insert(documents) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .values(rows.map((row: any) => ({ ...row, userId: toUserId }))) + .onConflictDoNothing(); + + await database.delete(documents).where(eq(documents.userId, fromUserId)); + return rows.length; +} + /** * Transfer audiobooks from one user to another. * Used when an anonymous user creates a real account. diff --git a/src/lib/server/db-indexing.ts b/src/lib/server/db-indexing.ts index 9f1c479..ab8c1c3 100644 --- a/src/lib/server/db-indexing.ts +++ b/src/lib/server/db-indexing.ts @@ -38,24 +38,14 @@ async function writeState(): Promise { await fs.writeFile(STATE_PATH, JSON.stringify(state, null, 2)); } -async function hasUnclaimedDbRows(): Promise { - const [docCount] = await db - .select({ count: count() }) - .from(documents) - .where(eq(documents.userId, UNCLAIMED_USER_ID)); - const [bookCount] = await db - .select({ count: count() }) - .from(audiobooks) - .where(eq(audiobooks.userId, UNCLAIMED_USER_ID)); +async function hasFilesystemContent(mode: DbIndexState['mode']): Promise<{ documents: boolean; audiobooks: boolean }> { + let documentsPresent = false; + let audiobooksPresent = false; - return Number(docCount?.count ?? 0) > 0 || Number(bookCount?.count ?? 0) > 0; -} - -async function hasFilesystemContent(mode: DbIndexState['mode']): Promise { // Documents are always stored under documents_v1. try { const entries = await fs.readdir(DOCUMENTS_V1_DIR); - if (entries.some((name) => /^[a-f0-9]{64}__.+$/i.test(name))) return true; + documentsPresent = entries.some((name) => /^[a-f0-9]{64}__.+$/i.test(name)); } catch { // ignore } @@ -64,12 +54,12 @@ async function hasFilesystemContent(mode: DbIndexState['mode']): Promise e.isDirectory() && e.name.endsWith('-audiobook'))) return true; + audiobooksPresent = entries.some((e) => e.isDirectory() && e.name.endsWith('-audiobook')); } catch { // ignore } - return false; + return { documents: documentsPresent, audiobooks: audiobooksPresent }; } async function isDocumentIndexed(id: string): Promise { @@ -234,9 +224,17 @@ export async function ensureDbIndexed(): Promise { const hasState = existsSync(STATE_PATH) ? await readState() : null; if (hasState && hasState.mode === mode) { // If the DB was reset but the state file survived, don't get stuck "indexed" forever. - // Only skip if the DB has rows OR there is no content on disk to index. - const [dbHasRows, fsHasRows] = await Promise.all([hasUnclaimedDbRows(), hasFilesystemContent(mode)]); - if (dbHasRows || !fsHasRows) { + // Only skip if: + // - the DB already has rows for any on-disk content (per category), OR + // - there is no content on disk to index (per category). + // + // This avoids a bad state where (for example) audiobooks are indexed, but documents exist on disk + // and aren't counted/claimable because we early-exit based on "some DB rows exist". + const [counts, fsHas] = await Promise.all([getUnclaimedCounts(), hasFilesystemContent(mode)]); + const docsOk = counts.documents > 0 || !fsHas.documents; + const audiobooksOk = counts.audiobooks > 0 || !fsHas.audiobooks; + + if (docsOk && audiobooksOk) { memoryIndexedMode = mode; return; } diff --git a/src/lib/server/documents-utils.ts b/src/lib/server/documents-utils.ts new file mode 100644 index 0000000..1d4bf1f --- /dev/null +++ b/src/lib/server/documents-utils.ts @@ -0,0 +1,33 @@ +import path from 'path'; +import { utimes } from 'fs/promises'; +import type { DocumentType } from '@/types/documents'; + +export function isEnoent(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && (error as { code?: unknown }).code === 'ENOENT'; +} + +export function safeDocumentName(rawName: string, fallback: string): string { + const baseName = path.basename(rawName || fallback); + return baseName.replaceAll('\u0000', '').slice(0, 240) || fallback; +} + +export function toDocumentTypeFromName(name: string): DocumentType { + const ext = path.extname(name).toLowerCase(); + if (ext === '.pdf') return 'pdf'; + if (ext === '.epub') return 'epub'; + if (ext === '.docx') return 'docx'; + return 'html'; +} + +export async function trySetFileMtime(filePath: string, lastModifiedMs: number): Promise { + if (!Number.isFinite(lastModifiedMs)) return; + const mtime = new Date(lastModifiedMs); + if (Number.isNaN(mtime.getTime())) return; + + try { + await utimes(filePath, mtime, mtime); + } catch (error) { + console.warn('Failed to set document mtime:', filePath, error); + } +} + diff --git a/src/lib/text-snippets.ts b/src/lib/text-snippets.ts new file mode 100644 index 0000000..41a8d0e --- /dev/null +++ b/src/lib/text-snippets.ts @@ -0,0 +1,40 @@ +export function extractTextSnippet(source: string, maxChars = 220): string { + const strippedHtml = source + .replace(/[\s\S]*?<\/script>/gi, ' ') + .replace(/[\s\S]*?<\/style>/gi, ' ') + .replace(/<[^>]+>/g, ' '); + + const normalizedMarkdown = strippedHtml + .replace(/```[\s\S]*?```/g, ' ') + .replace(/`[^`]+`/g, ' ') + .replace(/!\[[^\]]*?\]\([^)]+\)/g, ' ') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/[#>*_~]/g, ' '); + + const normalized = normalizedMarkdown + .replace(/\r\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .replace(/[ \t]{2,}/g, ' ') + .trim(); + + const paragraphs = normalized + .split(/\n\s*\n/) + .map((p) => p.trim()) + .filter(Boolean); + const first = paragraphs[0] ?? normalized; + + if (first.length <= maxChars) return first; + return `${first.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`; +} + +export function extractRawTextSnippet(source: string, maxChars = 1600): string { + const strippedHtml = source + .replace(/[\s\S]*?<\/script>/gi, '') + .replace(/[\s\S]*?<\/style>/gi, ''); + + const normalized = strippedHtml.replace(/\r\n/g, '\n').trim(); + + if (normalized.length <= maxChars) return normalized; + return `${normalized.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`; +} + diff --git a/src/types/config.ts b/src/types/config.ts index 2491302..8e07e06 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -31,6 +31,7 @@ export interface AppConfigValues { firstVisit: boolean; documentListState: DocumentListState; privacyAccepted: boolean; + documentsMigrationPrompted: boolean; } export const APP_CONFIG_DEFAULTS: AppConfigValues = { @@ -65,6 +66,7 @@ export const APP_CONFIG_DEFAULTS: AppConfigValues = { viewMode: 'grid', }, privacyAccepted: false, + documentsMigrationPrompted: false, }; export interface AppConfigRow extends AppConfigValues { diff --git a/src/types/documents.ts b/src/types/documents.ts index e1307f3..cae8b99 100644 --- a/src/types/documents.ts +++ b/src/types/documents.ts @@ -6,6 +6,7 @@ export interface BaseDocument { size: number; lastModified: number; type: DocumentType; + scope?: 'user' | 'unclaimed'; folderId?: string; isConverting?: boolean; } diff --git a/tests/accessibility.spec.ts b/tests/accessibility.spec.ts index dde4424..3370483 100644 --- a/tests/accessibility.spec.ts +++ b/tests/accessibility.spec.ts @@ -8,8 +8,8 @@ import { } from './helpers'; test.describe('Accessibility smoke', () => { - test.beforeEach(async ({ page }) => { - await setupTest(page); + test.beforeEach(async ({ page }, testInfo) => { + await setupTest(page, testInfo); }); test('dropzone input and hint text are accessible', async ({ page }) => { @@ -85,4 +85,4 @@ test.describe('Accessibility smoke', () => { await page.getByRole('button', { name: 'Pause' }).click(); await expect(page.getByRole('button', { name: 'Play' })).toBeVisible({ timeout: 10000 }); }); -}); \ No newline at end of file +}); diff --git a/tests/delete.spec.ts b/tests/delete.spec.ts index bc5ee30..8de7db8 100644 --- a/tests/delete.spec.ts +++ b/tests/delete.spec.ts @@ -2,8 +2,8 @@ import { test, expect } from '@playwright/test'; import { setupTest, uploadFile, expectDocumentListed, expectNoDocumentLink, deleteDocumentByName, deleteAllLocalDocuments, ensureDocumentsListed } from './helpers'; test.describe('Document deletion flow', () => { - test.beforeEach(async ({ page }) => { - await setupTest(page); + test.beforeEach(async ({ page }, testInfo) => { + await setupTest(page, testInfo); }); test('deletes a document and updates list', async ({ page }) => { @@ -45,4 +45,4 @@ test.describe('Document deletion flow', () => { // Uploader should be visible when no docs remain await expect(page.locator('input[type=file]')).toBeVisible({ timeout: 10000 }); }); -}); \ No newline at end of file +}); diff --git a/tests/export.spec.ts b/tests/export.spec.ts index c767c40..39cbd64 100644 --- a/tests/export.spec.ts +++ b/tests/export.spec.ts @@ -179,9 +179,9 @@ async function resetAudiobookIfPresent(page: Page) { await expect(page.getByRole('button', { name: 'Start Generation' })).toBeVisible({ timeout: 60_000 }); } -test('exports full MP3 audiobook for PDF using mocked 10s TTS sample', async ({ page }) => { +test('exports full MP3 audiobook for PDF using mocked 10s TTS sample', async ({ page }, testInfo) => { // Ensure TTS is mocked and app is ready - await setupTest(page); + await setupTest(page, testInfo); // Upload and open the sample PDF in the viewer await uploadAndDisplay(page, 'sample.pdf'); @@ -231,8 +231,8 @@ test('exports full MP3 audiobook for PDF using mocked 10s TTS sample', async ({ await resetAudiobookIfPresent(page); }); -test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async ({ page }) => { - await setupTest(page); +test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async ({ page }, testInfo) => { + await setupTest(page, testInfo); // Upload and open the sample EPUB in the viewer await uploadAndDisplay(page, 'sample.epub'); @@ -302,8 +302,8 @@ test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async await resetAudiobookIfPresent(page); }); -test('exports a single MP3 audiobook PDF page via chapters menu', async ({ page }) => { - await setupTest(page); +test('exports a single MP3 audiobook PDF page via chapters menu', async ({ page }, testInfo) => { + await setupTest(page, testInfo); await uploadAndDisplay(page, 'sample.pdf'); const bookId = await getBookIdFromUrl(page, 'pdf'); @@ -330,8 +330,8 @@ test('exports a single MP3 audiobook PDF page via chapters menu', async ({ page await resetAudiobookIfPresent(page); }); -test('resets all MP3 audiobook PDF pages', async ({ page }) => { - await setupTest(page); +test('resets all MP3 audiobook PDF pages', async ({ page }, testInfo) => { + await setupTest(page, testInfo); await uploadAndDisplay(page, 'sample.pdf'); const bookId = await getBookIdFromUrl(page, 'pdf'); @@ -366,8 +366,8 @@ test('resets all MP3 audiobook PDF pages', async ({ page }) => { expect(json.chapters.length).toBe(0); }); -test('regenerates a single MP3 audiobook PDF page and exports full audiobook', async ({ page }) => { - await setupTest(page); +test('regenerates a single MP3 audiobook PDF page and exports full audiobook', async ({ page }, testInfo) => { + await setupTest(page, testInfo); await uploadAndDisplay(page, 'sample.pdf'); // Extract bookId from /pdf/[id] URL (for backend verification later) @@ -439,8 +439,8 @@ test('regenerates a single MP3 audiobook PDF page and exports full audiobook', a await resetAudiobookIfPresent(page); }); -test('resumes audiobook when a chapter is missing and full download succeeds (PDF)', async ({ page }) => { - await setupTest(page); +test('resumes audiobook when a chapter is missing and full download succeeds (PDF)', async ({ page }, testInfo) => { + await setupTest(page, testInfo); await uploadAndDisplay(page, 'sample.pdf'); const bookId = await getBookIdFromUrl(page, 'pdf'); diff --git a/tests/folders.spec.ts b/tests/folders.spec.ts index 70c238d..b1c413d 100644 --- a/tests/folders.spec.ts +++ b/tests/folders.spec.ts @@ -1,9 +1,9 @@ import { test, expect } from '@playwright/test'; -import { setupTest, uploadFiles, ensureDocumentsListed, waitForDocumentListHintPersist } from './helpers'; +import { setupTest, uploadFiles, ensureDocumentsListed, waitForDocumentListHintPersist, dispatchHtml5DragAndDrop } from './helpers'; test.describe('Document folders and hint persistence', () => { - test.beforeEach(async ({ page }) => { - await setupTest(page); + test.beforeEach(async ({ page }, testInfo) => { + await setupTest(page, testInfo); }); // Utility to get the draggable row for a given filename (by link) @@ -21,13 +21,14 @@ test.describe('Document folders and hint persistence', () => { // Drag PDF onto EPUB to create a folder const pdfRow = rowFor(page, 'sample.pdf'); const epubRow = rowFor(page, 'sample.epub'); - await pdfRow.dragTo(epubRow); + await dispatchHtml5DragAndDrop(page, pdfRow, epubRow); // Folder name dialog appears await expect(page.getByRole('heading', { name: 'Create New Folder' })).toBeVisible(); const nameInput = page.getByPlaceholder('Enter folder name'); await nameInput.fill('My Folder'); await nameInput.press('Enter'); + await expect(page.getByRole('dialog', { name: 'Create New Folder' })).toHaveCount(0); // Folder shows with both docs const folderHeading = page.getByRole('heading', { name: 'My Folder' }); @@ -40,7 +41,7 @@ test.describe('Document folders and hint persistence', () => { // Drag third doc (TXT) into folder const txtRow = rowFor(page, 'sample.txt'); - await txtRow.dragTo(folderContainer); + await dispatchHtml5DragAndDrop(page, txtRow, folderContainer); await expect(folderContainer.getByRole('link', { name: /sample\.txt/i })).toBeVisible(); // Collapse folder and verify items are hidden @@ -96,4 +97,4 @@ test.describe('Document folders and hint persistence', () => { await page.waitForLoadState('networkidle'); await expect(page.getByText('Drag files on top of each other to make folders')).toHaveCount(0); }); -}); \ No newline at end of file +}); diff --git a/tests/global-teardown.ts b/tests/global-teardown.ts new file mode 100644 index 0000000..f8040c4 --- /dev/null +++ b/tests/global-teardown.ts @@ -0,0 +1,29 @@ +import fs from 'fs/promises'; +import path from 'path'; + +async function listWorkerNamespaces(documentsRoot: string): Promise { + let entries: Array<{ name: string; isDirectory: () => boolean }> = []; + try { + entries = await fs.readdir(documentsRoot, { withFileTypes: true }); + } catch { + return []; + } + + return entries + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .filter((name) => /^(chromium|firefox|webkit)-worker\d+$/.test(name)); +} + +export default async function globalTeardown(): Promise { + const documentsRoot = path.join(process.cwd(), 'docstore', 'documents_v1'); + const namespaces = await listWorkerNamespaces(documentsRoot); + if (!namespaces.length) return; + + await Promise.all( + namespaces.map(async (ns) => { + const dir = path.join(documentsRoot, ns); + await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); + }), + ); +} diff --git a/tests/helpers.ts b/tests/helpers.ts index de3cfb3..9513fd1 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -1,4 +1,4 @@ -import { Page, expect } from '@playwright/test'; +import { Page, expect, type TestInfo, type Locator } from '@playwright/test'; import fs from 'fs'; import path from 'path'; @@ -60,7 +60,12 @@ export async function uploadAndDisplay(page: Page, fileName: string) { const lower = fileName.toLowerCase(); if (lower.endsWith('.docx')) { - await expect(page.getByText('Converting DOCX to PDF...')).toBeVisible(); + // Best-effort: conversion can complete before we observe this UI state. + try { + await expect(page.getByText('Converting DOCX to PDF...')).toBeVisible({ timeout: 5000 }); + } catch { + // ignore + } const pdfName = fileName.replace(/\.docx$/i, '.pdf'); await page.getByRole('link', { name: new RegExp(escapeRegExp(pdfName), 'i') }).click(); await page.waitForSelector('.react-pdf__Document', { timeout: 15000 }); @@ -115,7 +120,14 @@ export async function pauseTTSAndVerify(page: Page) { /** * Common test setup function */ -export async function setupTest(page: Page) { +export async function setupTest(page: Page, testInfo?: TestInfo) { + if (testInfo) { + // Isolate server-side storage per Playwright worker to avoid cross-test flake + // when running with multiple workers (server-first document storage). + const namespace = `${testInfo.project.name}-worker${testInfo.workerIndex}`; + await page.context().setExtraHTTPHeaders({ 'x-openreader-test-namespace': namespace }); + } + // Mock the TTS API so tests don't hit the real TTS service. await ensureTtsRouteMock(page); @@ -140,12 +152,19 @@ export async function setupTest(page: Page) { try { await expect(privacyBtn).toBeVisible({ timeout: 5000 }); await privacyBtn.click(); + // HeadlessUI keeps dialogs in the DOM during leave transitions; "hidden" is enough + // (we mainly need to ensure it no longer blocks pointer events). + await page.getByRole('dialog', { name: /privacy/i }).waitFor({ state: 'hidden', timeout: 15000 }); + await expect(page.locator('.overlay-dim:visible')).toHaveCount(0); } catch { // ignore } // Settings modal should appear after privacy acceptance on first visit. - await expect(page.getByRole('button', { name: 'Save' })).toBeVisible({ timeout: 10000 }); + const saveBtn = page.getByRole('button', { name: 'Save' }); + await expect(saveBtn).toBeVisible({ timeout: 10000 }); + // SettingsModal can briefly disable Save while it mirrors a custom model into the input field. + await expect(saveBtn).toBeEnabled({ timeout: 15000 }); // If running in CI, select the "Custom OpenAI-Like" model and "Deepinfra" provider if (process.env.CI) { @@ -154,7 +173,43 @@ export async function setupTest(page: Page) { } // Click the "done" button to dismiss the welcome message - await page.getByRole('button', { name: 'Save' }).click(); + await saveBtn.click(); + await page.getByRole('dialog', { name: 'Settings' }).waitFor({ state: 'hidden', timeout: 15000 }); + await expect(page.locator('.overlay-dim:visible')).toHaveCount(0); +} + +/** + * More reliable than Playwright's `locator.dragTo` when a drop immediately opens a modal + * (which can intercept pointer events mid-gesture and cause flakiness). + * + * This uses DOM drag events directly; our app's doc list DnD logic only needs the events, + * not a real OS-level drag interaction. + */ +export async function dispatchHtml5DragAndDrop(page: Page, source: Locator, target: Locator): Promise { + const sourceHandle = await source.elementHandle(); + const targetHandle = await target.elementHandle(); + if (!sourceHandle) throw new Error('drag source element not found'); + if (!targetHandle) throw new Error('drag target element not found'); + + await page.evaluate( + async ([src, dst]) => { + const dt = typeof DataTransfer !== 'undefined' ? new DataTransfer() : ({} as DataTransfer); + const fire = (el: Element, type: string) => { + const event = new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: dt }); + el.dispatchEvent(event); + }; + + fire(src, 'dragstart'); + // Let React flush state updates (draggedDoc) before dispatching drop events. + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + fire(dst, 'dragenter'); + fire(dst, 'dragover'); + fire(dst, 'drop'); + fire(src, 'dragend'); + }, + [sourceHandle, targetHandle], + ); } @@ -236,9 +291,11 @@ export async function openSettingsDocumentsTab(page: Page) { // Delete all local documents through Settings and close dialogs export async function deleteAllLocalDocuments(page: Page) { await openSettingsDocumentsTab(page); - await page.getByRole('button', { name: 'Delete local' }).click(); + // When auth is enabled in tests, button label is "Delete all user docs". + // In our tests, auth is enabled and sessions start anonymous, so label is "Delete anonymous docs". + await page.getByRole('button', { name: 'Delete anonymous docs' }).click(); - const heading = page.getByRole('heading', { name: 'Delete Local Documents' }); + const heading = page.getByRole('heading', { name: 'Delete Anonymous Docs' }); await expect(heading).toBeVisible({ timeout: 10000 }); const confirmBtn = heading.locator('xpath=ancestor::*[@role="dialog"][1]//button[normalize-space()="Delete"]'); diff --git a/tests/navigation.spec.ts b/tests/navigation.spec.ts index f116774..9bff884 100644 --- a/tests/navigation.spec.ts +++ b/tests/navigation.spec.ts @@ -32,8 +32,8 @@ async function triggerViewportResize(page: any, width: number, height: number) { } test.describe('Document link navigation by type', () => { - test.beforeEach(async ({ page }) => { - await setupTest(page); + test.beforeEach(async ({ page }, testInfo) => { + await setupTest(page, testInfo); }); test('navigates to /pdf, /epub, /html and renders correct viewers', async ({ page }) => { @@ -60,8 +60,8 @@ test.describe('Document link navigation by type', () => { }); test.describe('PDF view modes and Navigator', () => { - test.beforeEach(async ({ page }) => { - await setupTest(page); + test.beforeEach(async ({ page }, testInfo) => { + await setupTest(page, testInfo); }); test('switches Single/Dual/Scroll modes and uses Navigator to change page', async ({ page }) => { @@ -101,8 +101,8 @@ test.describe('PDF view modes and Navigator', () => { }); test.describe('EPUB resize pauses TTS', () => { - test.beforeEach(async ({ page }) => { - await setupTest(page); + test.beforeEach(async ({ page }, testInfo) => { + await setupTest(page, testInfo); }); test('resizing viewport pauses playback and play resumes after', async ({ page }) => { @@ -122,4 +122,4 @@ test.describe('EPUB resize pauses TTS', () => { await page.getByRole('button', { name: 'Play' }).click(); await expectProcessingTransition(page); }); -}); \ No newline at end of file +}); diff --git a/tests/play.spec.ts b/tests/play.spec.ts index 57caaac..3d19121 100644 --- a/tests/play.spec.ts +++ b/tests/play.spec.ts @@ -45,8 +45,8 @@ async function changeNativeSpeedAndAssert(page: any, newSpeed: number) { } test.describe('Play/Pause Tests', () => { - test.beforeEach(async ({ page }) => { - await setupTest(page); + test.beforeEach(async ({ page }, testInfo) => { + await setupTest(page, testInfo); }); test.describe.configure({ mode: 'serial', timeout: 60000 }); @@ -128,4 +128,4 @@ test.describe('Play/Pause Tests', () => { await changeNativeSpeedAndAssert(page, 1.5); await expectMediaState(page, 'playing'); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/document-cache.spec.ts b/tests/unit/document-cache.spec.ts new file mode 100644 index 0000000..c1e7a02 --- /dev/null +++ b/tests/unit/document-cache.spec.ts @@ -0,0 +1,95 @@ +import { test, expect } from '@playwright/test'; +import type { BaseDocument, EPUBDocument, HTMLDocument, PDFDocument } from '../../src/types/documents'; +import { ensureCachedDocumentCore } from '../../src/lib/document-cache'; + +test.describe('document-cache-core', () => { + test('returns cached PDF without downloading', async () => { + const meta: BaseDocument = { + id: 'pdf1', + name: 'a.pdf', + size: 10, + lastModified: Date.now(), + type: 'pdf', + }; + + let downloads = 0; + const cached: PDFDocument = { ...meta, type: 'pdf', data: new ArrayBuffer(1) }; + + const result = await ensureCachedDocumentCore(meta, { + get: async () => cached, + putPdf: async () => { throw new Error('should not put'); }, + putEpub: async () => { throw new Error('should not put'); }, + putHtml: async () => { throw new Error('should not put'); }, + download: async () => { + downloads++; + return new ArrayBuffer(0); + }, + decodeText: () => '', + }); + + expect(downloads).toBe(0); + expect(result.type).toBe('pdf'); + }); + + test('downloads and stores on cache miss (EPUB)', async () => { + const meta: BaseDocument = { + id: 'epub1', + name: 'b.epub', + size: 10, + lastModified: Date.now(), + type: 'epub', + }; + + const store = new Map(); + let downloads = 0; + + const result = await ensureCachedDocumentCore(meta, { + get: async (m) => store.get(m.id) ?? null, + putPdf: async () => { /* unused */ }, + putEpub: async (m, data) => { + store.set(m.id, { ...m, type: 'epub', data } as EPUBDocument); + }, + putHtml: async () => { /* unused */ }, + download: async () => { + downloads++; + return new Uint8Array([1, 2, 3]).buffer; + }, + decodeText: () => '', + }); + + expect(downloads).toBe(1); + expect(result.type).toBe('epub'); + expect((result as EPUBDocument).data.byteLength).toBe(3); + }); + + test('downloads, decodes, and stores HTML on cache miss', async () => { + const meta: BaseDocument = { + id: 'html1', + name: 'c.txt', + size: 5, + lastModified: Date.now(), + type: 'html', + }; + + const store = new Map(); + let decodedCalls = 0; + + const result = await ensureCachedDocumentCore(meta, { + get: async (m) => store.get(m.id) ?? null, + putPdf: async () => { /* unused */ }, + putEpub: async () => { /* unused */ }, + putHtml: async (m, data) => { + store.set(m.id, { ...m, type: 'html', data } as HTMLDocument); + }, + download: async () => new TextEncoder().encode('hello').buffer, + decodeText: (buf) => { + decodedCalls++; + return new TextDecoder().decode(new Uint8Array(buf)); + }, + }); + + expect(decodedCalls).toBe(1); + expect(result.type).toBe('html'); + expect((result as HTMLDocument).data).toBe('hello'); + }); +}); diff --git a/tests/unit/transfer-user-documents.spec.ts b/tests/unit/transfer-user-documents.spec.ts new file mode 100644 index 0000000..290c3f5 --- /dev/null +++ b/tests/unit/transfer-user-documents.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from '@playwright/test'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import { documents } from '../../src/db/schema_sqlite'; +import { transferUserDocuments } from '../../src/lib/server/claim-data'; + +test.describe('transferUserDocuments', () => { + test('moves document rows to new user without PK conflicts', async () => { + process.env.BETTER_AUTH_URL = 'http://localhost:3003'; + process.env.BETTER_AUTH_SECRET = 'test-secret'; + + const sqlite = new Database(':memory:'); + sqlite.exec(` + CREATE TABLE documents ( + id TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL, + size INTEGER NOT NULL, + last_modified INTEGER NOT NULL, + file_path TEXT NOT NULL, + created_at INTEGER, + PRIMARY KEY (id, user_id) + ); + `); + + const db = drizzle(sqlite); + + const fromUserId = 'anon'; + const toUserId = 'user'; + + await db.insert(documents).values([ + { + id: 'doc-a', + userId: fromUserId, + name: 'a.pdf', + type: 'pdf', + size: 1, + lastModified: 1, + filePath: 'doc-a__a.pdf', + }, + { + id: 'doc-b', + userId: fromUserId, + name: 'b.txt', + type: 'html', + size: 2, + lastModified: 2, + filePath: 'doc-b__b.txt', + }, + // Existing row for the destination user (conflict on insert) + { + id: 'doc-a', + userId: toUserId, + name: 'a.pdf', + type: 'pdf', + size: 1, + lastModified: 1, + filePath: 'doc-a__a.pdf', + }, + ]); + + const transferred = await transferUserDocuments(fromUserId, toUserId, { db }); + expect(transferred).toBe(2); + + const remainingFrom = await db.select().from(documents).where(eq(documents.userId, fromUserId)); + expect(remainingFrom.length).toBe(0); + + const remainingTo = await db.select().from(documents).where(eq(documents.userId, toUserId)); + const ids = remainingTo.map((r) => r.id).sort(); + expect(ids).toEqual(['doc-a', 'doc-b']); + }); +}); + diff --git a/tests/upload.spec.ts b/tests/upload.spec.ts index ad49737..a233648 100644 --- a/tests/upload.spec.ts +++ b/tests/upload.spec.ts @@ -2,8 +2,8 @@ import { test, expect } from '@playwright/test'; import { uploadFile, uploadAndDisplay, setupTest, expectDocumentListed, uploadFiles, ensureDocumentsListed, clickDocumentLink, expectViewerForFile } from './helpers'; test.describe('Document Upload Tests', () => { - test.beforeEach(async ({ page }) => { - await setupTest(page); + test.beforeEach(async ({ page }, testInfo) => { + await setupTest(page, testInfo); }); test('uploads a PDF document', async ({ page }) => { @@ -62,8 +62,12 @@ test.describe('Document Upload Tests', () => { test('uploads and converts a DOCX document', async ({ page }) => { await uploadFile(page, 'sample.docx'); - // Should see the converting message - await expect(page.getByText('Converting DOCX to PDF...')).toBeVisible(); + // Should see the converting message (best-effort; conversion may complete extremely fast) + try { + await expect(page.getByText('Converting DOCX to PDF...')).toBeVisible({ timeout: 5000 }); + } catch { + // ignore + } // After conversion, should see the PDF with the same name await expectDocumentListed(page, 'sample.pdf'); }); From 81d249ed522509673c3cbc2d74b377071d4a8fb9 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 10 Feb 2026 12:29:15 -0700 Subject: [PATCH 16/55] feat(storage): implement S3/SeaweedFS blob storage for documents Replaces local filesystem document storage with S3-compatible object storage. Adds embedded SeaweedFS 'weed mini' for local development and Docker deployments. Updates document upload flow to use presigned URLs with a server fallback proxy. Refactors auth configuration to use BASE_URL and AUTH_SECRET. BREAKING CHANGE: Renamed BETTER_AUTH_URL to BASE_URL and BETTER_AUTH_SECRET to AUTH_SECRET. Removed legacy document upload/content endpoints. Requires S3 environment variables (auto-configured for embedded SeaweedFS). --- .env.example | 30 +- .gitignore | 3 +- Dockerfile | 12 +- README.md | 189 ++- examples/docker-compose.yml | 30 - package.json | 8 +- pnpm-lock.yaml | 1252 +++++++++++++++++ scripts/openreader-entrypoint.mjs | 367 +++++ src/app/api/audiobook/chapter/route.ts | 38 +- src/app/api/audiobook/route.ts | 52 +- src/app/api/audiobook/status/route.ts | 29 +- .../api/documents/{content => blob}/route.ts | 92 +- .../documents/blob/upload/fallback/route.ts | 51 + .../documents/blob/upload/presign/route.ts | 72 + .../api/documents/docx-to-pdf/upload/route.ts | 48 +- src/app/api/documents/route.ts | 317 ++--- src/app/api/documents/upload/route.ts | 95 -- src/app/api/migrations/v2/route.ts | 275 ++++ src/app/api/user/claim/route.ts | 34 + src/app/html/[id]/page.tsx | 3 +- src/components/doclist/DocumentPreview.tsx | 23 +- src/contexts/ConfigContext.tsx | 31 +- src/contexts/DocumentContext.tsx | 13 + src/contexts/HTMLContext.tsx | 10 +- src/lib/auth-client.ts | 32 +- src/lib/client-documents.ts | 160 ++- src/lib/dexie.ts | 287 ++-- src/lib/server/auth-config.ts | 6 +- src/lib/server/auth.ts | 33 +- src/lib/server/db-indexing.ts | 176 +-- src/lib/server/docstore.ts | 40 +- src/lib/server/documents-blobstore.ts | 222 +++ src/lib/server/documents-utils.ts | 18 - src/lib/server/rate-limiter.ts | 25 +- src/lib/server/s3.ts | 82 ++ tests/export.spec.ts | 9 +- tests/global-teardown.ts | 32 +- tests/unit/transfer-user-documents.spec.ts | 5 +- 38 files changed, 3314 insertions(+), 887 deletions(-) delete mode 100644 examples/docker-compose.yml create mode 100644 scripts/openreader-entrypoint.mjs rename src/app/api/documents/{content => blob}/route.ts (55%) create mode 100644 src/app/api/documents/blob/upload/fallback/route.ts create mode 100644 src/app/api/documents/blob/upload/presign/route.ts delete mode 100644 src/app/api/documents/upload/route.ts create mode 100644 src/app/api/migrations/v2/route.ts create mode 100644 src/lib/server/documents-blobstore.ts create mode 100644 src/lib/server/s3.ts diff --git a/.env.example b/.env.example index f0aa30c..8db86f8 100644 --- a/.env.example +++ b/.env.example @@ -9,14 +9,30 @@ API_KEY=api_key_optional # Path to your local whisper.cpp CLI binary for STT timestamp generation WHISPER_CPP_BIN=/whisper.cpp/build/bin/whisper-cli +# Auth (recommended for contributors and public instances) +# (Optional) Auth is only enabled when **both** AUTH_SECRET and BASE_URL are set +BASE_URL=http://localhost:3003 # Externally facing URL for this app (set to LAN IP for access from other devices on the network) +AUTH_SECRET=some_random_secret_key # Generate with `openssl rand -base64 32` +AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 # Additional trusted origins (BASE_URL is always trusted) +# (Optional) Sign in w/ GitHub Configuration +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + # Backend DB used for server-side metadata (documents/audiobooks) and, when auth is enabled, auth tables. # Defaults to SQLite at docstore/sqlite3.db when not set. POSTGRES_URL= -# Auth (recommended for contributors and public instances) -# (Optional) Auth is only enabled when **both** BETTER_AUTH_SECRET and BETTER_AUTH_URL are set -BETTER_AUTH_URL=http://localhost:3003 # Your externally facing URL for this app -BETTER_AUTH_SECRET=some_random_secret_key # Generate with `openssl rand -base64 32` -# (Optional) Sign in w/ GitHub Configuration -GITHUB_CLIENT_ID= -GITHUB_CLIENT_SECRET= +# Embedded SeaweedFS weed mini config +USE_EMBEDDED_WEED_MINI= +WEED_MINI_DIR= +WEED_MINI_WAIT_SEC= +# S3 storage config (use with embedded weed mini or external S3-compatible storage) +# For embedded weed mini, set explicit keys if you want stable credentials across restarts. +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= +S3_BUCKET= +S3_REGION= +# If empty in embedded mode, OpenReader uses BASE_URL host (when set) or detected LAN host. +S3_ENDPOINT= +S3_FORCE_PATH_STYLE= +S3_PREFIX= diff --git a/.gitignore b/.gitignore index 8cbfc60..38f5d2c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # dependencies /node_modules +/.pnpm-store /.pnp .pnp.* .yarn/* @@ -52,4 +53,4 @@ node_modules/ /playwright/.cache/ # vscode -.vscode \ No newline at end of file +.vscode diff --git a/Dockerfile b/Dockerfile index 255275d..6a37bb2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,10 @@ RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp.git && \ cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=OFF $( [ "$TARGETARCH" = "arm64" ] && echo "-DGGML_CPU_ARM_ARCH=armv8-a" || true ) && \ cmake --build build -j +# Stage 1b: extract seaweedfs weed binary (for optional embedded weed mini) +FROM chrislusf/seaweedfs:latest AS seaweedfs-builder +RUN cp "$(command -v weed)" /tmp/weed + # Stage 2: build the Next.js app FROM node:lts-alpine AS app-builder @@ -42,7 +46,7 @@ FROM node:lts-alpine AS runner # Add runtime OS dependencies: # - ffmpeg: required for audiobook export and word-by-word alignment (/api/whisper) # - libreoffice-writer: required for DOCX → PDF conversion -RUN apk add --no-cache ffmpeg libreoffice-writer +RUN apk add --no-cache ca-certificates ffmpeg libreoffice-writer # Install pnpm globally for running the app RUN npm install -g pnpm @@ -56,6 +60,9 @@ COPY --from=app-builder /app ./ # Copy the compiled whisper.cpp build output into the runtime image # (includes whisper-cli and its shared libraries, e.g. libwhisper.so, libggml.so) COPY --from=whisper-builder /opt/whisper.cpp/build /opt/whisper.cpp/build +# Copy seaweedfs weed binary for optional embedded local S3. +COPY --from=seaweedfs-builder /tmp/weed /usr/local/bin/weed +RUN chmod +x /usr/local/bin/weed # Point the app at the compiled whisper-cli binary and ensure its libs are discoverable ENV WHISPER_CPP_BIN=/opt/whisper.cpp/build/bin/whisper-cli @@ -65,4 +72,5 @@ ENV LD_LIBRARY_PATH=/opt/whisper.cpp/build EXPOSE 3003 # Start the application -CMD ["pnpm", "start"] +ENTRYPOINT ["node", "scripts/openreader-entrypoint.mjs", "--"] +CMD ["pnpm", "start:raw"] diff --git a/README.md b/README.md index 040c20c..1ba04ba 100644 --- a/README.md +++ b/README.md @@ -42,63 +42,110 @@ OpenReader WebUI is an open source text to speech document reader web app built ### 1. 🐳 Start the Docker container - Minimal (no persistence, auth disabled unless you set auth env vars): + Minimal (auth disabled, embedded storage is ephemeral, no library import): ```bash docker run --name openreader-webui \ --restart unless-stopped \ -p 3003:3003 \ + -p 8333:8333 \ ghcr.io/richardr1126/openreader-webui:latest ``` - Fully featured (persistent storage + server library import + KokoroFastAPI in Docker + optional auth): + Fully featured (persistent storage, embedded SeaweedFS `weed mini` for documents, optional auth): ```bash docker run --name openreader-webui \ --restart unless-stopped \ -p 3003:3003 \ + -p 8333:8333 \ -v openreader_docstore:/app/docstore \ -v /path/to/your/library:/app/docstore/library:ro \ -e API_BASE=http://host.docker.internal:8880/v1 \ -e API_KEY=none \ - -e BETTER_AUTH_URL=http://localhost:3003 \ - -e BETTER_AUTH_SECRET= \ + -e BASE_URL=http://localhost:3003 \ + -e AUTH_SECRET= \ ghcr.io/richardr1126/openreader-webui:latest ``` You can remove the `/app/docstore/library` mount if you don't need server library import. - You can remove both `BETTER_AUTH_*` env vars to keep auth disabled. + You can remove either `BASE_URL` or `AUTH_SECRET` to keep auth disabled. - > **Notes:** - > - > - `API_BASE` should point to your TTS API server's base URL (if running Kokoro-FastAPI locally in Docker, use `http://host.docker.internal:8880/v1`). - > - `BETTER_AUTH_URL` should be your externally-facing URL for this app (for example `https://reader.example.com` or `http://localhost:3003`). - > - To enable auth, set **both** `BETTER_AUTH_URL` and `BETTER_AUTH_SECRET` generated with `openssl rand -base64 32`. - > - OpenReader always uses a backend DB for server-side metadata. By default it uses SQLite at `/app/docstore/sqlite3.db` (persisted when `/app/docstore` is mounted). - > - If you set `POSTGRES_URL`, the container uses Postgres instead of SQLite and will run migrations against it on startup. Ensure the database is accessible. + Quick notes: + - `API_BASE` should point to your TTS API server's base URL (for local Docker Kokoro, use `http://host.docker.internal:8880/v1`). + - Expose `-p 8333:8333` for direct browser access to embedded SeaweedFS presigned URLs. + - If port `8333` is not exposed, uploads still work via `/api/documents/blob/upload/fallback`. + - To enable auth, set both `BASE_URL` and `AUTH_SECRET`.
- Docker environment variables (Click to expand) + Docker networking and blob behavior (Click to expand) + + **Ports** + + - `3003` serves the OpenReader app and API routes. + - `8333` serves embedded SeaweedFS S3 for browser direct blob access. + + **Upload behavior** + + - Primary upload path: browser uploads to presigned URL from `/api/documents/blob/upload/presign`. + - Fallback upload path: `/api/documents/blob/upload/fallback` if direct upload fails. + - Content serving path: `/api/documents/blob` (server-served bytes/snippets). + +
+ +
+ Which Docker setup should I use? (Click to expand) + + | Goal | Recommended setup | + | --- | --- | + | Fast local setup, no persistence | Minimal `docker run` example | + | Persistent docs/audiobooks and optional auth | Fully featured `docker run` example with `/app/docstore` volume | + | Browser direct blob uploads/downloads | Publish both `3003` and `8333` | + | Private blob endpoint (no `8333` published) | Keep using app APIs; uploads use fallback proxy when direct presigned upload is unreachable | + +
+ +
+ Common Docker environment variables (Click to expand) | Variable | Purpose | Example / Notes | | --- | --- | --- | | `API_BASE` | Default TTS API base URL (server-side) | `http://host.docker.internal:8880/v1` | | `API_KEY` | Default TTS API key | `none` or your provider key | - | `BETTER_AUTH_URL` | Enables auth when set with `BETTER_AUTH_SECRET` | External URL for this app, e.g. `http://localhost:3003` or `https://reader.example.com` | - | `BETTER_AUTH_SECRET` | Enables auth when set with `BETTER_AUTH_URL` | Generate with `openssl rand -base64 32` | + | `BASE_URL` | Enables auth when set with `AUTH_SECRET` | External URL for this app, e.g. `http://localhost:3003` or `https://reader.example.com` | + | `AUTH_SECRET` | Enables auth when set with `BASE_URL` | Generate with `openssl rand -base64 32` | + | `AUTH_TRUSTED_ORIGINS` | Extra allowed auth request origins | Comma-separated list; leave empty to trust only `BASE_URL` | | `POSTGRES_URL` | Use Postgres for server DB (metadata + auth tables) instead of SQLite | If set, startup migrations target Postgres | | `GITHUB_CLIENT_ID` | Optional GitHub OAuth sign-in | Requires `GITHUB_CLIENT_SECRET` | | `GITHUB_CLIENT_SECRET` | Optional GitHub OAuth sign-in | Requires `GITHUB_CLIENT_ID` |
+
+ Blob and embedded storage environment variables (Click to expand) + + | Variable | Purpose | Example / Notes | + | --- | --- | --- | + | `USE_EMBEDDED_WEED_MINI` | Start SeaweedFS before app startup | default: `true` when unset in shared entrypoint | + | `WEED_MINI_DIR` | Data directory for embedded `weed mini` | default: `docstore/seaweedfs` | + | `WEED_MINI_WAIT_SEC` | Startup wait timeout for embedded `weed mini` | default: `20` | + | `S3_BUCKET` | S3 bucket used for document blobs | default: `openreader-documents` in embedded mode | + | `S3_REGION` | S3 region used by AWS SDK | default: `us-east-1` in embedded mode | + | `S3_ENDPOINT` | Custom endpoint for S3-compatible providers | default: `http://:8333` when `BASE_URL` is set, otherwise detected LAN host | + | `S3_ACCESS_KEY_ID` | S3 access key | auto-generated in embedded mode if unset | + | `S3_SECRET_ACCESS_KEY` | S3 secret key | auto-generated in embedded mode if unset | + | `S3_FORCE_PATH_STYLE` | Force path-style addressing | default: `true` in embedded mode | + | `S3_PREFIX` | Prefix for stored object keys | default: `openreader` | + +
+
Docker volume mounts (Click to expand) | Mount | Type | Recommended | Purpose | Example | | --- | --- | --- | --- | --- | - | `/app/docstore` | Docker named volume | Yes | Persists server-side storage (documents, audiobook exports, settings, SQLite DB if used) | `-v openreader_docstore:/app/docstore` | - | `/app/docstore/library` | Bind mount (host folder) | Optional + `:ro` | Exposes an existing folder of documents for **Server Library Import** | `-v /path/to/your/library:/app/docstore/library:ro` | + | `/app/docstore` | Docker named volume | Yes (if you want persistence) | Persists embedded SeaweedFS blob data (`docstore/seaweedfs`), SQLite metadata DB (`docstore/sqlite3.db` when `POSTGRES_URL` is unset), audiobook export artifacts, and migration/runtime files | `-v openreader_docstore:/app/docstore` | + | `/app/docstore/library` | Bind mount (host folder) | Optional + `:ro` | Read-only source directory for **Server Library Import**; files are imported/copied into browser storage, not modified in place | `-v /path/to/your/library:/app/docstore/library:ro` | To import from the mounted library: **Settings → Documents → Server Library Import** @@ -117,8 +164,9 @@ OpenReader WebUI is an open source text to speech document reader web app built ### 3. ⬆️ Updating Docker Image ```bash -docker stop openreader-webui && \ -docker rm openreader-webui && \ +docker stop openreader-webui || true && \ +docker rm openreader-webui || true && \ +docker image rm ghcr.io/richardr1126/openreader-webui:latest || true && \ docker pull ghcr.io/richardr1126/openreader-webui:latest ``` @@ -194,7 +242,15 @@ docker run -d \ ``` - A TTS API server (Kokoro-FastAPI, Orpheus-FastAPI, Deepinfra, OpenAI, etc.) running and accessible -Optionally required for different features: +- [SeaweedFS](https://github.com/seaweedfs/seaweedfs) `weed` binary (required) + + ```bash + brew install seaweedfs + ``` + + > **Note:** Verify install with `weed version`. + +#### Optionally required for different features: - [FFmpeg](https://ffmpeg.org) (required for audiobook m4b creation only) ```bash @@ -248,16 +304,52 @@ Optionally required for different features: Auth is recommended for contributors and is enabled when **both** values are set: - - Set `BETTER_AUTH_URL` to your local URL (default: `http://localhost:3003`) - - Generate a `BETTER_AUTH_SECRET` and paste it into `.env`: + - Set `BASE_URL` to your local URL (default: `http://localhost:3003`) + - Generate a `AUTH_SECRET` and paste it into `.env`: ```bash openssl rand -base64 32 ``` + - (Optional) If you use both localhost and LAN URL, set `AUTH_TRUSTED_ORIGINS` (leave empty to trust only `BASE_URL`): - > Note: To disable auth, remove either `BETTER_AUTH_URL` or `BETTER_AUTH_SECRET`. + ```bash + AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://192.168.0.116:3003 + ``` + + The embedded weed mini object store is enabled by default for local development, and started through the shared entrypoint. The ACCESS_KEY_ID and SECRET_ACCESS_KEY are auto-generated if unset, but for stable credentials across restarts, you can generate and set them in `.env`: + - (Optional) Generate `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY` and paste them into `.env` for stable credentials: + + ```bash + S3_ACCESS_KEY_ID=<`openssl rand -hex 16`> + S3_SECRET_ACCESS_KEY=<`openssl rand -hex 32`> + ``` + - (Optional) Connect to external S3-compatible storage instead of embedded `weed mini`: + + ```bash + USE_EMBEDDED_WEED_MINI=false + + # Required + S3_BUCKET=your-bucket + S3_REGION=us-east-1 + S3_ACCESS_KEY_ID=your-access-key + S3_SECRET_ACCESS_KEY=your-secret-key + + # Optional / provider-specific + S3_ENDPOINT= + S3_FORCE_PATH_STYLE= + S3_PREFIX=openreader + ``` + + Notes: + - For AWS S3: usually leave `S3_ENDPOINT` empty and `S3_FORCE_PATH_STYLE` empty/false. + - For MinIO/SeaweedFS/R2/B2 S3: set `S3_ENDPOINT` to your endpoint URL and set `S3_FORCE_PATH_STYLE=true` when required by that provider. + + > Notes: + > - The base URL for the TTS API should be accessible and relative to the Next.js server > - > Note: The base URL for the TTS API should be accessible and relative to the Next.js server + > - To disable auth, remove either `BASE_URL` or `AUTH_SECRET`. + > + > - If S3 credentials are unset, they are auto-generated per startup. 4. Run DB migrations: @@ -316,6 +408,10 @@ This project would not be possible without standing on the shoulders of these gi - [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) model - [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI) +- [Better Auth](https://www.better-auth.com/) +- [SQLite](https://www.sqlite.org/) +- [PostgreSQL](https://www.postgresql.org/) +- [SeaweedFS](https://github.com/seaweedfs/seaweedfs) (`weed mini`) - [whisper.cpp](https://github.com/ggerganov/whisper.cpp) - [ffmpeg](https://ffmpeg.org) - [react-pdf](https://github.com/wojtekmaj/react-pdf) npm package @@ -328,31 +424,26 @@ This project would not be possible without standing on the shoulders of these gi ## Stack -- **Framework:** Next.js (React) -- **Containerization:** Docker -- **Storage:** - - [Dexie.js](https://dexie.org/) IndexedDB wrapper for client-side storage -- **PDF:** - - [react-pdf](https://github.com/wojtekmaj/react-pdf) - - [pdf.js](https://mozilla.github.io/pdf.js/) -- **EPUB:** - - [react-reader](https://github.com/gerhardsletten/react-reader) - - [epubjs](https://github.com/futurepress/epub.js/) -- **Markdown/Text:** - - [react-markdown](https://github.com/remarkjs/react-markdown) - - [remark-gfm](https://github.com/remarkjs/remark-gfm) -- **UI:** - - [Tailwind CSS](https://tailwindcss.com) - - [Headless UI](https://headlessui.com) - - [@tailwindcss/typography](https://tailwindcss.com/docs/typography-plugin) -- **TTS:** (tested on) - - [Deepinfra API](https://deepinfra.com) (Kokoro-82M, Orpheus-3B, Sesame-1B) - - [Kokoro FastAPI TTS](https://github.com/remsky/Kokoro-FastAPI/tree/v0.0.5post1-stable) - - [Orpheus FastAPI TTS](https://github.com/Lex-au/Orpheus-FastAPI) -- **NLP:** - - [compromise](https://github.com/spencermountain/compromise) NLP library for sentence splitting - - [cmpstr](https://github.com/remsky/cmpstr) String comparison library - - [whisper.cpp](https://github.com/ggerganov/whisper.cpp) for TTS timestamps (word-by-word highlighting) +- **Framework:** [Next.js](https://nextjs.org/) 15 (App Router), [React](https://react.dev/) 19, [TypeScript](https://www.typescriptlang.org/) +- **Containerization / Runtime:** [Docker](https://www.docker.com/) (linux/amd64 + linux/arm64), with a shared entrypoint that can bootstrap embedded SeaweedFS before app startup +- **Next.js Client:** + - **UI:** [Tailwind CSS](https://tailwindcss.com), [Headless UI](https://headlessui.com), [@tailwindcss/typography](https://tailwindcss.com/docs/typography-plugin) + - **Interactions:** `react-dnd`, `react-dropzone` + - **Authentication:** [Better Auth](https://www.better-auth.com/) client SDK (`better-auth/react`, anonymous client plugin) + - **Local storage/cache:** [Dexie.js](https://dexie.org/) (IndexedDB) for documents, cache, and app settings + - **Document rendering:** + - **PDF:** [react-pdf](https://github.com/wojtekmaj/react-pdf), [pdf.js](https://mozilla.github.io/pdf.js/) + - **EPUB:** [react-reader](https://github.com/gerhardsletten/react-reader), [epubjs](https://github.com/futurepress/epub.js/) + - **Markdown/Text:** [react-markdown](https://github.com/remarkjs/react-markdown), [remark-gfm](https://github.com/remarkjs/remark-gfm) + - **Text preprocessing / matching:** [compromise](https://github.com/spencermountain/compromise), [cmpstr](https://github.com/remsky/cmpstr) +- **Next.js Server:** + - **APIs:** Next.js Route Handlers for document sync, blob/content access, migration flows, audiobook export, and TTS/Whisper proxying + - **Authentication:** [Better Auth](https://www.better-auth.com/) server handlers/adapters for session and auth routing + - **Text preprocessing / NLP utilities:** [compromise](https://github.com/spencermountain/compromise) via shared `lib/nlp` helpers used in server processing paths + - **Metadata database:** [Drizzle ORM](https://orm.drizzle.team/), [SQLite](https://www.sqlite.org/) (`better-sqlite3`) by default, optional [PostgreSQL](https://www.postgresql.org/) (`pg`) + - **Blob/object storage:** embedded [SeaweedFS](https://github.com/seaweedfs/seaweedfs) (`weed mini`) by default, or external S3-compatible storage via AWS SDK v3 (`@aws-sdk/client-s3`, presigned URLs, upload fallback proxy) + - **Audio/processing pipeline:** OpenAI-compatible TTS providers (OpenAI, DeepInfra, Kokoro, Orpheus, custom), [ffmpeg](https://ffmpeg.org/) for audiobook assembly, optional [whisper.cpp](https://github.com/ggerganov/whisper.cpp) for word-level timestamps +- **Tooling / Testing:** ESLint, TypeScript, [Playwright](https://playwright.dev/) end-to-end tests, and Drizzle migrations/generation scripts ## License diff --git a/examples/docker-compose.yml b/examples/docker-compose.yml deleted file mode 100644 index 218398c..0000000 --- a/examples/docker-compose.yml +++ /dev/null @@ -1,30 +0,0 @@ -services: - kokoro-tts: - container_name: kokoro-tts - image: ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.4 - ports: - - "8880:8880" - environment: - # ONNX Optimization Settings for vectorized operations - - ONNX_NUM_THREADS=8 # Maximize core usage for vectorized ops - - ONNX_INTER_OP_THREADS=4 # Higher inter-op for parallel matrix operations - - ONNX_EXECUTION_MODE=parallel - - ONNX_OPTIMIZATION_LEVEL=all - - ONNX_MEMORY_PATTERN=true - - ONNX_ARENA_EXTEND_STRATEGY=kNextPowerOfTwo - - API_LOG_LEVEL=DEBUG - restart: unless-stopped - - openreader-webui: - container_name: openreader-webui - image: ghcr.io/richardr1126/openreader-webui:latest - environment: - - API_BASE=http://host.docker.internal:8880/v1 - ports: - - "3003:3003" - volumes: - - docstore:/app/docstore - restart: unless-stopped - -volumes: - docstore: \ No newline at end of file diff --git a/package.json b/package.json index d376aa1..dd95738 100644 --- a/package.json +++ b/package.json @@ -3,15 +3,19 @@ "version": "v1.3.0", "private": true, "scripts": { - "dev": "next dev --turbopack -p 3003", + "dev": "node scripts/openreader-entrypoint.mjs -- pnpm dev:raw", + "dev:raw": "next dev --turbopack -p 3003", "build": "next build", - "start": "node drizzle/scripts/migrate.mjs && next start -p 3003", + "start": "node scripts/openreader-entrypoint.mjs -- pnpm start:raw", + "start:raw": "next start -p 3003", "lint": "next lint", "test": "playwright test", "migrate": "node drizzle/scripts/migrate.mjs", "generate": "node drizzle/scripts/generate.mjs" }, "dependencies": { + "@aws-sdk/client-s3": "^3.985.0", + "@aws-sdk/s3-request-presigner": "^3.985.0", "@headlessui/react": "^2.2.9", "@types/howler": "^2.2.12", "@types/uuid": "^10.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cc42a0..f41538b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,12 @@ importers: .: dependencies: + '@aws-sdk/client-s3': + specifier: ^3.985.0 + version: 3.985.0 + '@aws-sdk/s3-request-presigner': + specifier: ^3.985.0 + version: 3.985.0 '@headlessui/react': specifier: ^2.2.9 version: 2.2.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -156,6 +162,177 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/crc32c@5.2.0': + resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} + + '@aws-crypto/sha1-browser@5.2.0': + resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-s3@3.985.0': + resolution: {integrity: sha512-S9TqjzzZEEIKBnC7yFpvqM7CG9ALpY5qhQ5BnDBJtdG20NoGpjKLGUUfD2wmZItuhbrcM4Z8c6m6Fg0XYIOVvw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-sso@3.985.0': + resolution: {integrity: sha512-81J8iE8MuXhdbMfIz4sWFj64Pe41bFi/uqqmqOC5SlGv+kwoyLsyKS/rH2tW2t5buih4vTUxskRjxlqikTD4oQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.973.7': + resolution: {integrity: sha512-wNZZQQNlJ+hzD49cKdo+PY6rsTDElO8yDImnrI69p2PLBa7QomeUKAJWYp9xnaR38nlHqWhMHZuYLCQ3oSX+xg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/crc64-nvme@3.972.0': + resolution: {integrity: sha512-ThlLhTqX68jvoIVv+pryOdb5coP1cX1/MaTbB9xkGDCbWbsqQcLqzPxuSoW1DCnAAIacmXCWpzUNOB9pv+xXQw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.5': + resolution: {integrity: sha512-LxJ9PEO4gKPXzkufvIESUysykPIdrV7+Ocb9yAhbhJLE4TiAYqbCVUE+VuKP1leGR1bBfjWjYgSV5MxprlX3mQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.7': + resolution: {integrity: sha512-L2uOGtvp2x3bTcxFTpSM+GkwFIPd8pHfGWO1764icMbo7e5xJh0nfhx1UwkXLnwvocTNEf8A7jISZLYjUSNaTg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.5': + resolution: {integrity: sha512-SdDTYE6jkARzOeL7+kudMIM4DaFnP5dZVeatzw849k4bSXDdErDS188bgeNzc/RA2WGrlEpsqHUKP6G7sVXhZg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.5': + resolution: {integrity: sha512-uYq1ILyTSI6ZDCMY5+vUsRM0SOCVI7kaW4wBrehVVkhAxC6y+e9rvGtnoZqCOWL1gKjTMouvsf4Ilhc5NCg1Aw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.6': + resolution: {integrity: sha512-DZ3CnAAtSVtVz+G+ogqecaErMLgzph4JH5nYbHoBMgBkwTUV+SUcjsjOJwdBJTHu3Dm6l5LBYekZoU2nDqQk2A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.5': + resolution: {integrity: sha512-HDKF3mVbLnuqGg6dMnzBf1VUOywE12/N286msI9YaK9mEIzdsGCtLTvrDhe3Up0R9/hGFbB+9l21/TwF5L1C6g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.5': + resolution: {integrity: sha512-8urj3AoeNeQisjMmMBhFeiY2gxt6/7wQQbEGun0YV/OaOOiXrIudTIEYF8ZfD+NQI6X1FY5AkRsx6O/CaGiybA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.5': + resolution: {integrity: sha512-OK3cULuJl6c+RcDZfPpaK5o3deTOnKZbxm7pzhFNGA3fI2hF9yDih17fGRazJzGGWaDVlR9ejZrpDef4DJCEsw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-bucket-endpoint@3.972.3': + resolution: {integrity: sha512-fmbgWYirF67YF1GfD7cg5N6HHQ96EyRNx/rDIrTF277/zTWVuPI2qS/ZHgofwR1NZPe/NWvoppflQY01LrbVLg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-expect-continue@3.972.3': + resolution: {integrity: sha512-4msC33RZsXQpUKR5QR4HnvBSNCPLGHmB55oDiROqqgyOc+TOfVu2xgi5goA7ms6MdZLeEh2905UfWMnMMF4mRg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-flexible-checksums@3.972.5': + resolution: {integrity: sha512-SF/1MYWx67OyCrLA4icIpWUfCkdlOi8Y1KecQ9xYxkL10GMjVdPTGPnYhAg0dw5U43Y9PVUWhAV2ezOaG+0BLg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-host-header@3.972.3': + resolution: {integrity: sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-location-constraint@3.972.3': + resolution: {integrity: sha512-nIg64CVrsXp67vbK0U1/Is8rik3huS3QkRHn2DRDx4NldrEFMgdkZGI/+cZMKD9k4YOS110Dfu21KZLHrFA/1g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-logger@3.972.3': + resolution: {integrity: sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.972.3': + resolution: {integrity: sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.7': + resolution: {integrity: sha512-VtZ7tMIw18VzjG+I6D6rh2eLkJfTtByiFoCIauGDtTTPBEUMQUiGaJ/zZrPlCY6BsvLLeFKz3+E5mntgiOWmIg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-ssec@3.972.3': + resolution: {integrity: sha512-dU6kDuULN3o3jEHcjm0c4zWJlY1zWVkjG9NPe9qxYLLpcbdj5kRYBS2DdWYD+1B9f910DezRuws7xDEqKkHQIg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-user-agent@3.972.7': + resolution: {integrity: sha512-HUD+geASjXSCyL/DHPQc/Ua7JhldTcIglVAoCV8kiVm99IaFSlAbTvEnyhZwdE6bdFyTL+uIaWLaCFSRsglZBQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.985.0': + resolution: {integrity: sha512-TsWwKzb/2WHafAY0CE7uXgLj0FmnkBTgfioG9HO+7z/zCPcl1+YU+i7dW4o0y+aFxFgxTMG+ExBQpqT/k2ao8g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/region-config-resolver@3.972.3': + resolution: {integrity: sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/s3-request-presigner@3.985.0': + resolution: {integrity: sha512-lPnf977GFM4cMLJ7X+ThktKMe/0CXIfX+wz1z+sUT7yagPL2IRyiNUPFZ0VTEGBo1gRhHEDPWy6yzk8WWRFsvg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.985.0': + resolution: {integrity: sha512-W6hTSOPiSbh4IdTYVxN7xHjpCh0qvfQU1GKGBzGQm0ZEIOaMmWqiDEvFfyGYKmfBvumT8vHKxQRTX0av9omtIg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.985.0': + resolution: {integrity: sha512-+hwpHZyEq8k+9JL2PkE60V93v2kNhUIv7STFt+EAez1UJsJOQDhc5LpzEX66pNjclI5OTwBROs/DhJjC/BtMjQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.1': + resolution: {integrity: sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-arn-parser@3.972.2': + resolution: {integrity: sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-endpoints@3.985.0': + resolution: {integrity: sha512-vth7UfGSUR3ljvaq8V4Rc62FsM7GUTH/myxPWkaEgOrprz1/Pc72EgTXxj+cPPPDAfHFIpjhkB7T7Td0RJx+BA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-format-url@3.972.3': + resolution: {integrity: sha512-n7F2ycckcKFXa01vAsT/SJdjFHfKH9s96QHcs5gn8AaaigASICeME8WdUL9uBp8XV/OVwEt8+6gzn6KFUgQa8g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.4': + resolution: {integrity: sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-user-agent-browser@3.972.3': + resolution: {integrity: sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==} + + '@aws-sdk/util-user-agent-node@3.972.5': + resolution: {integrity: sha512-GsUDF+rXyxDZkkJxUsDxnA67FG+kc5W1dnloCFLl6fWzceevsCYzJpASBzT+BPjwUgREE6FngfJYYYMQUY5fZQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/xml-builder@3.972.4': + resolution: {integrity: sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.3': + resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==} + engines: {node: '>=18.0.0'} + '@babel/runtime@7.28.6': resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} @@ -868,6 +1045,222 @@ packages: '@rushstack/eslint-patch@1.15.0': resolution: {integrity: sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw==} + '@smithy/abort-controller@4.2.8': + resolution: {integrity: sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==} + engines: {node: '>=18.0.0'} + + '@smithy/chunked-blob-reader-native@4.2.1': + resolution: {integrity: sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==} + engines: {node: '>=18.0.0'} + + '@smithy/chunked-blob-reader@5.2.0': + resolution: {integrity: sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA==} + engines: {node: '>=18.0.0'} + + '@smithy/config-resolver@4.4.6': + resolution: {integrity: sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==} + engines: {node: '>=18.0.0'} + + '@smithy/core@3.22.1': + resolution: {integrity: sha512-x3ie6Crr58MWrm4viHqqy2Du2rHYZjwu8BekasrQx4ca+Y24dzVAwq3yErdqIbc2G3I0kLQA13PQ+/rde+u65g==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.2.8': + resolution: {integrity: sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-codec@4.2.8': + resolution: {integrity: sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-browser@4.2.8': + resolution: {integrity: sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-config-resolver@4.3.8': + resolution: {integrity: sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-node@4.2.8': + resolution: {integrity: sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-universal@4.2.8': + resolution: {integrity: sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.3.9': + resolution: {integrity: sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-blob-browser@4.2.9': + resolution: {integrity: sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-node@4.2.8': + resolution: {integrity: sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-stream-node@4.2.8': + resolution: {integrity: sha512-v0FLTXgHrTeheYZFGhR+ehX5qUm4IQsjAiL9qehad2cyjMWcN2QG6/4mSwbSgEQzI7jwfoXj7z4fxZUx/Mhj2w==} + engines: {node: '>=18.0.0'} + + '@smithy/invalid-dependency@4.2.8': + resolution: {integrity: sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/is-array-buffer@4.2.0': + resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==} + engines: {node: '>=18.0.0'} + + '@smithy/md5-js@4.2.8': + resolution: {integrity: sha512-oGMaLj4tVZzLi3itBa9TCswgMBr7k9b+qKYowQ6x1rTyTuO1IU2YHdHUa+891OsOH+wCsH7aTPRsTJO3RMQmjQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-content-length@4.2.8': + resolution: {integrity: sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-endpoint@4.4.13': + resolution: {integrity: sha512-x6vn0PjYmGdNuKh/juUJJewZh7MoQ46jYaJ2mvekF4EesMuFfrl4LaW/k97Zjf8PTCPQmPgMvwewg7eNoH9n5w==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-retry@4.4.30': + resolution: {integrity: sha512-CBGyFvN0f8hlnqKH/jckRDz78Snrp345+PVk8Ux7pnkUCW97Iinse59lY78hBt04h1GZ6hjBN94BRwZy1xC8Bg==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-serde@4.2.9': + resolution: {integrity: sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-stack@4.2.8': + resolution: {integrity: sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-config-provider@4.3.8': + resolution: {integrity: sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.4.9': + resolution: {integrity: sha512-KX5Wml5mF+luxm1szW4QDz32e3NObgJ4Fyw+irhph4I/2geXwUy4jkIMUs5ZPGflRBeR6BUkC2wqIab4Llgm3w==} + engines: {node: '>=18.0.0'} + + '@smithy/property-provider@4.2.8': + resolution: {integrity: sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==} + engines: {node: '>=18.0.0'} + + '@smithy/protocol-http@5.3.8': + resolution: {integrity: sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-builder@4.2.8': + resolution: {integrity: sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-parser@4.2.8': + resolution: {integrity: sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==} + engines: {node: '>=18.0.0'} + + '@smithy/service-error-classification@4.2.8': + resolution: {integrity: sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==} + engines: {node: '>=18.0.0'} + + '@smithy/shared-ini-file-loader@4.4.3': + resolution: {integrity: sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.3.8': + resolution: {integrity: sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==} + engines: {node: '>=18.0.0'} + + '@smithy/smithy-client@4.11.2': + resolution: {integrity: sha512-SCkGmFak/xC1n7hKRsUr6wOnBTJ3L22Qd4e8H1fQIuKTAjntwgU8lrdMe7uHdiT2mJAOWA/60qaW9tiMu69n1A==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.12.0': + resolution: {integrity: sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.2.8': + resolution: {integrity: sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-base64@4.3.0': + resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-browser@4.2.0': + resolution: {integrity: sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-node@4.2.1': + resolution: {integrity: sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-buffer-from@4.2.0': + resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==} + engines: {node: '>=18.0.0'} + + '@smithy/util-config-provider@4.2.0': + resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-browser@4.3.29': + resolution: {integrity: sha512-nIGy3DNRmOjaYaaKcQDzmWsro9uxlaqUOhZDHQed9MW/GmkBZPtnU70Pu1+GT9IBmUXwRdDuiyaeiy9Xtpn3+Q==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-node@4.2.32': + resolution: {integrity: sha512-7dtFff6pu5fsjqrVve0YMhrnzJtccCWDacNKOkiZjJ++fmjGExmmSu341x+WU6Oc1IccL7lDuaUj7SfrHpWc5Q==} + engines: {node: '>=18.0.0'} + + '@smithy/util-endpoints@3.2.8': + resolution: {integrity: sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-hex-encoding@4.2.0': + resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.2.8': + resolution: {integrity: sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==} + engines: {node: '>=18.0.0'} + + '@smithy/util-retry@4.2.8': + resolution: {integrity: sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.5.11': + resolution: {integrity: sha512-lKmZ0S/3Qj2OF5H1+VzvDLb6kRxGzZHq6f3rAsoSu5cTLGsn3v3VQBA8czkNNXlLjoFEtVu3OQT2jEeOtOE2CA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-uri-escape@4.2.0': + resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@4.2.0': + resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-waiter@4.2.8': + resolution: {integrity: sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg==} + engines: {node: '>=18.0.0'} + + '@smithy/uuid@1.1.0': + resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} + engines: {node: '>=18.0.0'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1316,6 +1709,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bowser@2.13.1: + resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -1869,6 +2265,10 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-xml-parser@5.3.4: + resolution: {integrity: sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==} + hasBin: true + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -3076,6 +3476,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strnum@2.1.2: + resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -3294,6 +3697,509 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.1 + tslib: 2.8.1 + + '@aws-crypto/crc32c@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.1 + tslib: 2.8.1 + + '@aws-crypto/sha1-browser@5.2.0': + dependencies: + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-locate-window': 3.965.4 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-locate-window': 3.965.4 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.1 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.985.0': + dependencies: + '@aws-crypto/sha1-browser': 5.2.0 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.7 + '@aws-sdk/credential-provider-node': 3.972.6 + '@aws-sdk/middleware-bucket-endpoint': 3.972.3 + '@aws-sdk/middleware-expect-continue': 3.972.3 + '@aws-sdk/middleware-flexible-checksums': 3.972.5 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-location-constraint': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-sdk-s3': 3.972.7 + '@aws-sdk/middleware-ssec': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.7 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/signature-v4-multi-region': 3.985.0 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.985.0 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.5 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.22.1 + '@smithy/eventstream-serde-browser': 4.2.8 + '@smithy/eventstream-serde-config-resolver': 4.3.8 + '@smithy/eventstream-serde-node': 4.2.8 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-blob-browser': 4.2.9 + '@smithy/hash-node': 4.2.8 + '@smithy/hash-stream-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/md5-js': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.13 + '@smithy/middleware-retry': 4.4.30 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.9 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.2 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.29 + '@smithy/util-defaults-mode-node': 4.2.32 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-stream': 4.5.11 + '@smithy/util-utf8': 4.2.0 + '@smithy/util-waiter': 4.2.8 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-sso@3.985.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.7 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.7 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.985.0 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.5 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.22.1 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.13 + '@smithy/middleware-retry': 4.4.30 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.9 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.2 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.29 + '@smithy/util-defaults-mode-node': 4.2.32 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/core@3.973.7': + dependencies: + '@aws-sdk/types': 3.973.1 + '@aws-sdk/xml-builder': 3.972.4 + '@smithy/core': 3.22.1 + '@smithy/node-config-provider': 4.3.8 + '@smithy/property-provider': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/signature-v4': 5.3.8 + '@smithy/smithy-client': 4.11.2 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/crc64-nvme@3.972.0': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.5': + dependencies: + '@aws-sdk/core': 3.973.7 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.7': + dependencies: + '@aws-sdk/core': 3.973.7 + '@aws-sdk/types': 3.973.1 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/node-http-handler': 4.4.9 + '@smithy/property-provider': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.2 + '@smithy/types': 4.12.0 + '@smithy/util-stream': 4.5.11 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.5': + dependencies: + '@aws-sdk/core': 3.973.7 + '@aws-sdk/credential-provider-env': 3.972.5 + '@aws-sdk/credential-provider-http': 3.972.7 + '@aws-sdk/credential-provider-login': 3.972.5 + '@aws-sdk/credential-provider-process': 3.972.5 + '@aws-sdk/credential-provider-sso': 3.972.5 + '@aws-sdk/credential-provider-web-identity': 3.972.5 + '@aws-sdk/nested-clients': 3.985.0 + '@aws-sdk/types': 3.973.1 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-login@3.972.5': + dependencies: + '@aws-sdk/core': 3.973.7 + '@aws-sdk/nested-clients': 3.985.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-node@3.972.6': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.5 + '@aws-sdk/credential-provider-http': 3.972.7 + '@aws-sdk/credential-provider-ini': 3.972.5 + '@aws-sdk/credential-provider-process': 3.972.5 + '@aws-sdk/credential-provider-sso': 3.972.5 + '@aws-sdk/credential-provider-web-identity': 3.972.5 + '@aws-sdk/types': 3.973.1 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-process@3.972.5': + dependencies: + '@aws-sdk/core': 3.973.7 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.5': + dependencies: + '@aws-sdk/client-sso': 3.985.0 + '@aws-sdk/core': 3.973.7 + '@aws-sdk/token-providers': 3.985.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.972.5': + dependencies: + '@aws-sdk/core': 3.973.7 + '@aws-sdk/nested-clients': 3.985.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/middleware-bucket-endpoint@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-arn-parser': 3.972.2 + '@smithy/node-config-provider': 4.3.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-config-provider': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-expect-continue@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-flexible-checksums@3.972.5': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@aws-crypto/crc32c': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/core': 3.973.7 + '@aws-sdk/crc64-nvme': 3.972.0 + '@aws-sdk/types': 3.973.1 + '@smithy/is-array-buffer': 4.2.0 + '@smithy/node-config-provider': 4.3.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-stream': 4.5.11 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-host-header@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-location-constraint@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@aws/lambda-invoke-store': 0.2.3 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.7': + dependencies: + '@aws-sdk/core': 3.973.7 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-arn-parser': 3.972.2 + '@smithy/core': 3.22.1 + '@smithy/node-config-provider': 4.3.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/signature-v4': 5.3.8 + '@smithy/smithy-client': 4.11.2 + '@smithy/types': 4.12.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-stream': 4.5.11 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-ssec@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.972.7': + dependencies: + '@aws-sdk/core': 3.973.7 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.985.0 + '@smithy/core': 3.22.1 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.985.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.7 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.7 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.985.0 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.5 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.22.1 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.13 + '@smithy/middleware-retry': 4.4.30 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.9 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.2 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.29 + '@smithy/util-defaults-mode-node': 4.2.32 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/region-config-resolver@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/config-resolver': 4.4.6 + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/s3-request-presigner@3.985.0': + dependencies: + '@aws-sdk/signature-v4-multi-region': 3.985.0 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-format-url': 3.972.3 + '@smithy/middleware-endpoint': 4.4.13 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.2 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.985.0': + dependencies: + '@aws-sdk/middleware-sdk-s3': 3.972.7 + '@aws-sdk/types': 3.973.1 + '@smithy/protocol-http': 5.3.8 + '@smithy/signature-v4': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.985.0': + dependencies: + '@aws-sdk/core': 3.973.7 + '@aws-sdk/nested-clients': 3.985.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/types@3.973.1': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/util-arn-parser@3.972.2': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.985.0': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-endpoints': 3.2.8 + tslib: 2.8.1 + + '@aws-sdk/util-format-url@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/querystring-builder': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.4': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-browser@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + bowser: 2.13.1 + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-node@3.972.5': + dependencies: + '@aws-sdk/middleware-user-agent': 3.972.7 + '@aws-sdk/types': 3.973.1 + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.4': + dependencies: + '@smithy/types': 4.12.0 + fast-xml-parser: 5.3.4 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.3': {} + '@babel/runtime@7.28.6': {} '@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0)': @@ -3813,6 +4719,344 @@ snapshots: '@rushstack/eslint-patch@1.15.0': {} + '@smithy/abort-controller@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/chunked-blob-reader-native@4.2.1': + dependencies: + '@smithy/util-base64': 4.3.0 + tslib: 2.8.1 + + '@smithy/chunked-blob-reader@5.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/config-resolver@4.4.6': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + tslib: 2.8.1 + + '@smithy/core@3.22.1': + dependencies: + '@smithy/middleware-serde': 4.2.9 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-stream': 4.5.11 + '@smithy/util-utf8': 4.2.0 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.2.8': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/property-provider': 4.2.8 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + tslib: 2.8.1 + + '@smithy/eventstream-codec@4.2.8': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.12.0 + '@smithy/util-hex-encoding': 4.2.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-browser@4.2.8': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-config-resolver@4.3.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-node@4.2.8': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-universal@4.2.8': + dependencies: + '@smithy/eventstream-codec': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.3.9': + dependencies: + '@smithy/protocol-http': 5.3.8 + '@smithy/querystring-builder': 4.2.8 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + tslib: 2.8.1 + + '@smithy/hash-blob-browser@4.2.9': + dependencies: + '@smithy/chunked-blob-reader': 5.2.0 + '@smithy/chunked-blob-reader-native': 4.2.1 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/hash-node@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/hash-stream-node@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/invalid-dependency@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/is-array-buffer@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/md5-js@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/middleware-content-length@4.2.8': + dependencies: + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/middleware-endpoint@4.4.13': + dependencies: + '@smithy/core': 3.22.1 + '@smithy/middleware-serde': 4.2.9 + '@smithy/node-config-provider': 4.3.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-middleware': 4.2.8 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.4.30': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/service-error-classification': 4.2.8 + '@smithy/smithy-client': 4.11.2 + '@smithy/types': 4.12.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.2.9': + dependencies: + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/middleware-stack@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/node-config-provider@4.3.8': + dependencies: + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.4.9': + dependencies: + '@smithy/abort-controller': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/querystring-builder': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/property-provider@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/protocol-http@5.3.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/querystring-builder@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + '@smithy/util-uri-escape': 4.2.0 + tslib: 2.8.1 + + '@smithy/querystring-parser@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/service-error-classification@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + + '@smithy/shared-ini-file-loader@4.4.3': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.3.8': + dependencies: + '@smithy/is-array-buffer': 4.2.0 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-uri-escape': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/smithy-client@4.11.2': + dependencies: + '@smithy/core': 3.22.1 + '@smithy/middleware-endpoint': 4.4.13 + '@smithy/middleware-stack': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-stream': 4.5.11 + tslib: 2.8.1 + + '@smithy/types@4.12.0': + dependencies: + tslib: 2.8.1 + + '@smithy/url-parser@4.2.8': + dependencies: + '@smithy/querystring-parser': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-base64@4.3.0': + dependencies: + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-body-length-browser@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-body-length-node@4.2.1': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-buffer-from@4.2.0': + dependencies: + '@smithy/is-array-buffer': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-config-provider@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-defaults-mode-browser@4.3.29': + dependencies: + '@smithy/property-provider': 4.2.8 + '@smithy/smithy-client': 4.11.2 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-defaults-mode-node@4.2.32': + dependencies: + '@smithy/config-resolver': 4.4.6 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/property-provider': 4.2.8 + '@smithy/smithy-client': 4.11.2 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-endpoints@3.2.8': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-hex-encoding@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-middleware@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-retry@4.2.8': + dependencies: + '@smithy/service-error-classification': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-stream@4.5.11': + dependencies: + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/node-http-handler': 4.4.9 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-uri-escape@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@4.2.0': + dependencies: + '@smithy/util-buffer-from': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-waiter@4.2.8': + dependencies: + '@smithy/abort-controller': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/uuid@1.1.0': + dependencies: + tslib: 2.8.1 + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.15': @@ -4221,6 +5465,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + bowser@2.13.1: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -4898,6 +6144,10 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-xml-parser@5.3.4: + dependencies: + strnum: 2.1.2 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -6422,6 +7672,8 @@ snapshots: strip-json-comments@3.1.1: {} + strnum@2.1.2: {} + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 diff --git a/scripts/openreader-entrypoint.mjs b/scripts/openreader-entrypoint.mjs new file mode 100644 index 0000000..a39df05 --- /dev/null +++ b/scripts/openreader-entrypoint.mjs @@ -0,0 +1,367 @@ +#!/usr/bin/env node +import { spawn, spawnSync } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { once } from 'node:events'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import { setTimeout as delay } from 'node:timers/promises'; +import * as dotenv from 'dotenv'; + +function loadEnvFiles() { + const cwd = process.cwd(); + const envPath = path.join(cwd, '.env'); + const envLocalPath = path.join(cwd, '.env.local'); + + if (fs.existsSync(envPath)) { + dotenv.config({ path: envPath }); + } + if (fs.existsSync(envLocalPath)) { + dotenv.config({ path: envLocalPath, override: true }); + } +} + +function isTrue(value, defaultValue) { + if (value == null || value.trim() === '') return defaultValue; + const normalized = value.trim().toLowerCase(); + return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; +} + +function withDefault(value, fallback) { + return value && value.trim() ? value.trim() : fallback; +} + +function isPrivateIPv4(address) { + if (!address) return false; + if (address.startsWith('10.')) return true; + if (address.startsWith('192.168.')) return true; + const m = /^172\.(\d+)\./.exec(address); + if (m) { + const second = Number.parseInt(m[1], 10); + if (second >= 16 && second <= 31) return true; + } + return false; +} + +function detectHostForDefaultEndpoint() { + const interfaces = os.networkInterfaces(); + const ipv4 = []; + + for (const entries of Object.values(interfaces)) { + for (const entry of entries || []) { + if (!entry) continue; + const family = typeof entry.family === 'string' ? entry.family : String(entry.family); + if (family !== 'IPv4') continue; + if (entry.internal) continue; + ipv4.push(entry.address); + } + } + + const privateAddr = ipv4.find(isPrivateIPv4); + if (privateAddr) return privateAddr; + if (ipv4[0]) return ipv4[0]; + return '127.0.0.1'; +} + +function parseS3Endpoint(endpoint) { + let url; + try { + url = new URL(endpoint); + } catch { + throw new Error(`Invalid S3_ENDPOINT: ${endpoint}`); + } + + if (!url.hostname) { + throw new Error(`Invalid S3_ENDPOINT host: ${endpoint}`); + } + + const port = Number.parseInt(url.port || '8333', 10); + if (!Number.isFinite(port) || port < 1 || port > 65535) { + throw new Error(`Invalid S3_ENDPOINT port: ${endpoint}`); + } + + return { + hostname: url.hostname, + port, + normalized: `${url.protocol}//${url.hostname}:${port}`, + }; +} + +function parseUrlHost(urlValue, fieldName) { + let url; + try { + url = new URL(urlValue); + } catch { + throw new Error(`Invalid ${fieldName}: ${urlValue}`); + } + + if (!url.hostname) { + throw new Error(`Invalid ${fieldName} host: ${urlValue}`); + } + + return url.hostname; +} + +function parseCommandFromArgs(argv) { + const marker = argv.indexOf('--'); + if (marker >= 0) return argv.slice(marker + 1); + return argv; +} + +function forwardChildStream(stream, target) { + if (!stream) return () => {}; + const onData = (chunk) => { + target.write(chunk); + }; + stream.on('data', onData); + return () => { + stream.off('data', onData); + }; +} + +function hasWeedBinary() { + const probe = spawnSync('weed', ['version'], { stdio: 'ignore' }); + if (probe.error) return false; + return true; +} + +async function waitForEndpoint(url, timeoutSeconds) { + const waitMs = Math.max(1, timeoutSeconds) * 1000; + const deadline = Date.now() + waitMs; + + while (Date.now() < deadline) { + try { + const res = await fetch(url, { method: 'GET' }); + if (res) return; + } catch { + // retry + } + await delay(1000); + } + + throw new Error(`Embedded weed mini did not become ready at ${url} within ${timeoutSeconds}s.`); +} + +function spawnMainCommand(command, env) { + const [cmd, ...args] = command; + const child = spawn(cmd, args, { + env, + stdio: 'inherit', + shell: process.platform === 'win32', + }); + + const exitPromise = new Promise((resolve) => { + child.on('error', (error) => { + console.error('Failed to launch command:', error); + resolve(1); + }); + + child.on('exit', (code, signal) => { + if (typeof code === 'number') { + resolve(code); + return; + } + if (signal) { + resolve(1); + return; + } + resolve(0); + }); + }); + + return { child, exitPromise }; +} + +function runDbMigrations(env) { + const migrateScript = path.join(process.cwd(), 'drizzle', 'scripts', 'migrate.mjs'); + if (!fs.existsSync(migrateScript)) { + throw new Error(`Could not find migration script at ${migrateScript}`); + } + + console.log('Running database migrations...'); + const migration = spawnSync(process.execPath, [migrateScript], { + env, + stdio: 'inherit', + }); + + if (migration.error) { + throw migration.error; + } + if (typeof migration.status === 'number' && migration.status !== 0) { + throw new Error(`Database migrations failed with exit code ${migration.status}.`); + } +} + +function sendSignal(child, signal, useProcessGroup) { + if (!child) return false; + if (useProcessGroup && process.platform !== 'win32' && typeof child.pid === 'number' && child.pid > 0) { + try { + process.kill(-child.pid, signal); + return true; + } catch { + return false; + } + } + + try { + child.kill(signal); + return true; + } catch { + return false; + } +} + +async function terminateChild(child, signal = 'SIGTERM', graceMs = 3000, useProcessGroup = false) { + if (!child) return; + if (child.exitCode != null) return; + + if (!sendSignal(child, signal, useProcessGroup)) return; + + const exited = await Promise.race([ + once(child, 'exit').then(() => true).catch(() => true), + delay(graceMs).then(() => false), + ]); + + if (exited) return; + + if (!sendSignal(child, 'SIGKILL', useProcessGroup)) return; + + await Promise.race([ + once(child, 'exit').then(() => true).catch(() => true), + delay(1000).then(() => false), + ]); +} + +async function main() { + loadEnvFiles(); + + const command = parseCommandFromArgs(process.argv.slice(2)); + if (command.length === 0) { + console.error('Usage: node scripts/openreader-entrypoint.mjs -- [args]'); + process.exit(2); + } + + const embeddedEnvRaw = process.env.USE_EMBEDDED_WEED_MINI; + let useEmbeddedWeed = isTrue(embeddedEnvRaw, true); + + if (useEmbeddedWeed && !hasWeedBinary()) { + if (embeddedEnvRaw && isTrue(embeddedEnvRaw, true)) { + console.error('USE_EMBEDDED_WEED_MINI=true but `weed` binary is not available in PATH.'); + process.exit(1); + } + useEmbeddedWeed = false; + console.warn('`weed` binary not found; skipping embedded SeaweedFS startup.'); + } + + const runtimeEnv = { ...process.env }; + let weedProc = null; + let weedExitPromise = Promise.resolve(); + let appProc = null; + let shutdownPromise = null; + let stopWeedStdoutForward = () => {}; + let stopWeedStderrForward = () => {}; + let didExit = false; + + const exitOnce = (code) => { + if (didExit) return; + didExit = true; + process.exit(code); + }; + + const shutdown = async (signal = 'SIGTERM') => { + if (shutdownPromise) return shutdownPromise; + shutdownPromise = (async () => { + await Promise.all([ + terminateChild(appProc, signal, 4000), + terminateChild(weedProc, 'SIGTERM', 4000), + ]); + await weedExitPromise; + stopWeedStdoutForward(); + stopWeedStderrForward(); + })(); + return shutdownPromise; + }; + + process.once('SIGINT', () => { + void shutdown('SIGINT').finally(() => exitOnce(130)); + }); + process.once('SIGTERM', () => { + void shutdown('SIGTERM').finally(() => exitOnce(143)); + }); + + try { + const shouldRunDbMigrations = isTrue(runtimeEnv.RUN_DB_MIGRATIONS, true); + if (shouldRunDbMigrations) { + runDbMigrations(runtimeEnv); + } + + if (useEmbeddedWeed) { + runtimeEnv.WEED_MINI_DIR = withDefault(runtimeEnv.WEED_MINI_DIR, 'docstore/seaweedfs'); + runtimeEnv.WEED_MINI_WAIT_SEC = withDefault(runtimeEnv.WEED_MINI_WAIT_SEC, '20'); + runtimeEnv.S3_BUCKET = withDefault(runtimeEnv.S3_BUCKET, 'openreader-documents'); + runtimeEnv.S3_REGION = withDefault(runtimeEnv.S3_REGION, 'us-east-1'); + const configuredBaseUrl = runtimeEnv.BASE_URL?.trim() || ''; + const baseUrlHost = configuredBaseUrl ? parseUrlHost(configuredBaseUrl, 'BASE_URL') : ''; + const configuredS3Endpoint = runtimeEnv.S3_ENDPOINT?.trim() || ''; + const defaultS3Host = baseUrlHost || detectHostForDefaultEndpoint(); + runtimeEnv.S3_ENDPOINT = configuredS3Endpoint || `http://${defaultS3Host}:8333`; + runtimeEnv.S3_FORCE_PATH_STYLE = withDefault(runtimeEnv.S3_FORCE_PATH_STYLE, 'true'); + runtimeEnv.S3_PREFIX = withDefault(runtimeEnv.S3_PREFIX, 'openreader'); + runtimeEnv.S3_ACCESS_KEY_ID = withDefault(runtimeEnv.S3_ACCESS_KEY_ID, randomBytes(16).toString('hex')); + runtimeEnv.S3_SECRET_ACCESS_KEY = withDefault(runtimeEnv.S3_SECRET_ACCESS_KEY, randomBytes(32).toString('hex')); + runtimeEnv.AWS_ACCESS_KEY_ID = runtimeEnv.S3_ACCESS_KEY_ID; + runtimeEnv.AWS_SECRET_ACCESS_KEY = runtimeEnv.S3_SECRET_ACCESS_KEY; + + if (!runtimeEnv.S3_ACCESS_KEY_ID || !runtimeEnv.S3_SECRET_ACCESS_KEY) { + throw new Error('Failed to initialize embedded S3 credentials.'); + } + + fs.mkdirSync(runtimeEnv.WEED_MINI_DIR, { recursive: true }); + + console.log('Starting embedded SeaweedFS weed mini...'); + const weedArgs = ['mini', `-dir=${runtimeEnv.WEED_MINI_DIR}`]; + if (configuredS3Endpoint || baseUrlHost) { + const endpoint = parseS3Endpoint(runtimeEnv.S3_ENDPOINT); + weedArgs.push(`-ip=${endpoint.hostname}`); + weedArgs.push(`-s3.port=${endpoint.port}`); + } + + weedProc = spawn('weed', weedArgs, { + env: runtimeEnv, + stdio: ['ignore', 'pipe', 'pipe'], + }); + stopWeedStdoutForward = forwardChildStream(weedProc.stdout, process.stdout); + stopWeedStderrForward = forwardChildStream(weedProc.stderr, process.stderr); + weedExitPromise = once(weedProc, 'exit').then(() => undefined).catch(() => undefined); + + weedProc.on('exit', (code, signal) => { + if (typeof code === 'number' && code !== 0) { + console.error(`Embedded weed mini exited with code ${code}.`); + return; + } + if (signal) { + console.error(`Embedded weed mini exited due to signal ${signal}.`); + } + }); + + const endpoint = runtimeEnv.S3_ENDPOINT; + const waitSec = Number.parseInt(runtimeEnv.WEED_MINI_WAIT_SEC || '20', 10); + await waitForEndpoint(endpoint, Number.isFinite(waitSec) ? waitSec : 20); + console.log(`Embedded SeaweedFS is ready at ${endpoint}`); + } + + const { child, exitPromise } = spawnMainCommand(command, runtimeEnv); + appProc = child; + const exitCode = await exitPromise; + + await shutdown('SIGTERM'); + exitOnce(exitCode); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + await shutdown('SIGTERM'); + exitOnce(1); + } +} + +await main(); diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index 4da59ee..f52924d 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -2,34 +2,18 @@ import { NextRequest, NextResponse } from 'next/server'; import { createReadStream, existsSync } from 'fs'; import { readdir, unlink } from 'fs/promises'; import { join } from 'path'; -import { AUDIOBOOKS_V1_DIR, getUserAudiobookDir, ensureAudiobooksV1Ready, isAudiobooksV1Ready } from '@/lib/server/docstore'; +import { getAudiobooksRootDir, ensureAudiobooksV1Ready, isAudiobooksV1Ready } from '@/lib/server/docstore'; import { findStoredChapterByIndex } from '@/lib/server/audiobook'; import { db } from '@/db'; import { audiobooks, audiobookChapters } from '@/db/schema'; import { and, eq, inArray } from 'drizzle-orm'; import { requireAuthContext } from '@/lib/server/auth'; import { ensureDbIndexed } from '@/lib/server/db-indexing'; -import { applyOpenReaderTestNamespacePath, getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; import { pruneAudiobookChapterIfMissingFile, pruneAudiobookIfMissingDir } from '@/lib/server/audiobook-prune'; export const dynamic = 'force-dynamic'; -/** - * Get the base audiobooks directory, accounting for test namespaces. - * When auth is disabled, returns AUDIOBOOKS_V1_DIR. - * When auth is enabled, returns the user-specific directory. - */ -function getAudiobooksRootDir(request: NextRequest, userId: string | null, authEnabled: boolean): string { - const namespace = getOpenReaderTestNamespace(request.headers); - - if (!authEnabled || !userId) { - return applyOpenReaderTestNamespacePath(AUDIOBOOKS_V1_DIR, namespace); - } - - const userDir = getUserAudiobookDir(userId); - return applyOpenReaderTestNamespacePath(userDir, namespace); -} - export async function GET(request: NextRequest) { try { const bookId = request.nextUrl.searchParams.get('bookId'); @@ -77,7 +61,14 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Book not found' }, { status: 404 }); } - const intermediateDir = join(getAudiobooksRootDir(request, existingBook.userId, authEnabled), `${bookId}-audiobook`); + const intermediateDir = join( + getAudiobooksRootDir({ + userId: existingBook.userId, + authEnabled, + namespace: testNamespace, + }), + `${bookId}-audiobook`, + ); const dirExists = existsSync(intermediateDir); if (!dirExists) { await pruneAudiobookIfMissingDir(bookId, existingBook.userId, false); @@ -185,7 +176,14 @@ export async function DELETE(request: NextRequest) { ), ); - const intermediateDir = join(getAudiobooksRootDir(request, storageUserId, authEnabled), `${bookId}-audiobook`); + const intermediateDir = join( + getAudiobooksRootDir({ + userId: storageUserId, + authEnabled, + namespace: testNamespace, + }), + `${bookId}-audiobook`, + ); const chapterPrefix = `${String(chapterIndex + 1).padStart(4, '0')}__`; const files = await readdir(intermediateDir).catch(() => []); for (const file of files) { diff --git a/src/app/api/audiobook/route.ts b/src/app/api/audiobook/route.ts index 254f704..7ed21d1 100644 --- a/src/app/api/audiobook/route.ts +++ b/src/app/api/audiobook/route.ts @@ -4,7 +4,7 @@ import { readFile, writeFile, mkdir, unlink, rm, rename, readdir } from 'fs/prom import { existsSync, createReadStream } from 'fs'; import { basename, join } from 'path'; import { randomUUID } from 'crypto'; -import { AUDIOBOOKS_V1_DIR, ensureAudiobooksV1Ready, isAudiobooksV1Ready, getUserAudiobookDir } from '@/lib/server/docstore'; +import { ensureAudiobooksV1Ready, isAudiobooksV1Ready, getAudiobooksRootDir } from '@/lib/server/docstore'; import { encodeChapterFileName, encodeChapterTitleTag, listStoredChapters, ffprobeAudio, escapeFFMetadata } from '@/lib/server/audiobook'; import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; @@ -13,35 +13,11 @@ import { audiobooks, audiobookChapters } from '@/db/schema'; import { eq, and, inArray } from 'drizzle-orm'; import { requireAuthContext } from '@/lib/server/auth'; import { ensureDbIndexed } from '@/lib/server/db-indexing'; -import { applyOpenReaderTestNamespacePath, getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; import { pruneAudiobookIfMissingDir } from '@/lib/server/audiobook-prune'; export const dynamic = 'force-dynamic'; -/** - * Apply test namespace to a directory path if present in request headers. - */ -function applyTestNamespace(baseDir: string, request: NextRequest): string { - const namespace = getOpenReaderTestNamespace(request.headers); - return applyOpenReaderTestNamespacePath(baseDir, namespace); -} - -/** - * Get the base audiobooks directory, accounting for test namespaces. - * When auth is disabled, returns AUDIOBOOKS_V1_DIR (possibly with test namespace). - * When auth is enabled, returns the user-specific directory under AUDIOBOOKS_USERS_DIR. - */ -function getAudiobooksRootDir(request: NextRequest, userId: string | null, authEnabled: boolean): string { - // When auth is disabled, use the flat audiobooks_v1 directory - if (!authEnabled || !userId) { - return applyTestNamespace(AUDIOBOOKS_V1_DIR, request); - } - - // When auth is enabled, use user-specific directory - const userDir = getUserAudiobookDir(userId); - return applyTestNamespace(userDir, request); -} - interface ConversionRequest { chapterTitle: string; buffer: TTSAudioBytes; @@ -207,7 +183,14 @@ export async function POST(request: NextRequest) { }) .onConflictDoNothing(); - const intermediateDir = join(getAudiobooksRootDir(request, userId, ctxOrRes.authEnabled), `${bookId}-audiobook`); + const intermediateDir = join( + getAudiobooksRootDir({ + userId, + authEnabled: ctxOrRes.authEnabled, + namespace: testNamespace, + }), + `${bookId}-audiobook`, + ); // Create intermediate directory await mkdir(intermediateDir, { recursive: true }); @@ -434,7 +417,11 @@ export async function GET(request: NextRequest) { } const intermediateDir = join( - getAudiobooksRootDir(request, existingBook.userId, authEnabled), + getAudiobooksRootDir({ + userId: existingBook.userId, + authEnabled, + namespace: testNamespace, + }), `${bookId}-audiobook`, ); @@ -657,7 +644,14 @@ export async function DELETE(request: NextRequest) { await db.delete(audiobooks).where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId))); - const intermediateDir = join(getAudiobooksRootDir(request, userId, authEnabled), `${bookId}-audiobook`); + const intermediateDir = join( + getAudiobooksRootDir({ + userId, + authEnabled, + namespace: testNamespace, + }), + `${bookId}-audiobook`, + ); // If directory doesn't exist, consider it already reset if (!existsSync(intermediateDir)) { diff --git a/src/app/api/audiobook/status/route.ts b/src/app/api/audiobook/status/route.ts index 78d558a..1f502ca 100644 --- a/src/app/api/audiobook/status/route.ts +++ b/src/app/api/audiobook/status/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { existsSync } from 'fs'; import { join } from 'path'; -import { AUDIOBOOKS_V1_DIR, getUserAudiobookDir, ensureAudiobooksV1Ready, isAudiobooksV1Ready } from '@/lib/server/docstore'; +import { getAudiobooksRootDir, ensureAudiobooksV1Ready, isAudiobooksV1Ready } from '@/lib/server/docstore'; import { listStoredChapters } from '@/lib/server/audiobook'; import type { AudiobookGenerationSettings } from '@/types/client'; import type { TTSAudiobookFormat, TTSAudiobookChapter } from '@/types/tts'; @@ -11,27 +11,11 @@ import { db } from '@/db'; import { audiobooks } from '@/db/schema'; import { eq, and, inArray } from 'drizzle-orm'; import { ensureDbIndexed } from '@/lib/server/db-indexing'; -import { applyOpenReaderTestNamespacePath, getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; import { pruneAudiobookChaptersNotOnDisk, pruneAudiobookIfMissingDir } from '@/lib/server/audiobook-prune'; export const dynamic = 'force-dynamic'; -/** - * Get the base audiobooks directory, accounting for test namespaces. - * When auth is disabled, returns AUDIOBOOKS_V1_DIR. - * When auth is enabled, returns the user-specific directory. - */ -function getAudiobooksRootDir(request: NextRequest, userId: string | null, authEnabled: boolean): string { - const namespace = getOpenReaderTestNamespace(request.headers); - - if (!authEnabled || !userId) { - return applyOpenReaderTestNamespacePath(AUDIOBOOKS_V1_DIR, namespace); - } - - const userDir = getUserAudiobookDir(userId); - return applyOpenReaderTestNamespacePath(userDir, namespace); -} - const SAFE_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; function isSafeId(value: string): boolean { @@ -80,7 +64,14 @@ export async function GET(request: NextRequest) { }); } - const intermediateDir = join(getAudiobooksRootDir(request, existingBook.userId, authEnabled), `${bookId}-audiobook`); + const intermediateDir = join( + getAudiobooksRootDir({ + userId: existingBook.userId, + authEnabled, + namespace: testNamespace, + }), + `${bookId}-audiobook`, + ); if (!existsSync(intermediateDir)) { await pruneAudiobookIfMissingDir(bookId, existingBook.userId, false); diff --git a/src/app/api/documents/content/route.ts b/src/app/api/documents/blob/route.ts similarity index 55% rename from src/app/api/documents/content/route.ts rename to src/app/api/documents/blob/route.ts index ed6c0fe..04c183d 100644 --- a/src/app/api/documents/content/route.ts +++ b/src/app/api/documents/blob/route.ts @@ -1,16 +1,13 @@ -import { open, readFile } from 'fs/promises'; -import path from 'path'; import { NextRequest, NextResponse } from 'next/server'; import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; import { documents } from '@/db/schema'; -import { ensureDocumentsV1Ready, isDocumentsV1Ready, DOCUMENTS_V1_DIR } from '@/lib/server/docstore'; import { requireAuthContext } from '@/lib/server/auth'; -import { ensureDbIndexed } from '@/lib/server/db-indexing'; import { contentTypeForName } from '@/lib/server/library'; import { extractRawTextSnippet } from '@/lib/text-snippets'; -import { isEnoent } from '@/lib/server/documents-utils'; -import { applyOpenReaderTestNamespacePath, getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; +import { getDocumentBlob, getDocumentRange, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents-blobstore'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; +import { isS3Configured } from '@/lib/server/s3'; export const dynamic = 'force-dynamic'; @@ -19,67 +16,55 @@ function clampInt(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, Math.trunc(value))); } -async function readHeadBuffer(filePath: string, maxBytes: number): Promise { - const handle = await open(filePath, 'r'); - try { - const buf = Buffer.allocUnsafe(maxBytes); - const result = await handle.read(buf, 0, maxBytes, 0); - return buf.subarray(0, result.bytesRead); - } finally { - await handle.close(); - } +function s3NotConfiguredResponse(): NextResponse { + return NextResponse.json( + { error: 'Documents storage is not configured. Set S3_* environment variables.' }, + { status: 503 }, + ); } export async function GET(req: NextRequest) { try { - await ensureDocumentsV1Ready(); - if (!(await isDocumentsV1Ready())) { - return NextResponse.json( - { error: 'Documents storage is not migrated; run /api/migrations/v1 first.' }, - { status: 409 }, - ); - } + if (!isS3Configured()) return s3NotConfiguredResponse(); const ctxOrRes = await requireAuthContext(req); if (ctxOrRes instanceof Response) return ctxOrRes; const testNamespace = getOpenReaderTestNamespace(req.headers); - const documentsDir = applyOpenReaderTestNamespacePath(DOCUMENTS_V1_DIR, testNamespace); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const storageUserId = ctxOrRes.userId ?? unclaimedUserId; const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; - await ensureDbIndexed(); - const url = new URL(req.url); - const id = url.searchParams.get('id'); + const id = (url.searchParams.get('id') || '').trim().toLowerCase(); const format = (url.searchParams.get('format') || '').toLowerCase().trim(); - if (!id) { - return NextResponse.json({ error: 'Missing id' }, { status: 400 }); + if (!isValidDocumentId(id)) { + return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); } - const docs = await db + const rows = (await db .select({ id: documents.id, userId: documents.userId, name: documents.name, filePath: documents.filePath }) .from(documents) - .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds))); + .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{ + id: string; + userId: string; + name: string; + filePath: string; + }>; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const doc = docs.find((d: any) => d.userId === storageUserId) ?? docs[0]; - - if (!doc?.filePath) { + const doc = rows.find((row) => row.userId === storageUserId) ?? rows[0]; + if (!doc) { return NextResponse.json({ error: 'Not found' }, { status: 404 }); } - const filePath = path.join(documentsDir, doc.filePath); - const filename = doc.name || doc.filePath; + const filename = doc.name || `${id}.bin`; + const responseType = contentTypeForName(filename); if (format === 'snippet') { const maxChars = clampInt(Number.parseInt(url.searchParams.get('maxChars') || '1600', 10), 100, 8000); const maxBytes = clampInt(Number.parseInt(url.searchParams.get('maxBytes') || '131072', 10), 4096, 1024 * 1024); - try { - const head = await readHeadBuffer(filePath, maxBytes); + const head = await getDocumentRange(id, 0, maxBytes - 1, testNamespace); const decoded = new TextDecoder().decode(new Uint8Array(head)); const snippet = extractRawTextSnippet(decoded, maxChars); return NextResponse.json( @@ -87,7 +72,7 @@ export async function GET(req: NextRequest) { { headers: { 'Cache-Control': 'no-store' } }, ); } catch (error) { - if (isEnoent(error)) { + if (isMissingBlobError(error)) { await db .delete(documents) .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds))); @@ -97,14 +82,22 @@ export async function GET(req: NextRequest) { } } - let content: ArrayBuffer; try { - const buf = await readFile(filePath); - content = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); + const content = await getDocumentBlob(id, testNamespace); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(content)); + controller.close(); + }, + }); + return new NextResponse(stream, { + headers: { + 'Content-Type': responseType, + 'Cache-Control': 'no-store', + }, + }); } catch (error) { - if (isEnoent(error)) { - // The DB can become stale if a file is deleted manually from the docstore. - // Prune rows so the client stops showing ghost documents. + if (isMissingBlobError(error)) { await db .delete(documents) .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds))); @@ -112,13 +105,6 @@ export async function GET(req: NextRequest) { } throw error; } - - return new NextResponse(content, { - headers: { - 'Content-Type': contentTypeForName(filename), - 'Cache-Control': 'no-store', - }, - }); } catch (error) { console.error('Error loading document content:', error); return NextResponse.json({ error: 'Failed to load document content' }, { status: 500 }); diff --git a/src/app/api/documents/blob/upload/fallback/route.ts b/src/app/api/documents/blob/upload/fallback/route.ts new file mode 100644 index 0000000..16ada73 --- /dev/null +++ b/src/app/api/documents/blob/upload/fallback/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuthContext } from '@/lib/server/auth'; +import { isValidDocumentId, putDocumentBlob } from '@/lib/server/documents-blobstore'; +import { isS3Configured } from '@/lib/server/s3'; +import { getOpenReaderTestNamespace } from '@/lib/server/test-namespace'; + +export const dynamic = 'force-dynamic'; + +function isPreconditionFailed(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const maybe = error as { name?: string; $metadata?: { httpStatusCode?: number } }; + return maybe.$metadata?.httpStatusCode === 412 || maybe.name === 'PreconditionFailed'; +} + +export async function PUT(req: NextRequest) { + try { + if (!isS3Configured()) { + return NextResponse.json( + { error: 'Documents storage is not configured. Set S3_* environment variables.' }, + { status: 503 }, + ); + } + + const ctxOrRes = await requireAuthContext(req); + if (ctxOrRes instanceof Response) return ctxOrRes; + + const url = new URL(req.url); + const id = (url.searchParams.get('id') || '').trim().toLowerCase(); + if (!isValidDocumentId(id)) { + return NextResponse.json({ error: 'Invalid document id' }, { status: 400 }); + } + + const contentType = (req.headers.get('content-type') || 'application/octet-stream').trim() || 'application/octet-stream'; + const body = Buffer.from(await req.arrayBuffer()); + const namespace = getOpenReaderTestNamespace(req.headers); + + try { + await putDocumentBlob(id, body, contentType, namespace); + } catch (error) { + if (!isPreconditionFailed(error)) { + throw error; + } + } + + return NextResponse.json({ success: true, id }); + } catch (error) { + console.error('Error proxy-uploading document blob:', error); + return NextResponse.json({ error: 'Failed to upload document blob' }, { status: 500 }); + } +} + diff --git a/src/app/api/documents/blob/upload/presign/route.ts b/src/app/api/documents/blob/upload/presign/route.ts new file mode 100644 index 0000000..c1c803a --- /dev/null +++ b/src/app/api/documents/blob/upload/presign/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuthContext } from '@/lib/server/auth'; +import { isValidDocumentId, presignPut } from '@/lib/server/documents-blobstore'; +import { getOpenReaderTestNamespace } from '@/lib/server/test-namespace'; +import { isS3Configured } from '@/lib/server/s3'; + +export const dynamic = 'force-dynamic'; + +type PresignUpload = { + id: string; + contentType: string; + size: number; +}; + +function parseUploads(body: unknown): PresignUpload[] { + if (!body || typeof body !== 'object') return []; + const rawUploads = (body as { uploads?: unknown }).uploads; + if (!Array.isArray(rawUploads)) return []; + + const uploads: PresignUpload[] = []; + for (const raw of rawUploads) { + if (!raw || typeof raw !== 'object') continue; + const rec = raw as Record; + const id = typeof rec.id === 'string' ? rec.id.trim().toLowerCase() : ''; + if (!isValidDocumentId(id)) continue; + const contentType = + typeof rec.contentType === 'string' && rec.contentType.trim() + ? rec.contentType.trim() + : 'application/octet-stream'; + const size = Number.isFinite(rec.size) && Number(rec.size) >= 0 ? Number(rec.size) : 0; + uploads.push({ id, contentType, size }); + } + return uploads; +} + +export async function POST(req: NextRequest) { + try { + if (!isS3Configured()) { + return NextResponse.json( + { error: 'Documents storage is not configured. Set S3_* environment variables.' }, + { status: 503 }, + ); + } + + const ctxOrRes = await requireAuthContext(req); + if (ctxOrRes instanceof Response) return ctxOrRes; + + const body = await req.json().catch(() => null); + const uploads = parseUploads(body); + if (uploads.length === 0) { + return NextResponse.json({ error: 'No valid uploads provided' }, { status: 400 }); + } + + const namespace = getOpenReaderTestNamespace(req.headers); + const signed = await Promise.all( + uploads.map(async (upload) => { + const res = await presignPut(upload.id, upload.contentType, namespace); + return { + id: upload.id, + url: res.url, + headers: res.headers, + }; + }), + ); + + return NextResponse.json({ uploads: signed }); + } catch (error) { + console.error('Error creating document upload signatures:', error); + return NextResponse.json({ error: 'Failed to presign uploads' }, { status: 500 }); + } +} + diff --git a/src/app/api/documents/docx-to-pdf/upload/route.ts b/src/app/api/documents/docx-to-pdf/upload/route.ts index 2075b90..0b4a3bd 100644 --- a/src/app/api/documents/docx-to-pdf/upload/route.ts +++ b/src/app/api/documents/docx-to-pdf/upload/route.ts @@ -6,11 +6,12 @@ import { existsSync } from 'fs'; import { randomUUID, createHash } from 'crypto'; import { pathToFileURL } from 'url'; import { requireAuthContext } from '@/lib/server/auth'; -import { ensureDocumentsV1Ready, isDocumentsV1Ready, DOCUMENTS_V1_DIR } from '@/lib/server/docstore'; import { db } from '@/db'; import { documents } from '@/db/schema'; -import { safeDocumentName, trySetFileMtime } from '@/lib/server/documents-utils'; -import { applyOpenReaderTestNamespacePath, getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; +import { safeDocumentName } from '@/lib/server/documents-utils'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; +import { isS3Configured } from '@/lib/server/s3'; +import { putDocumentBlob } from '@/lib/server/documents-blobstore'; const DOCSTORE_DIR = path.join(process.cwd(), 'docstore'); const TEMP_DIR = path.join(DOCSTORE_DIR, 'tmp'); @@ -66,18 +67,15 @@ async function waitForPdfReady(dir: string, timeoutMs = 20000, intervalMs = 100) export async function POST(req: NextRequest) { try { - await ensureDocumentsV1Ready(); - if (!(await isDocumentsV1Ready())) { + if (!isS3Configured()) { return NextResponse.json( - { error: 'Documents storage is not migrated; run /api/migrations/v1 first.' }, - { status: 409 }, + { error: 'Documents storage is not configured. Set S3_* environment variables.' }, + { status: 503 }, ); } const testNamespace = getOpenReaderTestNamespace(req.headers); - const documentsDir = applyOpenReaderTestNamespacePath(DOCUMENTS_V1_DIR, testNamespace); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - await mkdir(documentsDir, { recursive: true }); const ctxOrRes = await requireAuthContext(req); if (ctxOrRes instanceof Response) return ctxOrRes; @@ -96,7 +94,7 @@ export async function POST(req: NextRequest) { } const docxBytes = Buffer.from(await file.arrayBuffer()); - // IMPORTANT: use sha of the source DOCX bytes for a stable ID across conversions. + // Keep stable IDs tied to source bytes. const id = createHash('sha256').update(docxBytes).digest('hex'); const tempId = randomUUID(); @@ -113,18 +111,19 @@ export async function POST(req: NextRequest) { const pdfPath = await waitForPdfReady(jobDir); const pdfContent = await readFile(pdfPath); - const derivedName = safeDocumentName(`${path.parse(file.name).name}.pdf`, `${id}.pdf`); - const targetFileName = `${id}__${encodeURIComponent(derivedName)}`; - const targetPath = path.join(documentsDir, targetFileName); - try { - await stat(targetPath); - } catch { - await writeFile(targetPath, pdfContent); + await putDocumentBlob(id, pdfContent, 'application/pdf', testNamespace); + } catch (error) { + // Idempotent behavior: if blob already exists for this sha, continue. + const maybe = error as { name?: string; $metadata?: { httpStatusCode?: number } } | undefined; + const isPreconditionFailed = maybe?.$metadata?.httpStatusCode === 412 || maybe?.name === 'PreconditionFailed'; + if (!isPreconditionFailed) { + throw error; + } } + const derivedName = safeDocumentName(`${path.parse(file.name).name}.pdf`, `${id}.pdf`); const lastModified = Number.isFinite(file.lastModified) ? file.lastModified : Date.now(); - await trySetFileMtime(targetPath, lastModified); await db .insert(documents) @@ -135,9 +134,18 @@ export async function POST(req: NextRequest) { type: 'pdf', size: pdfContent.length, lastModified, - filePath: targetFileName, + filePath: id, }) - .onConflictDoNothing(); + .onConflictDoUpdate({ + target: [documents.id, documents.userId], + set: { + name: derivedName, + type: 'pdf', + size: pdfContent.length, + lastModified, + filePath: id, + }, + }); return NextResponse.json({ stored: { diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 5d3d7ef..023afdf 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -1,212 +1,207 @@ -import { createHash } from 'crypto'; -import { mkdir, readFile, stat, unlink, writeFile } from 'fs/promises'; -import { existsSync } from 'fs'; import { NextRequest, NextResponse } from 'next/server'; -import path from 'path'; -import { DOCUMENTS_V1_DIR, ensureDocumentsV1Ready, isDocumentsV1Ready } from '@/lib/server/docstore'; -import type { BaseDocument, DocumentType, SyncedDocument } from '@/types/documents'; +import { and, count, eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; import { documents } from '@/db/schema'; -import { eq, and, inArray, count } from 'drizzle-orm'; import { requireAuthContext } from '@/lib/server/auth'; -import { ensureDbIndexed } from '@/lib/server/db-indexing'; -import { isEnoent, toDocumentTypeFromName, trySetFileMtime } from '@/lib/server/documents-utils'; -import { applyOpenReaderTestNamespacePath, getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; +import { safeDocumentName, toDocumentTypeFromName } from '@/lib/server/documents-utils'; +import { deleteDocumentBlob, headDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents-blobstore'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; +import { isS3Configured } from '@/lib/server/s3'; +import type { BaseDocument, DocumentType } from '@/types/documents'; export const dynamic = 'force-dynamic'; +type RegisterDocument = { + id: string; + name: string; + type: DocumentType; + size: number; + lastModified: number; +}; + +function s3NotConfiguredResponse(): NextResponse { + return NextResponse.json( + { error: 'Documents storage is not configured. Set S3_* environment variables.' }, + { status: 503 }, + ); +} + +function normalizeDocumentType(rawType: unknown, safeName: string): DocumentType { + if (rawType === 'pdf' || rawType === 'epub' || rawType === 'docx' || rawType === 'html') { + return rawType; + } + return toDocumentTypeFromName(safeName); +} + +function normalizeLastModified(value: unknown): number { + return Number.isFinite(value) && Number(value) > 0 ? Number(value) : Date.now(); +} + +function parseDocumentPayload(body: unknown): RegisterDocument[] { + if (!body || typeof body !== 'object') return []; + const rawDocs = (body as { documents?: unknown }).documents; + if (!Array.isArray(rawDocs)) return []; + + const docs: RegisterDocument[] = []; + for (const rawDoc of rawDocs) { + if (!rawDoc || typeof rawDoc !== 'object') continue; + const rec = rawDoc as Record; + const id = typeof rec.id === 'string' ? rec.id.trim().toLowerCase() : ''; + if (!isValidDocumentId(id)) continue; + const fallbackName = `${id}.${typeof rec.type === 'string' ? rec.type : 'txt'}`; + const name = safeDocumentName(typeof rec.name === 'string' ? rec.name : '', fallbackName); + const type = normalizeDocumentType(rec.type, name); + const lastModified = normalizeLastModified(rec.lastModified); + const size = Number.isFinite(rec.size) && Number(rec.size) >= 0 ? Number(rec.size) : 0; + docs.push({ id, name, type, size, lastModified }); + } + return docs; +} + export async function POST(req: NextRequest) { try { - await ensureDocumentsV1Ready(); - if (!(await isDocumentsV1Ready())) { - return NextResponse.json( - { error: 'Documents storage is not migrated; run /api/migrations/v1 first.' }, - { status: 409 }, - ); - } - - const testNamespace = getOpenReaderTestNamespace(req.headers); - const syncDir = applyOpenReaderTestNamespacePath(DOCUMENTS_V1_DIR, testNamespace); - const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - await mkdir(syncDir, { recursive: true }); + if (!isS3Configured()) return s3NotConfiguredResponse(); const ctxOrRes = await requireAuthContext(req); if (ctxOrRes instanceof Response) return ctxOrRes; + + const testNamespace = getOpenReaderTestNamespace(req.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const storageUserId = ctxOrRes.userId ?? unclaimedUserId; - const data = await req.json(); - const documentsData = data.documents as SyncedDocument[]; - const stored: Array<{ oldId: string; id: string; name: string }> = []; + const body = await req.json().catch(() => null); + const documentsData = parseDocumentPayload(body); + if (documentsData.length === 0) { + return NextResponse.json({ error: 'No valid documents provided' }, { status: 400 }); + } - // Ensure directory exists (redundant with isDocumentsV1Ready but safe) - // const SYNC_DIR = DOCUMENTS_V1_DIR; + const stored: BaseDocument[] = []; for (const doc of documentsData) { - const content = Buffer.from(new Uint8Array(doc.data)); - const id = createHash('sha256').update(content).digest('hex'); - - const baseName = path.basename(doc.name || `${id}.${doc.type}`); - const safeName = baseName.replaceAll('\u0000', '').slice(0, 240) || `${id}.${doc.type}`; - - const targetFileName = `${id}__${encodeURIComponent(safeName)}`; - const targetPath = path.join(syncDir, targetFileName); - - // Write file if not exists + let headSize = doc.size; try { - await stat(targetPath); - } catch { - await writeFile(targetPath, content); + const head = await headDocumentBlob(doc.id, testNamespace); + if (head.contentLength > 0) headSize = head.contentLength; + } catch (error) { + if (isMissingBlobError(error)) { + return NextResponse.json( + { + error: `Blob missing for document ${doc.id}. Upload bytes first using /api/documents/blob/upload/presign.`, + }, + { status: 409 }, + ); + } + throw error; } - await trySetFileMtime(targetPath, doc.lastModified); - // DB Upsert - // With composite PK (id, userId), we check if THIS user already has this document await db .insert(documents) .values({ - id, + id: doc.id, userId: storageUserId, - name: safeName, + name: doc.name, type: doc.type, - size: content.length, + size: headSize, lastModified: doc.lastModified, - filePath: targetFileName, + filePath: doc.id, }) - .onConflictDoNothing(); + .onConflictDoUpdate({ + target: [documents.id, documents.userId], + set: { + name: doc.name, + type: doc.type, + size: headSize, + lastModified: doc.lastModified, + filePath: doc.id, + }, + }); - stored.push({ oldId: doc.id, id, name: safeName }); + stored.push({ + id: doc.id, + name: doc.name, + type: doc.type, + size: headSize, + lastModified: doc.lastModified, + scope: storageUserId === unclaimedUserId ? 'unclaimed' : 'user', + }); } return NextResponse.json({ success: true, stored }); } catch (error) { - console.error('Error saving documents:', error); - return NextResponse.json({ error: 'Failed to save documents' }, { status: 500 }); + console.error('Error registering documents:', error); + return NextResponse.json({ error: 'Failed to register documents' }, { status: 500 }); } } export async function GET(req: NextRequest) { try { - await ensureDocumentsV1Ready(); - if (!(await isDocumentsV1Ready())) { - return NextResponse.json( - { error: 'Documents storage is not migrated; run /api/migrations/v1 first.' }, - { status: 409 }, - ); - } + if (!isS3Configured()) return s3NotConfiguredResponse(); const ctxOrRes = await requireAuthContext(req); if (ctxOrRes instanceof Response) return ctxOrRes; const testNamespace = getOpenReaderTestNamespace(req.headers); - const syncDir = applyOpenReaderTestNamespacePath(DOCUMENTS_V1_DIR, testNamespace); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const storageUserId = ctxOrRes.userId ?? unclaimedUserId; const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; - await ensureDbIndexed(); - const url = new URL(req.url); - const list = url.searchParams.get('list') === 'true'; - const format = url.searchParams.get('format'); const idsParam = url.searchParams.get('ids'); + const targetIds = idsParam + ? idsParam + .split(',') + .map((id) => id.trim().toLowerCase()) + .filter((id) => isValidDocumentId(id)) + : null; - // If list=true, force metadata only. - // If format=metadata, force metadata only. - // Otherwise include data. - const includeData = !list && format !== 'metadata'; - - const targetIds = idsParam ? idsParam.split(',').filter(Boolean) : null; - - // Query database for documents the user is allowed to access - let allowedDocs: { id: string; userId: string; name: string; type: string; size: number; lastModified: number; filePath: string }[] = []; + if (idsParam && (!targetIds || targetIds.length === 0)) { + return NextResponse.json({ documents: [] }); + } const conditions = [ inArray(documents.userId, allowedUserIds), - ...(targetIds ? [inArray(documents.id, targetIds)] : []), + ...(targetIds && targetIds.length > 0 ? [inArray(documents.id, targetIds)] : []), ]; - const rows = await db.select().from(documents).where(and(...conditions)); - allowedDocs = rows as unknown as { id: string; userId: string; name: string; type: string; size: number; lastModified: number; filePath: string }[]; + const rows = (await db.select().from(documents).where(and(...conditions))) as Array<{ + id: string; + userId: string; + name: string; + type: string; + size: number; + lastModified: number; + filePath: string; + }>; - const results: (BaseDocument | SyncedDocument)[] = []; - - for (const doc of allowedDocs) { - const type: DocumentType = - doc.type === 'pdf' || doc.type === 'epub' || doc.type === 'docx' || doc.type === 'html' - ? (doc.type as DocumentType) - : toDocumentTypeFromName(doc.name); - - // If the underlying file was deleted manually, keep the API self-healing: - // prune the DB row so clients stop listing ghost documents. - const absolutePath = doc.filePath ? path.join(syncDir, doc.filePath) : ''; - if (!absolutePath || !existsSync(absolutePath)) { - await db - .delete(documents) - .where(and(eq(documents.id, doc.id), eq(documents.userId, doc.userId))); - continue; - } - - const metadata: BaseDocument = { - id: doc.id!, + const results: BaseDocument[] = rows.map((doc) => { + const type = normalizeDocumentType(doc.type, doc.name); + return { + id: doc.id, name: doc.name, - size: doc.size, - lastModified: doc.lastModified, + size: Number(doc.size), + lastModified: Number(doc.lastModified), type, scope: doc.userId === unclaimedUserId ? 'unclaimed' : 'user', }; - - if (!includeData) { - results.push(metadata); - continue; - } - - try { - const content = await readFile(absolutePath); - results.push({ - ...metadata, - data: Array.from(new Uint8Array(content)), - }); - } catch (err) { - if (isEnoent(err)) { - await db - .delete(documents) - .where(and(eq(documents.id, doc.id), eq(documents.userId, doc.userId))); - continue; - } - console.warn(`Failed to read content for document ${doc.id} at ${doc.filePath}`, err); - } - } + }); return NextResponse.json({ documents: results }); } catch (error) { - console.error('Error loading documents:', error); + console.error('Error loading document metadata:', error); return NextResponse.json({ error: 'Failed to load documents' }, { status: 500 }); } } - export async function DELETE(req: NextRequest) { try { - await ensureDocumentsV1Ready(); - if (!(await isDocumentsV1Ready())) { - return NextResponse.json( - { error: 'Documents storage is not migrated; run /api/migrations/v1 first.' }, - { status: 409 }, - ); - } + if (!isS3Configured()) return s3NotConfiguredResponse(); - // Auth check - require session const ctxOrRes = await requireAuthContext(req); if (ctxOrRes instanceof Response) return ctxOrRes; const testNamespace = getOpenReaderTestNamespace(req.headers); - const syncDir = applyOpenReaderTestNamespacePath(DOCUMENTS_V1_DIR, testNamespace); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const storageUserId = ctxOrRes.userId ?? unclaimedUserId; - await ensureDbIndexed(); - const url = new URL(req.url); const idsParam = url.searchParams.get('ids'); const scopeParam = (url.searchParams.get('scope') || '').toLowerCase().trim(); @@ -221,7 +216,6 @@ export async function DELETE(req: NextRequest) { ); } - // Deleting the global unclaimed pool is a privileged operation when auth is enabled. if (ctxOrRes.authEnabled && wantsUnclaimed && ctxOrRes.user?.isAnonymous) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } @@ -239,58 +233,45 @@ export async function DELETE(req: NextRequest) { return NextResponse.json({ success: true, deleted: 0 }); } - // Determine which IDs to try to delete let targetIds: string[] = []; - if (idsParam) { - targetIds = idsParam.split(',').filter(Boolean); + targetIds = idsParam + .split(',') + .map((id) => id.trim().toLowerCase()) + .filter((id) => isValidDocumentId(id)); } else { - // Existing behavior was "nuke everything"; keep it scoped to the selected user buckets. - const rows = await db + const rows = (await db .select({ id: documents.id }) .from(documents) - .where(inArray(documents.userId, targetUserIds)); - targetIds = rows.map((d: { id: string | null }) => d.id!).filter(Boolean) as string[]; + .where(inArray(documents.userId, targetUserIds))) as Array<{ id: string }>; + targetIds = rows.map((row) => row.id); } if (targetIds.length === 0) { return NextResponse.json({ success: true, deleted: 0 }); } - const deletedRows: { id: string; filePath: string }[] = []; - - const rows = await db + const deletedRows = (await db .delete(documents) .where(and(inArray(documents.userId, targetUserIds), inArray(documents.id, targetIds))) - .returning({ id: documents.id, filePath: documents.filePath }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - rows.forEach((r: any) => deletedRows.push({ id: r.id!, filePath: r.filePath })); + .returning({ id: documents.id })) as Array<{ id: string }>; - // If driver doesn't support returning (e.g. older SQLite without properly configured returning), we might fallback. - // But Drizzle usually handles this. + const uniqueIds = Array.from(new Set(deletedRows.map((row) => row.id))); + for (const id of uniqueIds) { + const [ref] = await db.select({ count: count() }).from(documents).where(eq(documents.id, id)); + const refCount = Number(ref?.count ?? 0); + if (refCount > 0) continue; - let deletedCount = 0; - - for (const row of deletedRows) { - deletedCount++; - // Chech reference count for this ID - // If 0 remaining, delete file - let refCount = 0; - const [ref] = await db.select({ count: count() }).from(documents).where(eq(documents.id, row.id!)); - refCount = Number(ref?.count ?? 0); - - if (refCount === 0) { - const filePath = path.join(syncDir, row.filePath); - try { - await unlink(filePath); - } catch { - // Ignore if missing + try { + await deleteDocumentBlob(id, testNamespace); + } catch (error) { + if (!isMissingBlobError(error)) { + throw error; } } } - return NextResponse.json({ success: true, deleted: deletedCount }); - + return NextResponse.json({ success: true, deleted: deletedRows.length }); } catch (error) { console.error('Error deleting documents:', error); return NextResponse.json({ error: 'Failed to delete documents' }, { status: 500 }); diff --git a/src/app/api/documents/upload/route.ts b/src/app/api/documents/upload/route.ts deleted file mode 100644 index 4491a33..0000000 --- a/src/app/api/documents/upload/route.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { createHash } from 'crypto'; -import { mkdir, stat, writeFile } from 'fs/promises'; -import path from 'path'; -import { NextRequest, NextResponse } from 'next/server'; -import { db } from '@/db'; -import { documents } from '@/db/schema'; -import { ensureDocumentsV1Ready, isDocumentsV1Ready, DOCUMENTS_V1_DIR } from '@/lib/server/docstore'; -import { requireAuthContext } from '@/lib/server/auth'; -import { safeDocumentName, toDocumentTypeFromName, trySetFileMtime } from '@/lib/server/documents-utils'; -import { applyOpenReaderTestNamespacePath, getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; - -export const dynamic = 'force-dynamic'; - -export async function POST(req: NextRequest) { - try { - await ensureDocumentsV1Ready(); - if (!(await isDocumentsV1Ready())) { - return NextResponse.json( - { error: 'Documents storage is not migrated; run /api/migrations/v1 first.' }, - { status: 409 }, - ); - } - - const testNamespace = getOpenReaderTestNamespace(req.headers); - const documentsDir = applyOpenReaderTestNamespacePath(DOCUMENTS_V1_DIR, testNamespace); - const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - await mkdir(documentsDir, { recursive: true }); - - const ctxOrRes = await requireAuthContext(req); - if (ctxOrRes instanceof Response) return ctxOrRes; - const storageUserId = ctxOrRes.userId ?? unclaimedUserId; - - const form = await req.formData(); - const files = form.getAll('files').filter((value): value is File => value instanceof File); - - if (files.length === 0) { - return NextResponse.json({ error: 'Missing files' }, { status: 400 }); - } - - const stored: Array<{ - id: string; - name: string; - type: 'pdf' | 'epub' | 'docx' | 'html'; - size: number; - lastModified: number; - }> = []; - - for (const file of files) { - const arrayBuffer = await file.arrayBuffer(); - const content = Buffer.from(new Uint8Array(arrayBuffer)); - const id = createHash('sha256').update(content).digest('hex'); - - const safeName = safeDocumentName(file.name, `${id}.${toDocumentTypeFromName(file.name)}`); - const targetFileName = `${id}__${encodeURIComponent(safeName)}`; - const targetPath = path.join(documentsDir, targetFileName); - - try { - await stat(targetPath); - } catch { - await writeFile(targetPath, content); - } - - const lastModified = Number.isFinite(file.lastModified) ? file.lastModified : Date.now(); - await trySetFileMtime(targetPath, lastModified); - - const type = toDocumentTypeFromName(safeName); - - await db - .insert(documents) - .values({ - id, - userId: storageUserId, - name: safeName, - type, - size: content.length, - lastModified, - filePath: targetFileName, - }) - .onConflictDoNothing(); - - stored.push({ - id, - name: safeName, - type, - size: content.length, - lastModified, - }); - } - - return NextResponse.json({ stored }); - } catch (error) { - console.error('Error uploading documents:', error); - return NextResponse.json({ error: 'Failed to upload documents' }, { status: 500 }); - } -} diff --git a/src/app/api/migrations/v2/route.ts b/src/app/api/migrations/v2/route.ts new file mode 100644 index 0000000..d289f03 --- /dev/null +++ b/src/app/api/migrations/v2/route.ts @@ -0,0 +1,275 @@ +import { createHash } from 'crypto'; +import { existsSync } from 'fs'; +import { readdir, readFile, stat, unlink } from 'fs/promises'; +import path from 'path'; +import { and, eq } from 'drizzle-orm'; +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/db'; +import { documents } from '@/db/schema'; +import { auth } from '@/lib/server/auth'; +import { DOCUMENTS_V1_DIR } from '@/lib/server/docstore'; +import { isValidDocumentId, putDocumentBlob } from '@/lib/server/documents-blobstore'; +import { toDocumentTypeFromName } from '@/lib/server/documents-utils'; +import { contentTypeForName } from '@/lib/server/library'; +import { isS3Configured } from '@/lib/server/s3'; +import type { DocumentType } from '@/types/documents'; +import { + applyOpenReaderTestNamespacePath, + getOpenReaderTestNamespace, + getUnclaimedUserIdForNamespace, +} from '@/lib/server/test-namespace'; + +export const dynamic = 'force-dynamic'; + +type V2Body = { + deleteLocal?: boolean; + dryRun?: boolean; +}; + +type LegacyDocumentCandidate = { + id: string; + name: string; + type: string; + size: number; + lastModified: number; +}; + +function isPreconditionFailed(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const maybe = error as { name?: string; $metadata?: { httpStatusCode?: number } }; + return maybe.$metadata?.httpStatusCode === 412 || maybe.name === 'PreconditionFailed'; +} + +function extractIdFromFileName(fileName: string): string | null { + const match = /^([a-f0-9]{64})__/i.exec(fileName); + if (!match) return null; + const id = match[1].toLowerCase(); + return isValidDocumentId(id) ? id : null; +} + +function decodeNameFromFileName(fileName: string, id: string): string { + const prefix = `${id}__`; + if (!fileName.startsWith(prefix)) return `${id}.bin`; + const encoded = fileName.slice(prefix.length); + try { + return decodeURIComponent(encoded); + } catch { + return `${id}.bin`; + } +} + +function sniffBinaryDocumentType(bytes: Buffer): Exclude | null { + // PDF signature: "%PDF-" + if (bytes.length >= 5 && bytes.subarray(0, 5).toString('ascii') === '%PDF-') { + return 'pdf'; + } + + // ZIP signatures: PK.. + const isZip = + bytes.length >= 4 && + bytes[0] === 0x50 && + bytes[1] === 0x4b && + (bytes[2] === 0x03 || bytes[2] === 0x05 || bytes[2] === 0x07) && + (bytes[3] === 0x04 || bytes[3] === 0x06 || bytes[3] === 0x08); + if (!isZip) return null; + + // EPUB/DOCX markers usually appear in ZIP local headers near the start. + const probe = bytes.subarray(0, Math.min(bytes.length, 1024 * 1024)).toString('latin1'); + if (probe.includes('application/epub+zip') || probe.includes('META-INF/container.xml')) { + return 'epub'; + } + if (probe.includes('[Content_Types].xml') && probe.includes('word/')) { + return 'docx'; + } + + return null; +} + +function normalizeNameForType(name: string, id: string, type: DocumentType): string { + if (type === 'html') return name; + const expectedExt = type === 'pdf' ? '.pdf' : type === 'epub' ? '.epub' : '.docx'; + if (name.toLowerCase().endsWith(expectedExt)) return name; + const base = name.replace(/\.bin$/i, ''); + return `${base || id}${expectedExt}`; +} + +function contentTypeForDocument(type: DocumentType, name: string): string { + if (type === 'pdf') return 'application/pdf'; + if (type === 'epub') return 'application/epub+zip'; + if (type === 'docx') return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + return contentTypeForName(name); +} + +async function migrateDocumentRowsToBlobHandle(dryRun: boolean): Promise { + const rows = (await db.select().from(documents)) as Array<{ + id: string; + userId: string; + filePath: string; + }>; + + let updated = 0; + for (const row of rows) { + if (!row.id || !row.userId || row.filePath === row.id) continue; + updated++; + if (dryRun) continue; + await db + .update(documents) + .set({ filePath: row.id }) + .where(and(eq(documents.id, row.id), eq(documents.userId, row.userId))); + } + return updated; +} + +async function seedMissingDocumentRows( + userId: string, + candidates: LegacyDocumentCandidate[], + dryRun: boolean, +): Promise { + if (candidates.length === 0) return 0; + + const existingRows = (await db.select().from(documents).where(eq(documents.userId, userId))) as Array<{ + id: string; + }>; + const existingIds = new Set(existingRows.map((row) => row.id)); + + const seen = new Set(); + const toInsert: LegacyDocumentCandidate[] = []; + for (const candidate of candidates) { + if (seen.has(candidate.id)) continue; + seen.add(candidate.id); + if (existingIds.has(candidate.id)) continue; + toInsert.push(candidate); + } + + if (toInsert.length === 0) return 0; + if (dryRun) return toInsert.length; + + await db.insert(documents).values( + toInsert.map((candidate) => ({ + id: candidate.id, + userId, + name: candidate.name, + type: candidate.type, + size: candidate.size, + lastModified: candidate.lastModified, + filePath: candidate.id, + })), + ).onConflictDoNothing(); + + return toInsert.length; +} + +export async function POST(request: NextRequest) { + try { + const session = await auth?.api.getSession({ headers: request.headers }); + if (auth && !session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + if (!isS3Configured()) { + return NextResponse.json( + { error: 'S3 is not configured. Set S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY.' }, + { status: 409 }, + ); + } + + const raw = (await request.json().catch(() => ({}))) as V2Body; + const dryRun = raw.dryRun === true; + const deleteLocal = raw.deleteLocal === true; + const testNamespace = getOpenReaderTestNamespace(request.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + const docsDir = applyOpenReaderTestNamespacePath(DOCUMENTS_V1_DIR, testNamespace); + + if (!existsSync(docsDir)) { + const rowsUpdated = await migrateDocumentRowsToBlobHandle(dryRun); + return NextResponse.json({ + success: true, + dryRun, + deleteLocal, + docsDir, + filesScanned: 0, + uploaded: 0, + alreadyPresent: 0, + skippedInvalid: 0, + deletedLocal: 0, + dbRowsUpdated: rowsUpdated, + dbRowsSeeded: 0, + }); + } + + const entries = await readdir(docsDir, { withFileTypes: true }); + const files = entries.filter((entry) => entry.isFile()).map((entry) => entry.name); + + let uploaded = 0; + let alreadyPresent = 0; + let skippedInvalid = 0; + let deletedLocal = 0; + const candidates: LegacyDocumentCandidate[] = []; + + for (const fileName of files) { + const fullPath = path.join(docsDir, fileName); + const bytes = await readFile(fullPath); + const fileStats = await stat(fullPath); + + const extractedId = extractIdFromFileName(fileName); + const id = extractedId ?? createHash('sha256').update(bytes).digest('hex'); + if (!isValidDocumentId(id)) { + skippedInvalid++; + continue; + } + + const inferredName = decodeNameFromFileName(fileName, id); + const inferredType = toDocumentTypeFromName(inferredName); + const type = inferredType === 'html' ? (sniffBinaryDocumentType(bytes) ?? inferredType) : inferredType; + const normalizedName = normalizeNameForType(inferredName, id, type); + const contentType = contentTypeForDocument(type, normalizedName); + const lastModified = Number.isFinite(fileStats.mtimeMs) ? Math.floor(fileStats.mtimeMs) : Date.now(); + + candidates.push({ + id, + name: normalizedName, + type, + size: bytes.length, + lastModified, + }); + + if (!dryRun) { + try { + await putDocumentBlob(id, bytes, contentType, testNamespace); + uploaded++; + } catch (error) { + if (isPreconditionFailed(error)) { + alreadyPresent++; + } else { + throw error; + } + } + } + + if (deleteLocal && !dryRun) { + await unlink(fullPath).catch(() => {}); + deletedLocal++; + } + } + + const rowsUpdated = await migrateDocumentRowsToBlobHandle(dryRun); + const rowsSeeded = await seedMissingDocumentRows(unclaimedUserId, candidates, dryRun); + + return NextResponse.json({ + success: true, + dryRun, + deleteLocal, + docsDir, + filesScanned: files.length, + uploaded, + alreadyPresent, + skippedInvalid, + deletedLocal, + dbRowsUpdated: rowsUpdated, + dbRowsSeeded: rowsSeeded, + }); + } catch (error) { + console.error('Error running v2 migrations:', error); + return NextResponse.json({ error: 'Failed to run v2 migrations' }, { status: 500 }); + } +} diff --git a/src/app/api/user/claim/route.ts b/src/app/api/user/claim/route.ts index 1011282..592cbfa 100644 --- a/src/app/api/user/claim/route.ts +++ b/src/app/api/user/claim/route.ts @@ -2,6 +2,34 @@ import { NextRequest, NextResponse } from 'next/server'; import { claimAnonymousData } from '@/lib/server/claim-data'; import { auth } from '@/lib/server/auth'; import { ensureDbIndexed, getUnclaimedCounts } from '@/lib/server/db-indexing'; +import { isDocumentsV1Ready } from '@/lib/server/docstore'; +import { db } from '@/db'; +import { documents } from '@/db/schema'; +import { count, ne } from 'drizzle-orm'; + +async function checkClaimMigrationReadiness(): Promise { + const documentsV1Ready = await isDocumentsV1Ready(); + if (!documentsV1Ready) { + return NextResponse.json( + { error: 'Document migration is not ready. Run startup migrations first.' }, + { status: 409 }, + ); + } + + const [legacyRows] = await db + .select({ count: count() }) + .from(documents) + .where(ne(documents.filePath, documents.id)); + + if (Number(legacyRows?.count ?? 0) > 0) { + return NextResponse.json( + { error: 'Document metadata migration is still pending. Wait for startup migrations to complete.' }, + { status: 409 }, + ); + } + + return null; +} export async function GET(req: NextRequest) { try { @@ -10,6 +38,9 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } + const readiness = await checkClaimMigrationReadiness(); + if (readiness) return readiness; + await ensureDbIndexed(); const counts = await getUnclaimedCounts(); return NextResponse.json({ success: true, ...counts }); @@ -26,6 +57,9 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } + const readiness = await checkClaimMigrationReadiness(); + if (readiness) return readiness; + const userId = session.user.id; await ensureDbIndexed(); diff --git a/src/app/html/[id]/page.tsx b/src/app/html/[id]/page.tsx index f70381b..a3d0811 100644 --- a/src/app/html/[id]/page.tsx +++ b/src/app/html/[id]/page.tsx @@ -57,8 +57,9 @@ export default function HTMLPage() { }, [isLoading, id, router, setCurrentDocument, stop]); useEffect(() => { + if (!isLoading) return; loadDocument(); - }, [loadDocument]); + }, [loadDocument, isLoading]); // Compute available height = viewport - (header height + tts bar height) useEffect(() => { diff --git a/src/components/doclist/DocumentPreview.tsx b/src/components/doclist/DocumentPreview.tsx index 1a17429..d6bcab9 100644 --- a/src/components/doclist/DocumentPreview.tsx +++ b/src/components/doclist/DocumentPreview.tsx @@ -5,7 +5,8 @@ import { extractEpubCoverToDataUrl, renderPdfFirstPageToDataUrl, } from '@/lib/documentPreview'; -import { downloadDocumentContent, getDocumentContentSnippet } from '@/lib/client-documents'; +import { getDocumentContentSnippet } from '@/lib/client-documents'; +import { ensureCachedDocument } from '@/lib/document-cache'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -36,6 +37,16 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) { const [isGenerating, setIsGenerating] = useState(false); const previewKey = useMemo(() => `${doc.type}:${doc.id}`, [doc.id, doc.type]); + const cacheMeta = useMemo( + () => ({ + id: doc.id, + name: doc.name, + type: doc.type, + size: doc.size, + lastModified: doc.lastModified, + }), + [doc.id, doc.lastModified, doc.name, doc.size, doc.type], + ); useEffect(() => { const el = containerRef.current; @@ -81,7 +92,9 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) { const targetWidth = 240; if (doc.type === 'pdf') { - const data = await downloadDocumentContent(doc.id, { signal: controller.signal }); + const cached = await ensureCachedDocument(cacheMeta, { signal: controller.signal }); + if (cached.type !== 'pdf') return; + const data = cached.data; if (cancelled) return; const dataUrl = await renderPdfFirstPageToDataUrl(data, targetWidth); if (cancelled) return; @@ -92,7 +105,9 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) { } if (doc.type === 'epub') { - const data = await downloadDocumentContent(doc.id, { signal: controller.signal }); + const cached = await ensureCachedDocument(cacheMeta, { signal: controller.signal }); + if (cached.type !== 'epub') return; + const data = cached.data; if (cancelled) return; const cover = await extractEpubCoverToDataUrl(data, targetWidth); if (cancelled) return; @@ -130,7 +145,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) { cancelled = true; controller.abort(); }; - }, [doc.id, doc.type, isVisible, previewKey]); + }, [cacheMeta, doc.id, doc.type, isVisible, previewKey]); const gradientClass = isPDF ? 'from-red-500/80 via-red-400/60 to-red-600/80' diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index 3622679..9773e20 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -111,13 +111,40 @@ export function ConfigProvider({ children }: { children: ReactNode }) { if (response?.ok) { const data = await response.json(); - const didMigrate = + const v2ApplyResponse = await fetch('/api/migrations/v2', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ dryRun: false, deleteLocal: false }), + }).catch(() => null); + const v2ApplyData = v2ApplyResponse?.ok ? await v2ApplyResponse.json().catch(() => null) : null; + + const didMigrateV1 = data.documentsMigrated || data.audiobooksMigrated || (data.rekey?.renamed ?? 0) > 0 || (data.rekey?.merged ?? 0) > 0; - if (didMigrate) { + if (v2ApplyData) { + const uploaded = Number(v2ApplyData.uploaded ?? 0); + const alreadyPresent = Number(v2ApplyData.alreadyPresent ?? 0); + const dbRowsUpdated = Number(v2ApplyData.dbRowsUpdated ?? 0); + const dbRowsSeeded = Number(v2ApplyData.dbRowsSeeded ?? 0); + const deletedLocal = Number(v2ApplyData.deletedLocal ?? 0); + const dbRowsMigrated = dbRowsUpdated + dbRowsSeeded; + const didMigrateV2 = uploaded > 0 || dbRowsMigrated > 0 || deletedLocal > 0; + + if (didMigrateV2) { + toast.success( + `Legacy document migration complete: ${uploaded} uploaded, ${alreadyPresent} already in S3, ${dbRowsMigrated} DB row(s) migrated.`, + { duration: 6000, icon: '📦' }, + ); + window.dispatchEvent(new CustomEvent('openreader:documentsChanged', { + detail: { reason: 'migration-v2-complete' }, + })); + } + } + + if (didMigrateV1) { toast.success('Library migration complete', { duration: 5000, icon: '📦', diff --git a/src/contexts/DocumentContext.tsx b/src/contexts/DocumentContext.tsx index 126f918..d9f7390 100644 --- a/src/contexts/DocumentContext.tsx +++ b/src/contexts/DocumentContext.tsx @@ -50,6 +50,19 @@ export function DocumentProvider({ children }: { children: ReactNode }) { }); }, [refreshDocuments]); + useEffect(() => { + const handler = () => { + refreshDocuments().catch((err) => { + console.error('Failed to refresh documents after change event:', err); + }); + }; + + window.addEventListener('openreader:documentsChanged', handler as EventListener); + return () => { + window.removeEventListener('openreader:documentsChanged', handler as EventListener); + }; + }, [refreshDocuments]); + const docsByType = useMemo(() => { const pdfDocs = (docs ?? []).filter((d) => d.type === 'pdf') as Array; const epubDocs = (docs ?? []).filter((d) => d.type === 'epub') as Array; diff --git a/src/contexts/HTMLContext.tsx b/src/contexts/HTMLContext.tsx index 401ed89..caf8e26 100644 --- a/src/contexts/HTMLContext.tsx +++ b/src/contexts/HTMLContext.tsx @@ -7,6 +7,8 @@ import { ReactNode, useCallback, useMemo, + useEffect, + useRef, } from 'react'; import { getDocumentMetadata } from '@/lib/client-documents'; import { ensureCachedDocument } from '@/lib/document-cache'; @@ -30,12 +32,16 @@ const HTMLContext = createContext(undefined); */ export function HTMLProvider({ children }: { children: ReactNode }) { const { setText: setTTSText, stop } = useTTS(); + const setTTSTextRef = useRef(setTTSText); // Current document state const [currDocData, setCurrDocData] = useState(); const [currDocName, setCurrDocName] = useState(); const [currDocText, setCurrDocText] = useState(); + useEffect(() => { + setTTSTextRef.current = setTTSText; + }, [setTTSText]); /** * Clears all current document state and stops any active TTS @@ -69,12 +75,12 @@ export function HTMLProvider({ children }: { children: ReactNode }) { setCurrDocName(doc.name); setCurrDocData(doc.data); setCurrDocText(doc.data); // Use the same text for TTS - setTTSText(doc.data); + setTTSTextRef.current(doc.data); } catch (error) { console.error('Failed to get HTML document:', error); clearCurrDoc(); } - }, [clearCurrDoc, setTTSText]); + }, [clearCurrDoc]); diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index 067bda2..a7e8c9e 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -12,22 +12,32 @@ function createAuthClientWithUrl(baseUrl: string) { // Cache for auth client instances by baseUrl const clientCache = new Map>(); +function resolveAuthClientBaseUrl(baseUrl: string | null): string { + if (typeof window !== 'undefined' && window.location?.origin) { + // Always use same-origin in the browser so local hostname variants + // (localhost vs LAN IP) do not break cookie/session bootstrap. + return window.location.origin; + } + + if (baseUrl) return baseUrl; + + throw new Error( + 'Cannot create auth client without baseUrl in a non-browser context. ' + + 'Use useAuthConfig() in components to get the properly configured baseUrl.' + ); +} + /** * Factory function to get auth client with specific baseUrl. * In components, prefer reading `baseUrl` from `useAuthConfig()` and then calling `getAuthClient(baseUrl)`. - * @param baseUrl - The auth server base URL. If null, will throw an error. + * @param baseUrl - Server-provided auth URL; in the browser we use same-origin automatically. */ export function getAuthClient(baseUrl: string | null) { - if (!baseUrl) { - throw new Error( - 'Cannot create auth client without baseUrl. ' + - 'Use useAuthConfig() in components to get the properly configured baseUrl.' - ); + const resolvedBaseUrl = resolveAuthClientBaseUrl(baseUrl); + + if (!clientCache.has(resolvedBaseUrl)) { + clientCache.set(resolvedBaseUrl, createAuthClientWithUrl(resolvedBaseUrl)); } - if (!clientCache.has(baseUrl)) { - clientCache.set(baseUrl, createAuthClientWithUrl(baseUrl)); - } - - return clientCache.get(baseUrl)!; + return clientCache.get(resolvedBaseUrl)!; } diff --git a/src/lib/client-documents.ts b/src/lib/client-documents.ts index df58c33..194524b 100644 --- a/src/lib/client-documents.ts +++ b/src/lib/client-documents.ts @@ -1,8 +1,52 @@ -import type { BaseDocument } from '@/types/documents'; +import { sha256HexFromArrayBuffer } from '@/lib/sha256'; +import type { BaseDocument, DocumentType } from '@/types/documents'; + +export type UploadSource = { + id: string; + name: string; + type: DocumentType; + size: number; + lastModified: number; + contentType: string; + body: Blob | ArrayBuffer | Uint8Array; +}; + +type UploadOptions = { + signal?: AbortSignal; +}; + +function toUploadBody(body: UploadSource['body']): BodyInit { + if (body instanceof Blob) return body; + if (body instanceof ArrayBuffer) return body; + return body as unknown as BodyInit; +} + +async function uploadDocumentSourceViaProxy(source: UploadSource, options?: UploadOptions): Promise { + const res = await fetch(`/api/documents/blob/upload/fallback?id=${encodeURIComponent(source.id)}`, { + method: 'PUT', + headers: { 'Content-Type': source.contentType || 'application/octet-stream' }, + body: toUploadBody(source.body), + signal: options?.signal, + }); + + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || `Proxy upload failed (status ${res.status})`); + } +} + +function documentTypeForName(name: string): DocumentType { + const lower = name.toLowerCase(); + if (lower.endsWith('.pdf')) return 'pdf'; + if (lower.endsWith('.epub')) return 'epub'; + if (lower.endsWith('.docx')) return 'docx'; + return 'html'; +} export function mimeTypeForDoc(doc: Pick): string { if (doc.type === 'pdf') return 'application/pdf'; if (doc.type === 'epub') return 'application/epub+zip'; + if (doc.type === 'docx') return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; const lower = doc.name.toLowerCase(); if (lower.endsWith('.md') || lower.endsWith('.markdown') || lower.endsWith('.mdown') || lower.endsWith('.mkd')) { @@ -13,7 +57,6 @@ export function mimeTypeForDoc(doc: Pick): string export async function listDocuments(options?: { ids?: string[]; signal?: AbortSignal }): Promise { const params = new URLSearchParams(); - params.set('list', 'true'); if (options?.ids?.length) { params.set('ids', options.ids.join(',')); } @@ -33,27 +76,114 @@ export async function getDocumentMetadata(id: string, options?: { signal?: Abort return docs[0] ?? null; } -export async function uploadDocuments(files: File[], options?: { signal?: AbortSignal }): Promise { - const form = new FormData(); - for (const file of files) { - form.append('files', file); - } +export async function uploadDocumentSources(sources: UploadSource[], options?: UploadOptions): Promise { + if (sources.length === 0) return []; - const res = await fetch('/api/documents/upload', { + const presignRes = await fetch('/api/documents/blob/upload/presign', { method: 'POST', - body: form, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + uploads: sources.map((source) => ({ + id: source.id, + contentType: source.contentType, + size: source.size, + })), + }), signal: options?.signal, }); - if (!res.ok) { - const data = (await res.json().catch(() => null)) as { error?: string } | null; - throw new Error(data?.error || 'Failed to upload documents'); + if (!presignRes.ok) { + const data = (await presignRes.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || 'Failed to prepare uploads'); } - const data = (await res.json()) as { stored: BaseDocument[] }; + const presigned = (await presignRes.json()) as { + uploads?: Array<{ id: string; url: string; headers?: Record }>; + }; + const byId = new Map((presigned.uploads || []).map((upload) => [upload.id, upload])); + + for (const source of sources) { + const upload = byId.get(source.id); + if (!upload?.url) { + throw new Error(`Missing presigned upload for document ${source.id}`); + } + + let putError: unknown = null; + try { + const putRes = await fetch(upload.url, { + method: 'PUT', + headers: new Headers(upload.headers || {}), + body: toUploadBody(source.body), + signal: options?.signal, + }); + + // 412 means the content-hash object already exists (idempotent upload). + if (putRes.ok || putRes.status === 412) { + continue; + } + putError = new Error(`Direct upload failed with status ${putRes.status}`); + } catch (error) { + if (options?.signal?.aborted) throw error; + putError = error; + } + + try { + await uploadDocumentSourceViaProxy(source, options); + } catch (proxyError) { + const directMessage = putError instanceof Error ? putError.message : 'unknown direct upload error'; + const proxyMessage = proxyError instanceof Error ? proxyError.message : 'unknown proxy upload error'; + throw new Error(`Failed to upload document ${source.name}: ${directMessage}; fallback failed: ${proxyMessage}`); + } + } + + const registerRes = await fetch('/api/documents', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + documents: sources.map((source) => ({ + id: source.id, + name: source.name, + type: source.type, + size: source.size, + lastModified: source.lastModified, + })), + }), + signal: options?.signal, + }); + + if (!registerRes.ok) { + const data = (await registerRes.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || 'Failed to register uploaded documents'); + } + + const data = (await registerRes.json()) as { stored: BaseDocument[] }; return data.stored || []; } +export async function uploadDocuments(files: File[], options?: UploadOptions): Promise { + if (files.length === 0) return []; + + const sources: UploadSource[] = []; + for (const file of files) { + const bytes = await file.arrayBuffer(); + const id = await sha256HexFromArrayBuffer(bytes); + const type = documentTypeForName(file.name); + const name = file.name || `${id}.${type}`; + const contentType = file.type || mimeTypeForDoc({ name, type }); + sources.push({ + id, + name, + type, + size: file.size, + lastModified: Number.isFinite(file.lastModified) ? file.lastModified : Date.now(), + contentType, + body: file, + }); + } + + return uploadDocumentSources(sources, options); +} + export async function deleteDocuments(options?: { ids?: string[]; scope?: 'user' | 'unclaimed'; signal?: AbortSignal }): Promise { const params = new URLSearchParams(); if (options?.ids?.length) { @@ -72,7 +202,7 @@ export async function deleteDocuments(options?: { ids?: string[]; scope?: 'user' } export async function downloadDocumentContent(id: string, options?: { signal?: AbortSignal }): Promise { - const res = await fetch(`/api/documents/content?id=${encodeURIComponent(id)}`, { signal: options?.signal }); + const res = await fetch(`/api/documents/blob?id=${encodeURIComponent(id)}`, { signal: options?.signal }); if (!res.ok) { const contentType = res.headers.get('content-type') || ''; if (contentType.includes('application/json')) { @@ -95,7 +225,7 @@ export async function getDocumentContentSnippet( if (typeof options?.maxChars === 'number') params.set('maxChars', String(options.maxChars)); if (typeof options?.maxBytes === 'number') params.set('maxBytes', String(options.maxBytes)); - const res = await fetch(`/api/documents/content?${params.toString()}`, { signal: options?.signal }); + const res = await fetch(`/api/documents/blob?${params.toString()}`, { signal: options?.signal }); if (!res.ok) { const data = (await res.json().catch(() => null)) as { error?: string } | null; throw new Error(data?.error || `Failed to load content snippet (status ${res.status})`); diff --git a/src/lib/dexie.ts b/src/lib/dexie.ts index e43d39e..5392b18 100644 --- a/src/lib/dexie.ts +++ b/src/lib/dexie.ts @@ -5,11 +5,12 @@ import { EPUBDocument, HTMLDocument, DocumentListState, - SyncedDocument, BaseDocument, DocumentListDocument, } from '@/types/documents'; import { sha256HexFromBytes, sha256HexFromString } from '@/lib/sha256'; +import { downloadDocumentContent, listDocuments, uploadDocumentSources, type UploadSource } from '@/lib/client-documents'; +import { cacheStoredDocumentFromBytes } from '@/lib/document-cache'; const DB_NAME = 'openreader-db'; // Managed via Dexie (version bumped from the original manual IndexedDB) @@ -657,15 +658,26 @@ export async function syncDocumentsToServer( const epubDocs = await getAllEpubDocuments(); const htmlDocs = await getAllHtmlDocuments(); - const documents: SyncedDocument[] = []; + const uploads: Array<{ oldId: string; source: UploadSource }> = []; const totalDocs = pdfDocs.length + epubDocs.length + htmlDocs.length; let processedDocs = 0; + const textEncoder = new TextEncoder(); + for (const doc of pdfDocs) { - documents.push({ - ...doc, - type: 'pdf', - data: Array.from(new Uint8Array(doc.data)), + const bytes = new Uint8Array(doc.data); + const id = await sha256HexFromBytes(bytes); + uploads.push({ + oldId: doc.id, + source: { + id, + name: doc.name, + type: 'pdf', + size: bytes.byteLength, + lastModified: doc.lastModified, + contentType: 'application/pdf', + body: bytes, + }, }); processedDocs++; if (onProgress) { @@ -674,10 +686,19 @@ export async function syncDocumentsToServer( } for (const doc of epubDocs) { - documents.push({ - ...doc, - type: 'epub', - data: Array.from(new Uint8Array(doc.data)), + const bytes = new Uint8Array(doc.data); + const id = await sha256HexFromBytes(bytes); + uploads.push({ + oldId: doc.id, + source: { + id, + name: doc.name, + type: 'epub', + size: bytes.byteLength, + lastModified: doc.lastModified, + contentType: 'application/epub+zip', + body: bytes, + }, }); processedDocs++; if (onProgress) { @@ -685,13 +706,20 @@ export async function syncDocumentsToServer( } } - const encoder = new TextEncoder(); for (const doc of htmlDocs) { - const encoded = encoder.encode(doc.data); - documents.push({ - ...doc, - type: 'html', - data: Array.from(encoded), + const encoded = textEncoder.encode(doc.data); + const id = await sha256HexFromBytes(encoded); + uploads.push({ + oldId: doc.id, + source: { + id, + name: doc.name, + type: 'html', + size: encoded.byteLength, + lastModified: doc.lastModified, + contentType: 'text/plain; charset=utf-8', + body: encoded, + }, }); processedDocs++; if (onProgress) { @@ -703,25 +731,11 @@ export async function syncDocumentsToServer( onProgress(50, 'Uploading to server...'); } - const response = await fetch('/api/documents', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ documents }), - signal, - }); + await uploadDocumentSources(uploads.map((entry) => entry.source), { signal }); - if (!response.ok) { - throw new Error('Failed to sync documents to server'); - } - - const payload = (await response.json().catch(() => null)) as - | { stored?: Array<{ oldId: string; id: string }> } - | null; - const stored = payload?.stored ?? []; - for (const mapping of stored) { - if (!mapping || typeof mapping.oldId !== 'string' || typeof mapping.id !== 'string') continue; - if (mapping.oldId === mapping.id) continue; - await applyDocumentIdMapping(mapping.oldId, mapping.id); + for (const entry of uploads) { + if (entry.oldId === entry.source.id) continue; + await applyDocumentIdMapping(entry.oldId, entry.source.id); } if (onProgress) { @@ -736,52 +750,77 @@ export async function syncSelectedDocumentsToServer( onProgress?: (progress: number, status?: string) => void, signal?: AbortSignal, ): Promise<{ lastSync: number }> { - // Re-use logic from syncDocumentsToServer but only for specific documents - // Actually, syncDocumentsToServer fetches all docs from DB. - // We need to fetch the *full content* of the selected docs from DB. - - const fullDocs: SyncedDocument[] = []; - let processed = 0; - - for (const doc of documents) { - if (doc.type === 'pdf') { - const data = await getPdfDocument(doc.id); - if (data) fullDocs.push({ ...data, type: 'pdf', data: Array.from(new Uint8Array(data.data)) }); - } else if (doc.type === 'epub') { - const data = await getEpubDocument(doc.id); - if (data) fullDocs.push({ ...data, type: 'epub', data: Array.from(new Uint8Array(data.data)) }); - } else { - const data = await getHtmlDocument(doc.id); - if (data) { - const encoder = new TextEncoder(); - fullDocs.push({ ...data, type: 'html', data: Array.from(encoder.encode(data.data)) }); - } - } - processed++; - if (onProgress) onProgress((processed / documents.length) * 50, `Preparing ${processed}/${documents.length}...`); + const uploads: Array<{ oldId: string; source: UploadSource }> = []; + const textEncoder = new TextEncoder(); + let processed = 0; + + for (const doc of documents) { + if (doc.type === 'pdf') { + const data = await getPdfDocument(doc.id); + if (data) { + const bytes = new Uint8Array(data.data); + const id = await sha256HexFromBytes(bytes); + uploads.push({ + oldId: data.id, + source: { + id, + name: data.name, + type: 'pdf', + size: bytes.byteLength, + lastModified: data.lastModified, + contentType: 'application/pdf', + body: bytes, + }, + }); + } + } else if (doc.type === 'epub') { + const data = await getEpubDocument(doc.id); + if (data) { + const bytes = new Uint8Array(data.data); + const id = await sha256HexFromBytes(bytes); + uploads.push({ + oldId: data.id, + source: { + id, + name: data.name, + type: 'epub', + size: bytes.byteLength, + lastModified: data.lastModified, + contentType: 'application/epub+zip', + body: bytes, + }, + }); + } + } else { + const data = await getHtmlDocument(doc.id); + if (data) { + const bytes = textEncoder.encode(data.data); + const id = await sha256HexFromBytes(bytes); + uploads.push({ + oldId: data.id, + source: { + id, + name: data.name, + type: 'html', + size: bytes.byteLength, + lastModified: data.lastModified, + contentType: 'text/plain; charset=utf-8', + body: bytes, + }, + }); + } } - - if (onProgress) onProgress(50, 'Uploading to server...'); - const response = await fetch('/api/documents', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ documents: fullDocs }), - signal, - }); - - if (!response.ok) { - throw new Error('Failed to sync documents to server'); + processed++; + if (onProgress) onProgress((processed / documents.length) * 50, `Preparing ${processed}/${documents.length}...`); } - const payload = (await response.json().catch(() => null)) as - | { stored?: Array<{ oldId: string; id: string }> } - | null; - const stored = payload?.stored ?? []; - for (const mapping of stored) { - if (!mapping || typeof mapping.oldId !== 'string' || typeof mapping.id !== 'string') continue; - if (mapping.oldId === mapping.id) continue; - await applyDocumentIdMapping(mapping.oldId, mapping.id); + if (onProgress) onProgress(50, 'Uploading to server...'); + await uploadDocumentSources(uploads.map((entry) => entry.source), { signal }); + + for (const entry of uploads) { + if (entry.oldId === entry.source.id) continue; + await applyDocumentIdMapping(entry.oldId, entry.source.id); } if (onProgress) { @@ -800,22 +839,8 @@ export async function loadDocumentsFromServer( onProgress(10, 'Starting download...'); } - const response = await fetch('/api/documents', { signal }); - if (!response.ok) { - throw new Error('Failed to fetch documents from server'); - } - - if (onProgress) { - onProgress(30, 'Download complete'); - } - - const { documents } = (await response.json()) as { documents: SyncedDocument[] }; - - if (onProgress) { - onProgress(40, 'Parsing documents...'); - } - - await saveSyncedDocumentsLocally(documents, onProgress); + const documents = await listDocuments({ signal }); + await downloadAndCacheServerDocuments(documents, onProgress, signal); if (onProgress) { onProgress(100, 'Load complete!'); @@ -832,26 +857,9 @@ export async function loadSelectedDocumentsFromServer( if (onProgress) { onProgress(10, 'Starting download...'); } - - // Use new filtered API - const idsParam = selectedIds.join(','); - const response = await fetch(`/api/documents?ids=${encodeURIComponent(idsParam)}`, { signal }); - - if (!response.ok) { - throw new Error('Failed to fetch documents from server'); - } - if (onProgress) { - onProgress(30, 'Download complete'); - } - - const { documents } = (await response.json()) as { documents: SyncedDocument[] }; - - if (onProgress) { - onProgress(40, 'Parsing documents...'); - } - - await saveSyncedDocumentsLocally(documents, onProgress); + const documents = await listDocuments({ ids: selectedIds, signal }); + await downloadAndCacheServerDocuments(documents, onProgress, signal); if (onProgress) { onProgress(100, 'Load complete!'); @@ -860,52 +868,23 @@ export async function loadSelectedDocumentsFromServer( return { lastSync: Date.now() }; } -async function saveSyncedDocumentsLocally(documents: SyncedDocument[], onProgress?: (progress: number, status?: string) => void) { - const textDecoder = new TextDecoder(); +async function downloadAndCacheServerDocuments( + documents: BaseDocument[], + onProgress?: (progress: number, status?: string) => void, + signal?: AbortSignal, +) { + if (onProgress) onProgress(30, 'List complete'); + if (documents.length === 0) { + if (onProgress) onProgress(95, 'No documents to import'); + return; + } for (let i = 0; i < documents.length; i++) { const doc = documents[i]; - - if (doc.type === 'pdf') { - const uint8Array = new Uint8Array(doc.data); - const documentData: PDFDocument = { - id: doc.id, - type: 'pdf', - name: doc.name, - size: doc.size, - lastModified: doc.lastModified, - data: uint8Array.buffer, - }; - await addPdfDocument(documentData); - } else if (doc.type === 'epub') { - const uint8Array = new Uint8Array(doc.data); - const documentData: EPUBDocument = { - id: doc.id, - type: 'epub', - name: doc.name, - size: doc.size, - lastModified: doc.lastModified, - data: uint8Array.buffer, - }; - await addEpubDocument(documentData); - } else if (doc.type === 'html') { - const uint8Array = new Uint8Array(doc.data); - const decoded = textDecoder.decode(uint8Array); - const documentData: HTMLDocument = { - id: doc.id, - type: 'html', - name: doc.name, - size: doc.size, - lastModified: doc.lastModified, - data: decoded, - }; - await addHtmlDocument(documentData); - } else { - console.warn(`Unknown document type: ${doc.type}`); - } - + const bytes = await downloadDocumentContent(doc.id, { signal }); + await cacheStoredDocumentFromBytes(doc, bytes); if (onProgress) { - onProgress(40 + ((i + 1) / documents.length) * 50, `Processing document ${i + 1}/${documents.length}...`); + onProgress(30 + ((i + 1) / documents.length) * 65, `Downloading ${i + 1}/${documents.length}: ${doc.name}`); } } } @@ -1007,5 +986,3 @@ export async function importDocumentsFromLibrary( onProgress(100, 'Library import complete!'); } } - - diff --git a/src/lib/server/auth-config.ts b/src/lib/server/auth-config.ts index 2279e9e..6acea79 100644 --- a/src/lib/server/auth-config.ts +++ b/src/lib/server/auth-config.ts @@ -1,9 +1,9 @@ /** * Centralized auth configuration check. - * Auth is only enabled when BOTH BETTER_AUTH_SECRET and BETTER_AUTH_URL are set. + * Auth is only enabled when BOTH AUTH_SECRET and BASE_URL are set. */ export function isAuthEnabled(): boolean { - return !!(process.env.BETTER_AUTH_SECRET && process.env.BETTER_AUTH_URL); + return !!(process.env.AUTH_SECRET && process.env.BASE_URL); } /** @@ -13,5 +13,5 @@ export function getAuthBaseUrl(): string | null { if (!isAuthEnabled()) { return null; } - return process.env.BETTER_AUTH_URL || null; + return process.env.BASE_URL || null; } diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 1339dc4..e483ff1 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -13,6 +13,34 @@ import * as schema from "@/db/schema"; // Import the dynamic schema // ... +function tryGetOrigin(url: string | undefined): string | null { + if (!url) return null; + try { + return new URL(url).origin; + } catch { + return null; + } +} + +function getTrustedOrigins(): string[] { + const origins = new Set(); + const baseOrigin = tryGetOrigin(process.env.BASE_URL); + if (baseOrigin) origins.add(baseOrigin); + + // Comma-separated list for local multi-host setups (e.g., localhost + LAN IP). + const extra = (process.env.AUTH_TRUSTED_ORIGINS || '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + + for (const candidate of extra) { + const origin = tryGetOrigin(candidate); + if (origin) origins.add(origin); + } + + return Array.from(origins); +} + const createAuth = () => betterAuth({ // eslint-disable-next-line @typescript-eslint/no-explicit-any database: drizzleAdapter(db as any, { @@ -25,8 +53,9 @@ const createAuth = () => betterAuth({ verification: schema.verification, } }), - secret: process.env.BETTER_AUTH_SECRET!, - baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3003", + secret: process.env.AUTH_SECRET!, + baseURL: process.env.BASE_URL!, + trustedOrigins: getTrustedOrigins(), emailAndPassword: { enabled: true, requireEmailVerification: false, // Set to true in production diff --git a/src/lib/server/db-indexing.ts b/src/lib/server/db-indexing.ts index ab8c1c3..1572fcd 100644 --- a/src/lib/server/db-indexing.ts +++ b/src/lib/server/db-indexing.ts @@ -6,7 +6,7 @@ import { db } from '@/db'; import { audiobookChapters, audiobooks, documents } from '@/db/schema'; import { and, count, eq } from 'drizzle-orm'; import { listStoredChapters } from '@/lib/server/audiobook'; -import { AUDIOBOOKS_V1_DIR, DOCUMENTS_V1_DIR, UNCLAIMED_USER_ID, getUnclaimedAudiobookDir } from '@/lib/server/docstore'; +import { AUDIOBOOKS_V1_DIR, UNCLAIMED_USER_ID, getUnclaimedAudiobookDir } from '@/lib/server/docstore'; const DOCSTORE_DIR = path.join(process.cwd(), 'docstore'); const MIGRATIONS_DIR = path.join(DOCSTORE_DIR, '.migrations'); @@ -38,33 +38,14 @@ async function writeState(): Promise { await fs.writeFile(STATE_PATH, JSON.stringify(state, null, 2)); } -async function hasFilesystemContent(mode: DbIndexState['mode']): Promise<{ documents: boolean; audiobooks: boolean }> { - let documentsPresent = false; - let audiobooksPresent = false; - - // Documents are always stored under documents_v1. - try { - const entries = await fs.readdir(DOCUMENTS_V1_DIR); - documentsPresent = entries.some((name) => /^[a-f0-9]{64}__.+$/i.test(name)); - } catch { - // ignore - } - - // Audiobooks differ by auth-mode layout. +async function hasAudiobookFilesystemContent(mode: DbIndexState['mode']): Promise { const audiobookDir = mode === 'auth' ? getUnclaimedAudiobookDir() : AUDIOBOOKS_V1_DIR; try { const entries = await fs.readdir(audiobookDir, { withFileTypes: true }); - audiobooksPresent = entries.some((e) => e.isDirectory() && e.name.endsWith('-audiobook')); + return entries.some((entry) => entry.isDirectory() && entry.name.endsWith('-audiobook')); } catch { - // ignore + return false; } - - return { documents: documentsPresent, audiobooks: audiobooksPresent }; -} - -async function isDocumentIndexed(id: string): Promise { - const result = await db.select({ id: documents.id }).from(documents).where(eq(documents.id, id)); - return result.length > 0; } async function isAudiobookIndexedForUser(id: string, userId: string): Promise { @@ -90,7 +71,6 @@ async function migrateLegacyAudiobooksToUnclaimed(): Promise { const sourceDir = path.join(AUDIOBOOKS_V1_DIR, entry.name); const targetDir = path.join(unclaimedDir, entry.name); - if (existsSync(targetDir)) continue; try { @@ -115,133 +95,83 @@ export async function getUnclaimedCounts(): Promise<{ documents: number; audiobo }; } -async function scanAndPopulateDb(): Promise<{ documents: number; audiobooks: number }> { +async function scanAndPopulateAudiobookDb(): Promise { const authEnabled = isAuthEnabled(); - - console.log('Scanning file system for un-indexed content...'); + console.log('Scanning file system for un-indexed audiobooks...'); if (authEnabled) { await migrateLegacyAudiobooksToUnclaimed(); } - if (existsSync(DOCUMENTS_V1_DIR)) { - const files = await fs.readdir(DOCUMENTS_V1_DIR); - for (const file of files) { - const match = /^([a-f0-9]{64})__(.+)$/i.exec(file); - if (!match) continue; - - const id = match[1]; - const encodedName = match[2]; - if (await isDocumentIndexed(id)) continue; - - let name: string; - try { - name = decodeURIComponent(encodedName); - } catch { - continue; - } - - const filePath = path.join(DOCUMENTS_V1_DIR, file); - const stats = await fs.stat(filePath); - const ext = path.extname(name).toLowerCase().replace('.', ''); - - await db.insert(documents).values({ - id, - userId: UNCLAIMED_USER_ID, - name, - type: ext, - size: stats.size, - lastModified: Math.floor(stats.mtimeMs), - filePath: file, - }); - console.log(`Indexed document: ${name} (${id})`); - } - } - const audiobookScanDir = authEnabled ? getUnclaimedAudiobookDir() : AUDIOBOOKS_V1_DIR; - if (existsSync(audiobookScanDir)) { - const entries = await fs.readdir(audiobookScanDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (!entry.name.endsWith('-audiobook')) continue; + if (!existsSync(audiobookScanDir)) return; - const bookId = entry.name.replace('-audiobook', ''); - if (await isAudiobookIndexedForUser(bookId, UNCLAIMED_USER_ID)) continue; + const entries = await fs.readdir(audiobookScanDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (!entry.name.endsWith('-audiobook')) continue; - const dirPath = path.join(audiobookScanDir, entry.name); + const bookId = entry.name.replace('-audiobook', ''); + if (await isAudiobookIndexedForUser(bookId, UNCLAIMED_USER_ID)) continue; - let title = 'Unknown Title'; - try { - const metaPath = path.join(dirPath, 'audiobook.meta.json'); - const metaContent = await fs.readFile(metaPath, 'utf8'); - JSON.parse(metaContent); - } catch { - // ignore - } + const dirPath = path.join(audiobookScanDir, entry.name); - const chapters = await listStoredChapters(dirPath); - const totalDuration = chapters.reduce((acc, c) => acc + (c.durationSec || 0), 0); - if (chapters.length > 0) title = chapters[0].title || title; + let title = 'Unknown Title'; + try { + const metaPath = path.join(dirPath, 'audiobook.meta.json'); + const metaContent = await fs.readFile(metaPath, 'utf8'); + JSON.parse(metaContent); + } catch { + // ignore + } - await db.insert(audiobooks).values({ - id: bookId, + const chapters = await listStoredChapters(dirPath); + const totalDuration = chapters.reduce((acc, chapter) => acc + (chapter.durationSec || 0), 0); + if (chapters.length > 0) title = chapters[0].title || title; + + await db.insert(audiobooks).values({ + id: bookId, + userId: UNCLAIMED_USER_ID, + title, + duration: totalDuration, + }); + console.log(`Indexed audiobook: ${bookId}`); + + for (const chapter of chapters) { + await db.insert(audiobookChapters).values({ + id: `${bookId}-${chapter.index}`, + bookId, userId: UNCLAIMED_USER_ID, - title, - duration: totalDuration, + chapterIndex: chapter.index, + title: chapter.title, + duration: chapter.durationSec || 0, + filePath: chapter.filePath, + format: chapter.format, }); - console.log(`Indexed audiobook: ${bookId}`); - - for (const chapter of chapters) { - await db.insert(audiobookChapters).values({ - id: `${bookId}-${chapter.index}`, - bookId, - userId: UNCLAIMED_USER_ID, - chapterIndex: chapter.index, - title: chapter.title, - duration: chapter.durationSec || 0, - filePath: chapter.filePath, - format: chapter.format, - }); - } } } - - return getUnclaimedCounts(); } -/** - * Ensure DB has rows for existing filesystem content (documents/audiobooks). - * - * This is intentionally safe to call from routes: - * - Runs at most once per process (memoryIndexed) - * - Uses a persisted state file under docstore/.migrations to avoid rescanning every boot - */ -export async function ensureDbIndexed(): Promise { +export async function ensureAudiobooksIndexed(): Promise { const mode: DbIndexState['mode'] = isAuthEnabled() ? 'auth' : 'noauth'; if (memoryIndexedMode === mode) return; inflight ??= (async () => { const hasState = existsSync(STATE_PATH) ? await readState() : null; if (hasState && hasState.mode === mode) { - // If the DB was reset but the state file survived, don't get stuck "indexed" forever. - // Only skip if: - // - the DB already has rows for any on-disk content (per category), OR - // - there is no content on disk to index (per category). - // - // This avoids a bad state where (for example) audiobooks are indexed, but documents exist on disk - // and aren't counted/claimable because we early-exit based on "some DB rows exist". - const [counts, fsHas] = await Promise.all([getUnclaimedCounts(), hasFilesystemContent(mode)]); - const docsOk = counts.documents > 0 || !fsHas.documents; - const audiobooksOk = counts.audiobooks > 0 || !fsHas.audiobooks; - - if (docsOk && audiobooksOk) { + const [counts, fsHasAudiobooks] = await Promise.all([ + getUnclaimedCounts(), + hasAudiobookFilesystemContent(mode), + ]); + const audiobooksOk = counts.audiobooks > 0 || !fsHasAudiobooks; + if (audiobooksOk) { memoryIndexedMode = mode; return; } } await fs.mkdir(DOCSTORE_DIR, { recursive: true }); - await scanAndPopulateDb(); + await scanAndPopulateAudiobookDb(); await writeState(); memoryIndexedMode = mode; })().finally(() => { @@ -250,3 +180,7 @@ export async function ensureDbIndexed(): Promise { await inflight; } + +export async function ensureDbIndexed(): Promise { + await ensureAudiobooksIndexed(); +} diff --git a/src/lib/server/docstore.ts b/src/lib/server/docstore.ts index 8ade6a3..79e76a7 100644 --- a/src/lib/server/docstore.ts +++ b/src/lib/server/docstore.ts @@ -11,6 +11,7 @@ export const AUDIOBOOKS_V1_DIR = path.join(DOCSTORE_DIR, 'audiobooks_v1'); export const AUDIOBOOKS_USERS_DIR = path.join(DOCSTORE_DIR, 'audiobooks_users_v1'); export const UNCLAIMED_USER_ID = 'unclaimed'; +const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; /** * Get the audiobook directory for a specific user when auth is enabled. @@ -34,15 +35,29 @@ export function getUnclaimedAudiobookDir(): string { } /** - * Get the full path to a specific audiobook directory. - * - When auth is disabled: docstore/audiobooks_v1/{bookId}-audiobook - * - When auth is enabled: docstore/audiobooks_users_v1/{userId}/{bookId}-audiobook + * Resolve the base audiobooks directory for request context, including optional test namespace. + * - When auth is disabled or userId is absent: docstore/audiobooks_v1[/] + * - When auth is enabled: docstore/audiobooks_users_v1/{userId}[/] */ -export function getAudiobookPath(bookId: string, userId: string | null, authEnabled: boolean): string { - if (!authEnabled || !userId) { - return path.join(AUDIOBOOKS_V1_DIR, `${bookId}-audiobook`); - } - return path.join(getUserAudiobookDir(userId), `${bookId}-audiobook`); +export function getAudiobooksRootDir({ + userId, + authEnabled, + namespace, +}: { + userId: string | null; + authEnabled: boolean; + namespace: string | null; +}): string { + const baseDir = !authEnabled || !userId ? AUDIOBOOKS_V1_DIR : getUserAudiobookDir(userId); + if (!namespace) return baseDir; + + const safe = namespace.trim(); + if (!safe || safe === '.' || safe === '..' || safe.includes('..')) return baseDir; + if (!SAFE_NAMESPACE_REGEX.test(safe)) return baseDir; + + const resolved = path.resolve(baseDir, safe); + if (!resolved.startsWith(path.resolve(baseDir) + path.sep)) return baseDir; + return resolved; } /** @@ -210,15 +225,6 @@ export function getMigratedDocumentFileName(id: string, name: string): string { return targetFileName; } -export type FSDocument = { - id: string; - name: string; - type: string; - size: number; - lastModified: number; - filePath: string; -}; - export async function ensureDocumentsV1Ready(): Promise { await mkdir(DOCSTORE_DIR, { recursive: true }); await mkdir(DOCUMENTS_V1_DIR, { recursive: true }); diff --git a/src/lib/server/documents-blobstore.ts b/src/lib/server/documents-blobstore.ts new file mode 100644 index 0000000..dad8092 --- /dev/null +++ b/src/lib/server/documents-blobstore.ts @@ -0,0 +1,222 @@ +import { + DeleteObjectCommand, + DeleteObjectsCommand, + GetObjectCommand, + HeadObjectCommand, + ListObjectsV2Command, + PutObjectCommand, +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { getS3Client, getS3Config } from '@/lib/server/s3'; + +const DOCUMENT_ID_REGEX = /^[a-f0-9]{64}$/i; +const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; + +function sanitizeNamespace(namespace: string | null): string | null { + if (!namespace) return null; + if (!SAFE_NAMESPACE_REGEX.test(namespace)) return null; + return namespace; +} + +function isNodeReadableStream(value: unknown): value is NodeJS.ReadableStream { + return !!value && typeof value === 'object' && 'on' in value && typeof (value as NodeJS.ReadableStream).on === 'function'; +} + +async function streamToBuffer(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + if (Buffer.isBuffer(chunk)) { + chunks.push(chunk); + } else if (typeof chunk === 'string') { + chunks.push(Buffer.from(chunk)); + } else { + chunks.push(Buffer.from(chunk as Uint8Array)); + } + } + return Buffer.concat(chunks); +} + +async function bodyToBuffer(body: unknown): Promise { + if (!body) return Buffer.alloc(0); + + if (body instanceof Uint8Array) return Buffer.from(body); + if (ArrayBuffer.isView(body)) return Buffer.from(body.buffer, body.byteOffset, body.byteLength); + if (body instanceof ArrayBuffer) return Buffer.from(body); + + if (typeof body === 'object' && body !== null && 'transformToByteArray' in body) { + const maybe = body as { transformToByteArray?: () => Promise }; + if (typeof maybe.transformToByteArray === 'function') { + return Buffer.from(await maybe.transformToByteArray()); + } + } + + if (isNodeReadableStream(body)) { + return streamToBuffer(body); + } + + throw new Error('Unsupported S3 response body type'); +} + +export function isValidDocumentId(id: string): boolean { + return DOCUMENT_ID_REGEX.test(id); +} + +export function documentKey(id: string, namespace: string | null): string { + if (!isValidDocumentId(id)) { + throw new Error(`Invalid document id: ${id}`); + } + + const cfg = getS3Config(); + const ns = sanitizeNamespace(namespace); + const nsSegment = ns ? `ns/${ns}/` : ''; + return `${cfg.prefix}/documents_v1/${nsSegment}${id}`; +} + +export async function presignPut( + id: string, + contentType: string, + namespace: string | null, +): Promise<{ url: string; headers: Record }> { + const cfg = getS3Config(); + const client = getS3Client(); + const key = documentKey(id, namespace); + const normalizedType = (contentType || 'application/octet-stream').trim() || 'application/octet-stream'; + + const command = new PutObjectCommand({ + Bucket: cfg.bucket, + Key: key, + ContentType: normalizedType, + IfNoneMatch: '*', + }); + const url = await getSignedUrl(client, command, { expiresIn: 60 * 5 }); + + return { + url, + headers: { + 'Content-Type': normalizedType, + 'If-None-Match': '*', + }, + }; +} + +export async function headDocumentBlob( + id: string, + namespace: string | null, +): Promise<{ contentLength: number; contentType: string | null; eTag: string | null }> { + const cfg = getS3Config(); + const client = getS3Client(); + const key = documentKey(id, namespace); + const res = await client.send(new HeadObjectCommand({ Bucket: cfg.bucket, Key: key })); + return { + contentLength: Number(res.ContentLength ?? 0), + contentType: res.ContentType ?? null, + eTag: res.ETag ?? null, + }; +} + +export async function getDocumentRange( + id: string, + start: number, + endInclusive: number, + namespace: string | null, +): Promise { + const cfg = getS3Config(); + const client = getS3Client(); + const key = documentKey(id, namespace); + const res = await client.send( + new GetObjectCommand({ + Bucket: cfg.bucket, + Key: key, + Range: `bytes=${Math.max(0, start)}-${Math.max(0, endInclusive)}`, + }), + ); + return bodyToBuffer(res.Body); +} + +export async function getDocumentBlob(id: string, namespace: string | null): Promise { + const cfg = getS3Config(); + const client = getS3Client(); + const key = documentKey(id, namespace); + const res = await client.send( + new GetObjectCommand({ + Bucket: cfg.bucket, + Key: key, + }), + ); + return bodyToBuffer(res.Body); +} + +export async function putDocumentBlob( + id: string, + body: Buffer, + contentType: string, + namespace: string | null, +): Promise { + const cfg = getS3Config(); + const client = getS3Client(); + const key = documentKey(id, namespace); + await client.send( + new PutObjectCommand({ + Bucket: cfg.bucket, + Key: key, + Body: body, + ContentType: contentType, + IfNoneMatch: '*', + }), + ); +} + +export async function deleteDocumentBlob(id: string, namespace: string | null): Promise { + const cfg = getS3Config(); + const client = getS3Client(); + const key = documentKey(id, namespace); + await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: key })); +} + +export function isMissingBlobError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const maybe = error as { name?: string; Code?: string; $metadata?: { httpStatusCode?: number } }; + if (maybe.$metadata?.httpStatusCode === 404) return true; + if (maybe.name === 'NotFound' || maybe.name === 'NoSuchKey') return true; + if (maybe.Code === 'NotFound' || maybe.Code === 'NoSuchKey') return true; + return false; +} + +export async function deleteDocumentPrefix(prefix: string): Promise { + const cfg = getS3Config(); + const client = getS3Client(); + const cleanedPrefix = prefix.replace(/^\/+/, ''); + let deleted = 0; + let continuationToken: string | undefined; + + do { + const listRes = await client.send( + new ListObjectsV2Command({ + Bucket: cfg.bucket, + Prefix: cleanedPrefix, + ContinuationToken: continuationToken, + }), + ); + + const keys = (listRes.Contents ?? []) + .map((item) => item.Key) + .filter((value): value is string => typeof value === 'string' && value.length > 0); + + if (keys.length > 0) { + const deleteRes = await client.send( + new DeleteObjectsCommand({ + Bucket: cfg.bucket, + Delete: { + Objects: keys.map((Key) => ({ Key })), + Quiet: true, + }, + }), + ); + deleted += deleteRes.Deleted?.length ?? 0; + } + + continuationToken = listRes.IsTruncated ? listRes.NextContinuationToken : undefined; + } while (continuationToken); + + return deleted; +} diff --git a/src/lib/server/documents-utils.ts b/src/lib/server/documents-utils.ts index 1d4bf1f..982c5a9 100644 --- a/src/lib/server/documents-utils.ts +++ b/src/lib/server/documents-utils.ts @@ -1,11 +1,6 @@ import path from 'path'; -import { utimes } from 'fs/promises'; import type { DocumentType } from '@/types/documents'; -export function isEnoent(error: unknown): boolean { - return typeof error === 'object' && error !== null && 'code' in error && (error as { code?: unknown }).code === 'ENOENT'; -} - export function safeDocumentName(rawName: string, fallback: string): string { const baseName = path.basename(rawName || fallback); return baseName.replaceAll('\u0000', '').slice(0, 240) || fallback; @@ -18,16 +13,3 @@ export function toDocumentTypeFromName(name: string): DocumentType { if (ext === '.docx') return 'docx'; return 'html'; } - -export async function trySetFileMtime(filePath: string, lastModifiedMs: number): Promise { - if (!Number.isFinite(lastModifiedMs)) return; - const mtime = new Date(lastModifiedMs); - if (Number.isNaN(mtime.getTime())) return; - - try { - await utimes(filePath, mtime, mtime); - } catch (error) { - console.warn('Failed to set document mtime:', filePath, error); - } -} - diff --git a/src/lib/server/rate-limiter.ts b/src/lib/server/rate-limiter.ts index 2cf097a..a041ce4 100644 --- a/src/lib/server/rate-limiter.ts +++ b/src/lib/server/rate-limiter.ts @@ -111,6 +111,17 @@ export class RateLimiter { return this.isPostgres() ? new Date() : Date.now(); } + // Use a transaction only when running with Postgres. + // better-sqlite3 transactions require sync callbacks and cannot be awaited. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async runMutation(fn: (conn: any) => Promise): Promise { + if (this.isPostgres()) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return safeDb().transaction(async (tx: any) => fn(tx)); + } + return fn(safeDb()); + } + /** * Check if a user can use TTS and increment their char count if allowed */ @@ -147,16 +158,10 @@ export class RateLimiter { try { const updatedAt = this.getUpdatedAtValue() as unknown as UserTtsCharsUpdatedAtValue; - - // Use a DB transaction to avoid partial increments across buckets and to avoid - // non-transactional "rollback" logic that can corrupt counts under concurrency. - // Note: We intentionally allow a request to push a bucket over its limit; we only - // block when the bucket was already exhausted before this request starts updating. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return await safeDb().transaction(async (tx: any) => { + return await this.runMutation(async (conn) => { // Ensure records exist for each bucket for (const bucket of buckets) { - await tx.insert(userTtsChars) + await conn.insert(userTtsChars) .values({ userId: bucket.key, date: dateValue, @@ -172,7 +177,7 @@ export class RateLimiter { // that start after the bucket is already exhausted, while still allowing a // request to push the count over the limit. for (const bucket of buckets) { - const updateResult = await tx.update(userTtsChars) + const updateResult = await conn.update(userTtsChars) .set({ charCount: sql`${userTtsChars.charCount} + ${charCount}`, updatedAt, @@ -191,7 +196,7 @@ export class RateLimiter { // Fetch current counts const bucketResults: Array<{ currentCount: number; limit: number }> = []; for (const bucket of buckets) { - const result = await tx.select({ currentCount: userTtsChars.charCount }) + const result = await conn.select({ currentCount: userTtsChars.charCount }) .from(userTtsChars) .where(and(eq(userTtsChars.userId, bucket.key), eq(userTtsChars.date, dateValue))); diff --git a/src/lib/server/s3.ts b/src/lib/server/s3.ts new file mode 100644 index 0000000..efd9f8a --- /dev/null +++ b/src/lib/server/s3.ts @@ -0,0 +1,82 @@ +import { S3Client } from '@aws-sdk/client-s3'; + +type S3Config = { + bucket: string; + region: string; + endpoint?: string; + accessKeyId: string; + secretAccessKey: string; + forcePathStyle: boolean; + prefix: string; +}; + +let cachedClient: S3Client | null = null; +let cachedConfig: S3Config | null = null; + +function parseBool(value: string | undefined): boolean { + if (!value) return false; + const normalized = value.trim().toLowerCase(); + return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; +} + +function normalizePrefix(prefix: string | undefined): string { + const base = (prefix || 'openreader').trim(); + if (!base) return 'openreader'; + return base.replace(/^\/+|\/+$/g, ''); +} + +function loadS3ConfigFromEnv(): S3Config | null { + const bucket = process.env.S3_BUCKET?.trim(); + const region = process.env.S3_REGION?.trim(); + const accessKeyId = process.env.S3_ACCESS_KEY_ID?.trim(); + const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY?.trim(); + const endpoint = process.env.S3_ENDPOINT?.trim(); + + if (!bucket || !region || !accessKeyId || !secretAccessKey) { + return null; + } + + return { + bucket, + region, + endpoint: endpoint || undefined, + accessKeyId, + secretAccessKey, + forcePathStyle: parseBool(process.env.S3_FORCE_PATH_STYLE), + prefix: normalizePrefix(process.env.S3_PREFIX), + }; +} + +export function isS3Configured(): boolean { + return loadS3ConfigFromEnv() !== null; +} + +export function getS3Config(): S3Config { + if (cachedConfig) return cachedConfig; + const config = loadS3ConfigFromEnv(); + if (!config) { + throw new Error( + 'S3 is not configured. Required env vars: S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY.', + ); + } + cachedConfig = config; + return config; +} + +export function getS3Client(): S3Client { + if (cachedClient) return cachedClient; + const config = getS3Config(); + + cachedClient = new S3Client({ + region: config.region, + endpoint: config.endpoint, + forcePathStyle: config.forcePathStyle, + credentials: { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + }, + }); + + return cachedClient; +} + diff --git a/tests/export.spec.ts b/tests/export.spec.ts index 39cbd64..05fbe1e 100644 --- a/tests/export.spec.ts +++ b/tests/export.spec.ts @@ -251,7 +251,8 @@ test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async await startGeneration(page); // Progress card should appear with a Cancel button while chapters are being generated - const cancelButton = page.getByRole('button', { name: 'Cancel' }); + const generationCard = page.locator('div', { hasText: 'Generating Audiobook' }).first(); + const cancelButton = generationCard.getByRole('button', { name: 'Cancel' }); await expect(cancelButton).toBeVisible({ timeout: 60_000 }); await expect(page.getByRole('heading', { name: 'Chapters' })).toBeVisible({ timeout: 60_000 }); @@ -266,8 +267,10 @@ test('exports partial MP3 audiobook for EPUB using mocked 10s TTS sample', async // Now cancel the in-flight generation await cancelButton.click(); - // After cancellation, the inline progress card's Cancel button should be gone - await expect(page.getByRole('button', { name: 'Cancel' })).toHaveCount(0); + // Cancellation is asynchronous: wait for generation to settle before asserting + // that the inline progress card has disappeared. + await expect(page.getByRole('button', { name: 'Resume' })).toBeVisible({ timeout: 30_000 }); + await expect(generationCard).toHaveCount(0, { timeout: 30_000 }); // After cancellation, wait for the chapter count to stabilize. In-flight TTS // requests may still complete after we click cancel, so we poll until the diff --git a/tests/global-teardown.ts b/tests/global-teardown.ts index f8040c4..fb2bea2 100644 --- a/tests/global-teardown.ts +++ b/tests/global-teardown.ts @@ -1,29 +1,11 @@ -import fs from 'fs/promises'; -import path from 'path'; - -async function listWorkerNamespaces(documentsRoot: string): Promise { - let entries: Array<{ name: string; isDirectory: () => boolean }> = []; - try { - entries = await fs.readdir(documentsRoot, { withFileTypes: true }); - } catch { - return []; - } - - return entries - .filter((d) => d.isDirectory()) - .map((d) => d.name) - .filter((name) => /^(chromium|firefox|webkit)-worker\d+$/.test(name)); -} +import { deleteDocumentPrefix } from '../src/lib/server/documents-blobstore'; +import { getS3Config, isS3Configured } from '../src/lib/server/s3'; export default async function globalTeardown(): Promise { - const documentsRoot = path.join(process.cwd(), 'docstore', 'documents_v1'); - const namespaces = await listWorkerNamespaces(documentsRoot); - if (!namespaces.length) return; + if (!isS3Configured()) return; - await Promise.all( - namespaces.map(async (ns) => { - const dir = path.join(documentsRoot, ns); - await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); - }), - ); + const config = getS3Config(); + const nsRootPrefix = `${config.prefix}/documents_v1/ns/`; + await deleteDocumentPrefix(nsRootPrefix); } + diff --git a/tests/unit/transfer-user-documents.spec.ts b/tests/unit/transfer-user-documents.spec.ts index 290c3f5..715bf5f 100644 --- a/tests/unit/transfer-user-documents.spec.ts +++ b/tests/unit/transfer-user-documents.spec.ts @@ -7,8 +7,8 @@ import { transferUserDocuments } from '../../src/lib/server/claim-data'; test.describe('transferUserDocuments', () => { test('moves document rows to new user without PK conflicts', async () => { - process.env.BETTER_AUTH_URL = 'http://localhost:3003'; - process.env.BETTER_AUTH_SECRET = 'test-secret'; + process.env.BASE_URL = 'http://localhost:3003'; + process.env.AUTH_SECRET = 'test-secret'; const sqlite = new Database(':memory:'); sqlite.exec(` @@ -72,4 +72,3 @@ test.describe('transferUserDocuments', () => { expect(ids).toEqual(['doc-a', 'doc-b']); }); }); - From 6d5fb65a725a21a5743f3da9e7820e8266ce1035 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 10 Feb 2026 15:11:37 -0700 Subject: [PATCH 17/55] feat(docs): add configurable rate limiting and external docs Add environment variables for fine-grained control over TTS rate limiting and Better Auth behavior. Move documentation to external Docusaurus site with automated deployment workflows. - TTS rate limiting can now be enabled/disabled via TTS_ENABLE_RATE_LIMIT - Customizable daily limits for anonymous/authenticated users and IP backstops - Better Auth rate limiting can be disabled via DISABLE_AUTH_RATE_LIMIT - Rename library import env vars to IMPORT_LIBRARY_DIRS/DIR - Add docs-site with Docusaurus and GitHub Actions workflows - Update README to reference external documentation --- .env.example | 30 +- .github/workflows/docs-check.yml | 37 + .github/workflows/docs-deploy.yml | 61 + .github/workflows/docs-version-on-tag.yml | 77 + README.md | 472 +- docs-site/.gitignore | 3 + docs-site/custom.css | 134 + docs-site/docs/community/acknowledgements.md | 16 + docs-site/docs/community/license.md | 7 + docs-site/docs/community/support.md | 19 + .../getting-started/docker-quick-start.md | 73 + .../docs/getting-started/local-development.md | 117 + docs-site/docs/guides/configuration.md | 19 + .../docs/guides/environment-variables.md | 280 + .../docs/guides/storage-and-blob-behavior.md | 44 + docs-site/docs/guides/tts-providers.md | 48 + docs-site/docs/guides/tts-rate-limiting.md | 51 + docs-site/docs/integrations/custom-openai.md | 28 + docs-site/docs/integrations/deepinfra.md | 19 + docs-site/docs/integrations/kokoro-fastapi.md | 49 + docs-site/docs/integrations/openai.md | 18 + .../docs/integrations/orpheus-fastapi.md | 23 + docs-site/docs/intro.md | 40 + .../operations/database-and-migrations.md | 52 + docs-site/docs/reference/stack.md | 41 + docs-site/docusaurus.config.ts | 122 + docs-site/package.json | 32 + docs-site/pnpm-lock.yaml | 12621 ++++++++++++++++ docs-site/sidebars.ts | 53 + docs-site/static/CNAME | 1 + docs-site/static/robots.txt | 2 + docs-site/tsconfig.json | 6 + package.json | 8 +- src/app/api/rate-limit/status/route.ts | 10 +- src/app/api/tts/route.ts | 103 +- src/lib/server/auth.ts | 15 +- src/lib/server/library.ts | 2 +- src/lib/server/rate-limiter.ts | 40 +- tsconfig.json | 2 +- 39 files changed, 14268 insertions(+), 507 deletions(-) create mode 100644 .github/workflows/docs-check.yml create mode 100644 .github/workflows/docs-deploy.yml create mode 100644 .github/workflows/docs-version-on-tag.yml create mode 100644 docs-site/.gitignore create mode 100644 docs-site/custom.css create mode 100644 docs-site/docs/community/acknowledgements.md create mode 100644 docs-site/docs/community/license.md create mode 100644 docs-site/docs/community/support.md create mode 100644 docs-site/docs/getting-started/docker-quick-start.md create mode 100644 docs-site/docs/getting-started/local-development.md create mode 100644 docs-site/docs/guides/configuration.md create mode 100644 docs-site/docs/guides/environment-variables.md create mode 100644 docs-site/docs/guides/storage-and-blob-behavior.md create mode 100644 docs-site/docs/guides/tts-providers.md create mode 100644 docs-site/docs/guides/tts-rate-limiting.md create mode 100644 docs-site/docs/integrations/custom-openai.md create mode 100644 docs-site/docs/integrations/deepinfra.md create mode 100644 docs-site/docs/integrations/kokoro-fastapi.md create mode 100644 docs-site/docs/integrations/openai.md create mode 100644 docs-site/docs/integrations/orpheus-fastapi.md create mode 100644 docs-site/docs/intro.md create mode 100644 docs-site/docs/operations/database-and-migrations.md create mode 100644 docs-site/docs/reference/stack.md create mode 100644 docs-site/docusaurus.config.ts create mode 100644 docs-site/package.json create mode 100644 docs-site/pnpm-lock.yaml create mode 100644 docs-site/sidebars.ts create mode 100644 docs-site/static/CNAME create mode 100644 docs-site/static/robots.txt create mode 100644 docs-site/tsconfig.json diff --git a/.env.example b/.env.example index 8db86f8..dba84d8 100644 --- a/.env.example +++ b/.env.example @@ -6,26 +6,48 @@ NEXT_PUBLIC_NODE_ENV=development # Not availble in Docker containers (due to bei API_BASE=http://localhost:8880/v1 API_KEY=api_key_optional +# (Optional) TTS request/cache tuning (leave unset to use defaults) +# TTS_CACHE_MAX_SIZE_BYTES=268435456 # 256MB +# TTS_CACHE_TTL_MS=1800000 # 30 minutes +# TTS_MAX_RETRIES=2 +# TTS_RETRY_INITIAL_MS=250 +# TTS_RETRY_MAX_MS=2000 +# TTS_RETRY_BACKOFF=2 + +# (Optional) Enable TTS character rate limiting (default is `false`) +# TTS_ENABLE_RATE_LIMIT=true +# (Optional) TTS per-user daily limits (leave unset to use defaults) +# TTS_DAILY_LIMIT_ANONYMOUS=50000 +# TTS_DAILY_LIMIT_AUTHENTICATED=500000 +# (Optional) TTS IP backstop daily limits (leave unset to use defaults) +# TTS_IP_DAILY_LIMIT_ANONYMOUS=100000 +# TTS_IP_DAILY_LIMIT_AUTHENTICATED=1000000 + # Path to your local whisper.cpp CLI binary for STT timestamp generation WHISPER_CPP_BIN=/whisper.cpp/build/bin/whisper-cli -# Auth (recommended for contributors and public instances) -# (Optional) Auth is only enabled when **both** AUTH_SECRET and BASE_URL are set +# Auth configuration (recommended for contributors and public instances) +# (Optional) Auth is only enabled when AUTH_SECRET and BASE_URL are set BASE_URL=http://localhost:3003 # Externally facing URL for this app (set to LAN IP for access from other devices on the network) AUTH_SECRET=some_random_secret_key # Generate with `openssl rand -base64 32` AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 # Additional trusted origins (BASE_URL is always trusted) # (Optional) Sign in w/ GitHub Configuration GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= +# (Optional) Disable Better Auth built-in rate limiting (useful for testing) +DISABLE_AUTH_RATE_LIMIT= # Backend DB used for server-side metadata (documents/audiobooks) and, when auth is enabled, auth tables. # Defaults to SQLite at docstore/sqlite3.db when not set. POSTGRES_URL= +# (Optional) Skip automatic startup migrations when set to `false` (default: `true`) +RUN_DB_MIGRATIONS= # Embedded SeaweedFS weed mini config USE_EMBEDDED_WEED_MINI= WEED_MINI_DIR= WEED_MINI_WAIT_SEC= + # S3 storage config (use with embedded weed mini or external S3-compatible storage) # For embedded weed mini, set explicit keys if you want stable credentials across restarts. S3_ACCESS_KEY_ID= @@ -36,3 +58,7 @@ S3_REGION= S3_ENDPOINT= S3_FORCE_PATH_STYLE= S3_PREFIX= + +# Server library import roots (optional, uses /docstore/library by default) +IMPORT_LIBRARY_DIR= +IMPORT_LIBRARY_DIRS= diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml new file mode 100644 index 0000000..e3227e2 --- /dev/null +++ b/.github/workflows/docs-check.yml @@ -0,0 +1,37 @@ +name: Docs Check + +on: + pull_request: + branches: [main, master] + paths: + - 'docs-site/**' + - '.github/workflows/docs-check.yml' + - '.github/workflows/docs-deploy.yml' + - 'README.md' + - 'package.json' + +jobs: + docs-build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: docs-site/pnpm-lock.yaml + + - name: Install docs dependencies + run: pnpm --dir docs-site install --frozen-lockfile + + - name: Build docs + run: pnpm --dir docs-site build diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml new file mode 100644 index 0000000..f86875b --- /dev/null +++ b/.github/workflows/docs-deploy.yml @@ -0,0 +1,61 @@ +name: Docs Deploy + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: docs-pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: docs-site/pnpm-lock.yaml + + - name: Configure Pages + uses: actions/configure-pages@v5 + + - name: Install docs dependencies + run: pnpm --dir docs-site install --frozen-lockfile + + - name: Build docs + run: pnpm --dir docs-site build + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs-site/build + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + needs: build + runs-on: ubuntu-latest + + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/docs-version-on-tag.yml b/.github/workflows/docs-version-on-tag.yml new file mode 100644 index 0000000..f00da17 --- /dev/null +++ b/.github/workflows/docs-version-on-tag.yml @@ -0,0 +1,77 @@ +name: Docs Version on Tag + +on: + push: + tags: + - 'v*.*.*' + +permissions: + contents: write + pull-requests: write + +jobs: + version-docs: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: docs-site/pnpm-lock.yaml + + - name: Install docs dependencies + run: pnpm --dir docs-site install --frozen-lockfile + + - name: Snapshot docs version + env: + VERSION: ${{ github.ref_name }} + run: | + set -euo pipefail + if [ -f docs-site/versions.json ] && grep -q "\"${VERSION}\"" docs-site/versions.json; then + echo "Version ${VERSION} already exists in versions.json; skipping docs:version" + else + pnpm --dir docs-site docusaurus docs:version "${VERSION}" + fi + + - name: Set default docs version + env: + VERSION: ${{ github.ref_name }} + run: | + set -euo pipefail + node -e "const fs=require('fs'); const file='docs-site/docusaurus.config.ts'; const source=fs.readFileSync(file,'utf8'); const updated=source.replace(/lastVersion:\s*'[^']*',/, \"lastVersion: '\" + process.env.VERSION + \"',\"); if (source===updated) { throw new Error('Unable to update lastVersion in docusaurus.config.ts'); } fs.writeFileSync(file, updated);" + + - name: Build docs + run: pnpm --dir docs-site build + + - name: Create docs version PR + uses: peter-evans/create-pull-request@v6 + with: + commit-message: docs: snapshot ${{ github.ref_name }} + title: docs: snapshot ${{ github.ref_name }} + body: | + Automated docs version snapshot for `${{ github.ref_name }}`. + + - Ran `docusaurus docs:version` + - Updated `lastVersion` in `docs-site/docusaurus.config.ts` + branch: docs/version-${{ github.ref_name }} + base: main + delete-branch: true + labels: docs,automation + add-paths: | + docs-site/docusaurus.config.ts + docs-site/versions.json + docs-site/versioned_docs/** + docs-site/versioned_sidebars/** diff --git a/README.md b/README.md index 1ba04ba..2bf393e 100644 --- a/README.md +++ b/README.md @@ -1,450 +1,48 @@ -[![GitHub Stars](https://img.shields.io/github/stars/richardr1126/OpenReader-WebUI)](../../stargazers) -[![GitHub Forks](https://img.shields.io/github/forks/richardr1126/OpenReader-WebUI)](../../network/members) -[![GitHub Watchers](https://img.shields.io/github/watchers/richardr1126/OpenReader-WebUI)](../../watchers) -[![GitHub Issues](https://img.shields.io/github/issues/richardr1126/OpenReader-WebUI)](../../issues) -[![GitHub Last Commit](https://img.shields.io/github/last-commit/richardr1126/OpenReader-WebUI)](../../commits) -[![GitHub Release](https://img.shields.io/github/v/release/richardr1126/OpenReader-WebUI)](../../releases) +[![GitHub Release](https://img.shields.io/github/v/release/richardr1126/OpenReader-WebUI)](https://github.com/richardr1126/OpenReader-WebUI/releases) +[![License](https://img.shields.io/github/license/richardr1126/OpenReader-WebUI)](LICENSE) +[![Docs](https://img.shields.io/badge/docs-openreader--webui-0a6c74)](https://docs.openreader.richardr.dev/) +[![Playwright Tests](https://github.com/richardr1126/OpenReader-WebUI/actions/workflows/playwright.yml/badge.svg)](https://github.com/richardr1126/OpenReader-WebUI/actions/workflows/playwright.yml) +[![Docs Check](https://github.com/richardr1126/OpenReader-WebUI/actions/workflows/docs-check.yml/badge.svg)](https://github.com/richardr1126/OpenReader-WebUI/actions/workflows/docs-check.yml) -[![Discussions](https://img.shields.io/badge/Discussions-Ask%20a%20Question-blue)](../../discussions) +[![GitHub Stars](https://img.shields.io/github/stars/richardr1126/OpenReader-WebUI)](https://github.com/richardr1126/OpenReader-WebUI/stargazers) +[![GitHub Forks](https://img.shields.io/github/forks/richardr1126/OpenReader-WebUI)](https://github.com/richardr1126/OpenReader-WebUI/network/members) +[![Discussions](https://img.shields.io/badge/Discussions-Ask%20a%20Question-blue)](https://github.com/richardr1126/OpenReader-WebUI/discussions) # 📄🔊 OpenReader WebUI -OpenReader WebUI is an open source text to speech document reader web app built using Next.js, offering a TTS read along experience with narration for **EPUB, PDF, TXT, MD, and DOCX documents**. It supports multiple TTS providers including OpenAI, Deepinfra, and custom OpenAI-compatible endpoints like [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI) and [Orpheus-FastAPI](https://github.com/Lex-au/Orpheus-FastAPI) +OpenReader WebUI is an open source, self-host-friendly text-to-speech document reader built with Next.js for **EPUB, PDF, TXT, MD, and DOCX** with synchronized read-along playback. -- 🎯 **Multi-Provider TTS Support** - - [**Kokoro-FastAPI**](https://github.com/remsky/Kokoro-FastAPI): Supporting multi-voice combinations (like `af_heart+af_bella`) - - [**Orpheus-FastAPI**](https://github.com/Lex-au/Orpheus-FastAPI) - - **Custom OpenAI-compatible**: Any TTS API with `/v1/audio/voices` and `/v1/audio/speech` endpoints - - **Cloud TTS Providers (requiring API keys)** - - [**Deepinfra**](https://deepinfra.com/models/text-to-speech): Kokoro-82M + models with support for cloned voices and more - - [**OpenAI API ($$)**](https://platform.openai.com/docs/pricing#transcription-and-speech): tts-1, tts-1-hd, and gpt-4o-mini-tts w/ instructions -- 🛜 *(Updated)* **Server-side Sync and Storage** - - *(New)* **External Library Import** enables importing documents to the browser's storage from a folder mounted on the server - - *(Updated)* **Sync documents** between the browser and server to get them on other browsers or devices -- 🎧 **Server-side Audiobook Export** in **m4b/mp3**, with resumable, chapter-based export and regeneration -- 📖 **Read Along Experience** providing real-time text highlighting during playback (PDF/EPUB) - - *(New)* **Word-by-word** highlighting uses word-by-word timestamps generated server-side with [*whisper.cpp*](https://github.com/ggml-org/whisper.cpp) (optional) -- 🧠 **Smart Sentence-Aware Narration** merges sentences across pages/chapters for smoother TTS -- 🚀 **Optimized Next.js TTS Proxy** with audio caching and optimized repeat playback -- 🎨 **Customizable Experience** - - 🎨 Multiple app theme options - - ⚙️ Various TTS and document handling settings - - And more ... +> **Get started in the [docs](https://docs.openreader.richardr.dev/)**. -## 🐳 Docker Quick Start +## ✨ Highlights -### Prerequisites +- 🎯 **Multi-provider TTS** with OpenAI-compatible endpoints (OpenAI, DeepInfra, Kokoro, Orpheus, custom). +- 📖 **Read-along playback** for PDF/EPUB with sentence-aware narration. +- ⏱️ **Word-by-word highlighting** via optional `whisper.cpp` timestamps. +- 🛜 **Sync + library import** to bring docs across devices and from server-mounted folders. +- 🗂️ **Flexible storage** with embedded SeaweedFS or external S3-compatible backends. +- 🎧 **Audiobook export** in `m4b`/`mp3` with resumable chapter processing. +- 🐳 **Self-host friendly** with Docker, optional auth, and automatic startup migrations. -- Recent version of Docker installed on your machine -- A TTS API server (Kokoro-FastAPI, Orpheus-FastAPI, Deepinfra, OpenAI, etc.) running and accessible +## 🚀 Start Here -> **Note:** If you have good hardware, you can run [Kokoro-FastAPI with Docker locally](#🗣️-local-kokoro-fastapi-quick-start-cpu-or-gpu) (see below). +| Goal | Link | +| --- | --- | +| Run with Docker | [Docker Quick Start](https://docs.openreader.richardr.dev/getting-started/docker-quick-start) | +| Develop locally | [Local Development](https://docs.openreader.richardr.dev/getting-started/local-development) | +| Configure auth | [Auth](https://docs.openreader.richardr.dev/guides/configuration) | +| Configure SQL database | [SQL Database](https://docs.openreader.richardr.dev/operations/database-and-migrations) | +| Configure object storage | [Object / Blob Storage](https://docs.openreader.richardr.dev/guides/storage-and-blob-behavior) | +| Configure TTS providers | [TTS Providers](https://docs.openreader.richardr.dev/guides/tts-providers) | +| Run Kokoro locally | [Kokoro-FastAPI](https://docs.openreader.richardr.dev/integrations/kokoro-fastapi) | +| Get support or contribute | [Support and Contributing](https://docs.openreader.richardr.dev/community/support) | -### 1. 🐳 Start the Docker container +## 🧭 Community - Minimal (auth disabled, embedded storage is ephemeral, no library import): +- Questions and ideas: [GitHub Discussions](https://github.com/richardr1126/OpenReader-WebUI/discussions) +- Bug reports: [GitHub Issues](https://github.com/richardr1126/OpenReader-WebUI/issues) +- Contributions: open a pull request - ```bash - docker run --name openreader-webui \ - --restart unless-stopped \ - -p 3003:3003 \ - -p 8333:8333 \ - ghcr.io/richardr1126/openreader-webui:latest - ``` +## 📜 License - Fully featured (persistent storage, embedded SeaweedFS `weed mini` for documents, optional auth): - - ```bash - docker run --name openreader-webui \ - --restart unless-stopped \ - -p 3003:3003 \ - -p 8333:8333 \ - -v openreader_docstore:/app/docstore \ - -v /path/to/your/library:/app/docstore/library:ro \ - -e API_BASE=http://host.docker.internal:8880/v1 \ - -e API_KEY=none \ - -e BASE_URL=http://localhost:3003 \ - -e AUTH_SECRET= \ - ghcr.io/richardr1126/openreader-webui:latest - ``` - - You can remove the `/app/docstore/library` mount if you don't need server library import. - You can remove either `BASE_URL` or `AUTH_SECRET` to keep auth disabled. - - Quick notes: - - `API_BASE` should point to your TTS API server's base URL (for local Docker Kokoro, use `http://host.docker.internal:8880/v1`). - - Expose `-p 8333:8333` for direct browser access to embedded SeaweedFS presigned URLs. - - If port `8333` is not exposed, uploads still work via `/api/documents/blob/upload/fallback`. - - To enable auth, set both `BASE_URL` and `AUTH_SECRET`. - -
- Docker networking and blob behavior (Click to expand) - - **Ports** - - - `3003` serves the OpenReader app and API routes. - - `8333` serves embedded SeaweedFS S3 for browser direct blob access. - - **Upload behavior** - - - Primary upload path: browser uploads to presigned URL from `/api/documents/blob/upload/presign`. - - Fallback upload path: `/api/documents/blob/upload/fallback` if direct upload fails. - - Content serving path: `/api/documents/blob` (server-served bytes/snippets). - -
- -
- Which Docker setup should I use? (Click to expand) - - | Goal | Recommended setup | - | --- | --- | - | Fast local setup, no persistence | Minimal `docker run` example | - | Persistent docs/audiobooks and optional auth | Fully featured `docker run` example with `/app/docstore` volume | - | Browser direct blob uploads/downloads | Publish both `3003` and `8333` | - | Private blob endpoint (no `8333` published) | Keep using app APIs; uploads use fallback proxy when direct presigned upload is unreachable | - -
- -
- Common Docker environment variables (Click to expand) - - | Variable | Purpose | Example / Notes | - | --- | --- | --- | - | `API_BASE` | Default TTS API base URL (server-side) | `http://host.docker.internal:8880/v1` | - | `API_KEY` | Default TTS API key | `none` or your provider key | - | `BASE_URL` | Enables auth when set with `AUTH_SECRET` | External URL for this app, e.g. `http://localhost:3003` or `https://reader.example.com` | - | `AUTH_SECRET` | Enables auth when set with `BASE_URL` | Generate with `openssl rand -base64 32` | - | `AUTH_TRUSTED_ORIGINS` | Extra allowed auth request origins | Comma-separated list; leave empty to trust only `BASE_URL` | - | `POSTGRES_URL` | Use Postgres for server DB (metadata + auth tables) instead of SQLite | If set, startup migrations target Postgres | - | `GITHUB_CLIENT_ID` | Optional GitHub OAuth sign-in | Requires `GITHUB_CLIENT_SECRET` | - | `GITHUB_CLIENT_SECRET` | Optional GitHub OAuth sign-in | Requires `GITHUB_CLIENT_ID` | - -
- -
- Blob and embedded storage environment variables (Click to expand) - - | Variable | Purpose | Example / Notes | - | --- | --- | --- | - | `USE_EMBEDDED_WEED_MINI` | Start SeaweedFS before app startup | default: `true` when unset in shared entrypoint | - | `WEED_MINI_DIR` | Data directory for embedded `weed mini` | default: `docstore/seaweedfs` | - | `WEED_MINI_WAIT_SEC` | Startup wait timeout for embedded `weed mini` | default: `20` | - | `S3_BUCKET` | S3 bucket used for document blobs | default: `openreader-documents` in embedded mode | - | `S3_REGION` | S3 region used by AWS SDK | default: `us-east-1` in embedded mode | - | `S3_ENDPOINT` | Custom endpoint for S3-compatible providers | default: `http://:8333` when `BASE_URL` is set, otherwise detected LAN host | - | `S3_ACCESS_KEY_ID` | S3 access key | auto-generated in embedded mode if unset | - | `S3_SECRET_ACCESS_KEY` | S3 secret key | auto-generated in embedded mode if unset | - | `S3_FORCE_PATH_STYLE` | Force path-style addressing | default: `true` in embedded mode | - | `S3_PREFIX` | Prefix for stored object keys | default: `openreader` | - -
- -
- Docker volume mounts (Click to expand) - - | Mount | Type | Recommended | Purpose | Example | - | --- | --- | --- | --- | --- | - | `/app/docstore` | Docker named volume | Yes (if you want persistence) | Persists embedded SeaweedFS blob data (`docstore/seaweedfs`), SQLite metadata DB (`docstore/sqlite3.db` when `POSTGRES_URL` is unset), audiobook export artifacts, and migration/runtime files | `-v openreader_docstore:/app/docstore` | - | `/app/docstore/library` | Bind mount (host folder) | Optional + `:ro` | Read-only source directory for **Server Library Import**; files are imported/copied into browser storage, not modified in place | `-v /path/to/your/library:/app/docstore/library:ro` | - - To import from the mounted library: **Settings → Documents → Server Library Import** - - > **Note:** Every file in the mounted library is imported into the client browser's storage. Keep the library reasonably sized to avoid performance issues. - -
- - Visit [http://localhost:3003](http://localhost:3003) to run the app and set your settings. - -### 2. ⚙️ Configure the app settings in the UI - -- Set the TTS Provider and Model in the Settings modal -- Set the TTS API Base URL and API Key if needed (more secure to set in env vars) -- Select your model's voice from the dropdown (voices try to be fetched from TTS Provider API) - -### 3. ⬆️ Updating Docker Image - -```bash -docker stop openreader-webui || true && \ -docker rm openreader-webui || true && \ -docker image rm ghcr.io/richardr1126/openreader-webui:latest || true && \ -docker pull ghcr.io/richardr1126/openreader-webui:latest -``` - -### 🗣️ Local Kokoro-FastAPI Quick-start (CPU or GPU) - -You can run the Kokoro TTS API server directly with Docker. **We are not responsible for issues with [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI).** For best performance, use an NVIDIA GPU (for GPU version) or Apple Silicon (for CPU version). - -
- - -**Kokoro-FastAPI (CPU)** - - - -```bash -docker run -d \ - --name kokoro-tts \ - --restart unless-stopped \ - -p 8880:8880 \ - -e ONNX_NUM_THREADS=8 \ - -e ONNX_INTER_OP_THREADS=4 \ - -e ONNX_EXECUTION_MODE=parallel \ - -e ONNX_OPTIMIZATION_LEVEL=all \ - -e ONNX_MEMORY_PATTERN=true \ - -e ONNX_ARENA_EXTEND_STRATEGY=kNextPowerOfTwo \ - -e API_LOG_LEVEL=DEBUG \ - ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.4 -``` - -> Adjust environment variables as needed for your hardware and use case. - -
- -
- - -**Kokoro-FastAPI (GPU)** - - - -```bash -docker run -d \ - --name kokoro-tts \ - --gpus all \ - --user 1001:1001 \ - --restart unless-stopped \ - -p 8880:8880 \ - -e USE_GPU=true \ - -e PYTHONUNBUFFERED=1 \ - -e API_LOG_LEVEL=DEBUG \ - ghcr.io/remsky/kokoro-fastapi-gpu:v0.2.4 -``` - -> Adjust environment variables as needed for your hardware and use case. - -
- -> **⚠️ Important Notes:** -> -> - For best results, set the `-e API_BASE=` for OpenReader's Docker to `http://kokoro-tts:8880/v1` -> - For issues or support, see the [Kokoro-FastAPI repository](https://github.com/remsky/Kokoro-FastAPI). -> - The GPU version requires NVIDIA Docker support and works best with NVIDIA GPUs. The CPU version works best on Apple Silicon or modern x86 CPUs. - -## Local Development Installation - -### Prerequisites - -- Node.js (recommended: use [nvm](https://github.com/nvm-sh/nvm)) -- pnpm (recommended) or npm - - ```bash - npm install -g pnpm - ``` - -- A TTS API server (Kokoro-FastAPI, Orpheus-FastAPI, Deepinfra, OpenAI, etc.) running and accessible -- [SeaweedFS](https://github.com/seaweedfs/seaweedfs) `weed` binary (required) - - ```bash - brew install seaweedfs - ``` - - > **Note:** Verify install with `weed version`. - -#### Optionally required for different features: -- [FFmpeg](https://ffmpeg.org) (required for audiobook m4b creation only) - - ```bash - brew install ffmpeg - ``` - -- [libreoffice](https://www.libreoffice.org) (required for DOCX files) - - ```bash - brew install libreoffice - ``` - -- [whisper.cpp](https://github.com/ggml-org/whisper.cpp) (optional, required for word-by-word highlighting) - - ```bash - # clone and build whisper.cpp (no model download needed – OpenReader handles that) - git clone https://github.com/ggml-org/whisper.cpp.git - cd whisper.cpp - cmake -B build - cmake --build build -j --config Release - - # point OpenReader to the compiled whisper-cli binary - echo WHISPER_CPP_BIN=\"$(pwd)/build/bin/whisper-cli\" - ``` - - > **Note:** The `WHISPER_CPP_BIN` path should be set in your `.env` file for OpenReader to use word-by-word highlighting features. - -### Steps - -1. Clone the repository: - - ```bash - git clone https://github.com/richardr1126/OpenReader-WebUI.git - cd OpenReader-WebUI - ``` - -2. Install dependencies: - - With pnpm (recommended): - - ```bash - pnpm i # or npm i - ``` - -3. Configure the environment: - - ```bash - cp .env.example .env - # Edit .env with your configuration settings - ``` - - Auth is recommended for contributors and is enabled when **both** values are set: - - - Set `BASE_URL` to your local URL (default: `http://localhost:3003`) - - Generate a `AUTH_SECRET` and paste it into `.env`: - - ```bash - openssl rand -base64 32 - ``` - - (Optional) If you use both localhost and LAN URL, set `AUTH_TRUSTED_ORIGINS` (leave empty to trust only `BASE_URL`): - - ```bash - AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://192.168.0.116:3003 - ``` - - The embedded weed mini object store is enabled by default for local development, and started through the shared entrypoint. The ACCESS_KEY_ID and SECRET_ACCESS_KEY are auto-generated if unset, but for stable credentials across restarts, you can generate and set them in `.env`: - - (Optional) Generate `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY` and paste them into `.env` for stable credentials: - - ```bash - S3_ACCESS_KEY_ID=<`openssl rand -hex 16`> - S3_SECRET_ACCESS_KEY=<`openssl rand -hex 32`> - ``` - - (Optional) Connect to external S3-compatible storage instead of embedded `weed mini`: - - ```bash - USE_EMBEDDED_WEED_MINI=false - - # Required - S3_BUCKET=your-bucket - S3_REGION=us-east-1 - S3_ACCESS_KEY_ID=your-access-key - S3_SECRET_ACCESS_KEY=your-secret-key - - # Optional / provider-specific - S3_ENDPOINT= - S3_FORCE_PATH_STYLE= - S3_PREFIX=openreader - ``` - - Notes: - - For AWS S3: usually leave `S3_ENDPOINT` empty and `S3_FORCE_PATH_STYLE` empty/false. - - For MinIO/SeaweedFS/R2/B2 S3: set `S3_ENDPOINT` to your endpoint URL and set `S3_FORCE_PATH_STYLE=true` when required by that provider. - - > Notes: - > - The base URL for the TTS API should be accessible and relative to the Next.js server - > - > - To disable auth, remove either `BASE_URL` or `AUTH_SECRET`. - > - > - If S3 credentials are unset, they are auto-generated per startup. - -4. Run DB migrations: - - - **Production / Docker**: Migrations run automatically on startup via `pnpm start`. - - **Development**: When using `pnpm dev`, it needs to be run explicitly: - - ```bash - pnpm migrate - ``` - - > Note: If you set `POSTGRES_URL` in `.env`, migrations will target Postgres instead of local SQLite. - > - > Generating migrations (contributors): - > - `pnpm generate` generates migrations for **both** SQLite and Postgres (requires `POSTGRES_URL`). - > - > Manual Drizzle Kit runs: - > - SQLite migrate: `npx drizzle-kit migrate --config drizzle.config.sqlite.ts` - > - Postgres migrate: `npx drizzle-kit migrate --config drizzle.config.pg.ts` - > - SQLite generate: `npx drizzle-kit generate --config drizzle.config.sqlite.ts` - > - Postgres generate: `npx drizzle-kit generate --config drizzle.config.pg.ts` - -5. Start the development server: - - With pnpm (recommended): - - ```bash - pnpm dev # or npm run dev - ``` - - or build and run the production server: - - With pnpm: - - ```bash - pnpm build # or npm run build - pnpm start # or npm start - ``` - - Visit [http://localhost:3003](http://localhost:3003) to run the app. - -## 💡 Feature requests - -For feature requests or ideas you have for the project, please use the [Discussions](https://github.com/richardr1126/OpenReader-WebUI/discussions) tab. - -## 🙋‍♂️ Support and issues - -If you encounter issues, please open an issue on GitHub following the template (which is very light). - -## 👥 Contributing - -Contributions are welcome! Fork the repository and submit a pull request with your changes. - -## ❤️ Acknowledgements - -This project would not be possible without standing on the shoulders of these giants: - -- [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) model -- [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI) -- [Better Auth](https://www.better-auth.com/) -- [SQLite](https://www.sqlite.org/) -- [PostgreSQL](https://www.postgresql.org/) -- [SeaweedFS](https://github.com/seaweedfs/seaweedfs) (`weed mini`) -- [whisper.cpp](https://github.com/ggerganov/whisper.cpp) -- [ffmpeg](https://ffmpeg.org) -- [react-pdf](https://github.com/wojtekmaj/react-pdf) npm package -- [react-reader](https://github.com/happyr/react-reader) npm package - -## Docker Supported Architectures - -- linux/amd64 (x86_64) -- linux/arm64 (Apple Silicon, Raspberry Pi, SBCs, etc.) - -## Stack - -- **Framework:** [Next.js](https://nextjs.org/) 15 (App Router), [React](https://react.dev/) 19, [TypeScript](https://www.typescriptlang.org/) -- **Containerization / Runtime:** [Docker](https://www.docker.com/) (linux/amd64 + linux/arm64), with a shared entrypoint that can bootstrap embedded SeaweedFS before app startup -- **Next.js Client:** - - **UI:** [Tailwind CSS](https://tailwindcss.com), [Headless UI](https://headlessui.com), [@tailwindcss/typography](https://tailwindcss.com/docs/typography-plugin) - - **Interactions:** `react-dnd`, `react-dropzone` - - **Authentication:** [Better Auth](https://www.better-auth.com/) client SDK (`better-auth/react`, anonymous client plugin) - - **Local storage/cache:** [Dexie.js](https://dexie.org/) (IndexedDB) for documents, cache, and app settings - - **Document rendering:** - - **PDF:** [react-pdf](https://github.com/wojtekmaj/react-pdf), [pdf.js](https://mozilla.github.io/pdf.js/) - - **EPUB:** [react-reader](https://github.com/gerhardsletten/react-reader), [epubjs](https://github.com/futurepress/epub.js/) - - **Markdown/Text:** [react-markdown](https://github.com/remarkjs/react-markdown), [remark-gfm](https://github.com/remarkjs/remark-gfm) - - **Text preprocessing / matching:** [compromise](https://github.com/spencermountain/compromise), [cmpstr](https://github.com/remsky/cmpstr) -- **Next.js Server:** - - **APIs:** Next.js Route Handlers for document sync, blob/content access, migration flows, audiobook export, and TTS/Whisper proxying - - **Authentication:** [Better Auth](https://www.better-auth.com/) server handlers/adapters for session and auth routing - - **Text preprocessing / NLP utilities:** [compromise](https://github.com/spencermountain/compromise) via shared `lib/nlp` helpers used in server processing paths - - **Metadata database:** [Drizzle ORM](https://orm.drizzle.team/), [SQLite](https://www.sqlite.org/) (`better-sqlite3`) by default, optional [PostgreSQL](https://www.postgresql.org/) (`pg`) - - **Blob/object storage:** embedded [SeaweedFS](https://github.com/seaweedfs/seaweedfs) (`weed mini`) by default, or external S3-compatible storage via AWS SDK v3 (`@aws-sdk/client-s3`, presigned URLs, upload fallback proxy) - - **Audio/processing pipeline:** OpenAI-compatible TTS providers (OpenAI, DeepInfra, Kokoro, Orpheus, custom), [ffmpeg](https://ffmpeg.org/) for audiobook assembly, optional [whisper.cpp](https://github.com/ggerganov/whisper.cpp) for word-level timestamps -- **Tooling / Testing:** ESLint, TypeScript, [Playwright](https://playwright.dev/) end-to-end tests, and Drizzle migrations/generation scripts - -## License - -This project is licensed under the MIT License. +MIT. See [LICENSE](LICENSE). diff --git a/docs-site/.gitignore b/docs-site/.gitignore new file mode 100644 index 0000000..531b31c --- /dev/null +++ b/docs-site/.gitignore @@ -0,0 +1,3 @@ +.docusaurus +build +node_modules diff --git a/docs-site/custom.css b/docs-site/custom.css new file mode 100644 index 0000000..f9a363c --- /dev/null +++ b/docs-site/custom.css @@ -0,0 +1,134 @@ +:root { + /* OpenReader default light theme colors */ + --ifm-color-primary: #ef4444; + --ifm-color-primary-dark: #dc2626; + --ifm-color-primary-darker: #b91c1c; + --ifm-color-primary-darkest: #991b1b; + --ifm-color-primary-light: #f87171; + --ifm-color-primary-lighter: #fca5a5; + --ifm-color-primary-lightest: #fecaca; + + --ifm-background-color: #ffffff; + --ifm-background-surface-color: #f7fafc; + --ifm-navbar-background-color: #f7fafc; + --ifm-footer-background-color: #f7fafc; + --ifm-font-color-base: #2d3748; + --ifm-color-content: #2d3748; + --ifm-color-content-secondary: #718096; + --ifm-toc-border-color: #e2e8f0; + --ifm-hr-background-color: #e2e8f0; + --ifm-code-background: #e2e8f0; + --ifm-code-font-size: 95%; +} + +[data-theme='dark'] { + /* OpenReader default dark theme colors */ + --ifm-color-primary: #f87171; + --ifm-color-primary-dark: #ef4444; + --ifm-color-primary-darker: #dc2626; + --ifm-color-primary-darkest: #b91c1c; + --ifm-color-primary-light: #fb8c8c; + --ifm-color-primary-lighter: #fca5a5; + --ifm-color-primary-lightest: #fecaca; + + --ifm-background-color: #111111; + --ifm-background-surface-color: #171717; + --ifm-navbar-background-color: #171717; + --ifm-footer-background-color: #171717; + --ifm-font-color-base: #ededed; + --ifm-color-content: #ededed; + --ifm-color-content-secondary: #a3a3a3; + --ifm-toc-border-color: #343434; + --ifm-hr-background-color: #343434; + --ifm-code-background: #343434; +} + +.navbar { + border-bottom: 1px solid var(--ifm-toc-border-color); +} + +.footer { + border-top: 1px solid var(--ifm-toc-border-color); + background: var(--ifm-background-surface-color); +} + +.footer__title { + color: var(--ifm-font-color-base); + font-weight: 600; +} + +.footer__link-item { + color: var(--ifm-color-content-secondary); +} + +.footer__link-item:hover { + color: var(--ifm-color-primary); + text-decoration: none; +} + +.footer__copyright { + border-top: 1px solid var(--ifm-toc-border-color); + color: var(--ifm-color-content-secondary); + margin-top: 1rem; + padding-top: 1rem; +} + +/* Force override Infima defaults (which use high-specificity selectors). */ +:root:not(#\#):not(#\#):not(#\#) { + --ifm-color-primary: #ef4444 !important; + --ifm-color-primary-dark: #dc2626 !important; + --ifm-color-primary-darker: #b91c1c !important; + --ifm-color-primary-darkest: #991b1b !important; + --ifm-color-primary-light: #f87171 !important; + --ifm-color-primary-lighter: #fca5a5 !important; + --ifm-color-primary-lightest: #fecaca !important; + + --ifm-background-color: #ffffff !important; + --ifm-background-surface-color: #f7fafc !important; + --ifm-navbar-background-color: #f7fafc !important; + --ifm-footer-background-color: #f7fafc !important; + --ifm-font-color-base: #2d3748 !important; + --ifm-color-content: #2d3748 !important; + --ifm-color-content-secondary: #718096 !important; + --ifm-toc-border-color: #e2e8f0 !important; + --ifm-hr-background-color: #e2e8f0 !important; + --ifm-code-background: #e2e8f0 !important; +} + +html[data-theme='dark']:not(#\#):not(#\#):not(#\#) { + --ifm-color-primary: #f87171 !important; + --ifm-color-primary-dark: #ef4444 !important; + --ifm-color-primary-darker: #dc2626 !important; + --ifm-color-primary-darkest: #b91c1c !important; + --ifm-color-primary-light: #fb8c8c !important; + --ifm-color-primary-lighter: #fca5a5 !important; + --ifm-color-primary-lightest: #fecaca !important; + + --ifm-background-color: #111111 !important; + --ifm-background-surface-color: #171717 !important; + --ifm-navbar-background-color: #171717 !important; + --ifm-footer-background-color: #171717 !important; + --ifm-font-color-base: #ededed !important; + --ifm-color-content: #ededed !important; + --ifm-color-content-secondary: #a3a3a3 !important; + --ifm-toc-border-color: #343434 !important; + --ifm-hr-background-color: #343434 !important; + --ifm-code-background: #343434 !important; +} + +/* Search/Ask-AI plugin accents should follow OpenReader accent colors. */ +:root:not(#\#):not(#\#):not(#\#) { + --search-local-highlight-color: var(--ifm-color-primary) !important; + --search-local-hit-active-color: var(--ifm-color-primary) !important; + --search-local-input-active-border-color: var(--ifm-color-primary) !important; +} + +.ask-ai:not(#\#):not(#\#) { + --ask-ai-primary: var(--ifm-color-primary) !important; + --ask-ai-primary-hover: var(--ifm-color-primary-dark) !important; +} + +html[data-theme='dark'] .ask-ai:not(#\#):not(#\#) { + --ask-ai-primary: var(--ifm-color-primary) !important; + --ask-ai-primary-hover: var(--ifm-color-primary-dark) !important; +} diff --git a/docs-site/docs/community/acknowledgements.md b/docs-site/docs/community/acknowledgements.md new file mode 100644 index 0000000..0a6c5ec --- /dev/null +++ b/docs-site/docs/community/acknowledgements.md @@ -0,0 +1,16 @@ +--- +title: Acknowledgements +--- + +This project is built with support from the following open-source projects and tools: + +- [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) +- [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI) +- [Better Auth](https://www.better-auth.com/) +- [SQLite](https://www.sqlite.org/) +- [PostgreSQL](https://www.postgresql.org/) +- [SeaweedFS](https://github.com/seaweedfs/seaweedfs) +- [whisper.cpp](https://github.com/ggerganov/whisper.cpp) +- [ffmpeg](https://ffmpeg.org) +- [react-pdf](https://github.com/wojtekmaj/react-pdf) +- [react-reader](https://github.com/happyr/react-reader) diff --git a/docs-site/docs/community/license.md b/docs-site/docs/community/license.md new file mode 100644 index 0000000..0208ca1 --- /dev/null +++ b/docs-site/docs/community/license.md @@ -0,0 +1,7 @@ +--- +title: License +--- + +OpenReader WebUI is licensed under the MIT License. + +- Repository license file: [LICENSE](https://github.com/richardr1126/OpenReader-WebUI/blob/main/LICENSE) diff --git a/docs-site/docs/community/support.md b/docs-site/docs/community/support.md new file mode 100644 index 0000000..8a630c9 --- /dev/null +++ b/docs-site/docs/community/support.md @@ -0,0 +1,19 @@ +--- +title: Support and Contributing +--- + +## Feature requests + +Use [GitHub Discussions](https://github.com/richardr1126/OpenReader-WebUI/discussions) for feature requests and ideas. + +## Issues and support + +If you encounter a bug, open an issue in [GitHub Issues](https://github.com/richardr1126/OpenReader-WebUI/issues). + +## Contributing + +Contributions are welcome. + +- Fork the repository +- Create your branch +- Open a pull request with your changes diff --git a/docs-site/docs/getting-started/docker-quick-start.md b/docs-site/docs/getting-started/docker-quick-start.md new file mode 100644 index 0000000..f44f76e --- /dev/null +++ b/docs-site/docs/getting-started/docker-quick-start.md @@ -0,0 +1,73 @@ +--- +title: Docker Quick Start +--- + +## Prerequisites + +- A recent Docker version installed +- A TTS API server that OpenReader can reach (Kokoro-FastAPI, Orpheus-FastAPI, DeepInfra, OpenAI, or equivalent) + +:::note +If you have suitable hardware, you can run Kokoro locally with Docker. See [Kokoro-FastAPI](../integrations/kokoro-fastapi). +::: + +## 1. Start the Docker container + +Minimal setup (auth disabled, embedded storage ephemeral, no library import): + +```bash +docker run --name openreader-webui \ + --restart unless-stopped \ + -p 3003:3003 \ + -p 8333:8333 \ + ghcr.io/richardr1126/openreader-webui:latest +``` + +Fully featured setup (persistent storage, embedded SeaweedFS `weed mini`, optional auth): + +```bash +docker run --name openreader-webui \ + --restart unless-stopped \ + -p 3003:3003 \ + -p 8333:8333 \ + -v openreader_docstore:/app/docstore \ + -v /path/to/your/library:/app/docstore/library:ro \ + -e API_BASE=http://host.docker.internal:8880/v1 \ + -e API_KEY=none \ + -e BASE_URL=http://localhost:3003 \ + -e AUTH_SECRET= \ + ghcr.io/richardr1126/openreader-webui:latest +``` + +You can remove `/app/docstore/library` if you do not need server library import. +You can remove either `BASE_URL` or `AUTH_SECRET` to keep auth disabled. + +Quick notes: + +- `API_BASE` should point to your TTS server base URL. +- Expose `8333` for direct browser access to embedded SeaweedFS presigned URLs. +- If `8333` is not exposed, uploads still work through `/api/documents/blob/upload/fallback`. +- To enable auth, set both `BASE_URL` and `AUTH_SECRET`. +- DB migrations run automatically during container startup via the shared entrypoint. + +For all environment variables, see [Environment Variables](../guides/environment-variables). +For app/auth behavior, see [Auth](../guides/configuration). +For database startup and migration behavior, see [SQL Database](../operations/database-and-migrations). +For blob behavior and mounts, see [Object / Blob Storage](../guides/storage-and-blob-behavior). + +## 2. Configure settings in the app UI + +- Set TTS provider and model in Settings +- Set TTS API base URL and API key if needed +- Select the model voice from the voice dropdown + +## 3. Update Docker image + +```bash +docker stop openreader-webui || true && \ +docker rm openreader-webui || true && \ +docker image rm ghcr.io/richardr1126/openreader-webui:latest || true && \ +docker pull ghcr.io/richardr1126/openreader-webui:latest +``` + +Visit [http://localhost:3003](http://localhost:3003) after startup. diff --git a/docs-site/docs/getting-started/local-development.md b/docs-site/docs/getting-started/local-development.md new file mode 100644 index 0000000..ffdb578 --- /dev/null +++ b/docs-site/docs/getting-started/local-development.md @@ -0,0 +1,117 @@ +--- +title: Local Development +--- + +## Prerequisites + +- Node.js (recommended with [nvm](https://github.com/nvm-sh/nvm)) +- `pnpm` (recommended) or `npm` + +```bash +npm install -g pnpm +``` + +- A reachable TTS API server +- [SeaweedFS](https://github.com/seaweedfs/seaweedfs) `weed` binary (required) + +```bash +brew install seaweedfs +``` + +Optional, depending on features: + +- [FFmpeg](https://ffmpeg.org) (required for `m4b` audiobook generation) + +```bash +brew install ffmpeg +``` + +- [libreoffice](https://www.libreoffice.org) (required for DOCX conversion) + +```bash +brew install libreoffice +``` + +- [whisper.cpp](https://github.com/ggml-org/whisper.cpp) (optional, for word-by-word highlighting) + +```bash +# clone and build whisper.cpp (no model download needed – OpenReader handles that) +git clone https://github.com/ggml-org/whisper.cpp.git +cd whisper.cpp +cmake -B build +cmake --build build -j --config Release + +# point OpenReader to the compiled whisper-cli binary +echo WHISPER_CPP_BIN="$(pwd)/build/bin/whisper-cli" +``` + +:::note +Set `WHISPER_CPP_BIN` in your `.env` to enable word-by-word highlighting. +::: + +## Steps + +1. Clone the repository. + +```bash +git clone https://github.com/richardr1126/OpenReader-WebUI.git +cd OpenReader-WebUI +``` + +2. Install dependencies. + +```bash +pnpm i +``` + +3. Configure the environment. + +```bash +cp .env.example .env +``` + +Then edit `.env`. + +Auth is enabled when both are set: + +- `BASE_URL` (for local dev, typically `http://localhost:3003`) +- `AUTH_SECRET` (generate with `openssl rand -base64 32`) + +Optional: + +- `AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://192.168.0.116:3003` +- Stable S3 credentials via `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY` +- External S3 storage by setting `USE_EMBEDDED_WEED_MINI=false` and related S3 vars + +For all environment variables, see [Environment Variables](../guides/environment-variables). +For app/auth behavior, see [Auth](../guides/configuration). +For storage configuration, see [Object / Blob Storage](../guides/storage-and-blob-behavior). +For database mode and migrations, see [SQL Database](../operations/database-and-migrations). + +4. Run DB migrations. + +- Migrations run automatically on startup through the shared entrypoint for both `pnpm dev` and `pnpm start`. +- You only need manual migration commands for one-off troubleshooting or explicit migration workflows: + +```bash +pnpm migrate +``` + +:::note +If `POSTGRES_URL` is set, migrations target Postgres; otherwise local SQLite is used. To disable automatic startup migrations, set `RUN_DB_MIGRATIONS=false`. +::: + +5. Start the app. + +```bash +pnpm dev +``` + +Or build + start production mode: + +```bash +pnpm build +pnpm start +``` + +Visit [http://localhost:3003](http://localhost:3003). diff --git a/docs-site/docs/guides/configuration.md b/docs-site/docs/guides/configuration.md new file mode 100644 index 0000000..0c9e305 --- /dev/null +++ b/docs-site/docs/guides/configuration.md @@ -0,0 +1,19 @@ +--- +title: Auth +--- + +This page covers application-level configuration for provider access and authentication. + +## Auth behavior + +- Auth is enabled only when both `BASE_URL` and `AUTH_SECRET` are set. +- Remove either value to disable auth. +- Keep `AUTH_TRUSTED_ORIGINS` empty to trust only `BASE_URL`. + +## Related docs + +- For the complete variable reference: [Environment Variables](./environment-variables) +- For TTS character limits and quota behavior: [TTS Rate Limiting](./tts-rate-limiting) +- For provider-specific guidance: [TTS Providers](./tts-providers) +- For storage/S3/SeaweedFS behavior: [Object / Blob Storage](./storage-and-blob-behavior) +- For database mode and migration commands: [SQL Database](../operations/database-and-migrations) diff --git a/docs-site/docs/guides/environment-variables.md b/docs-site/docs/guides/environment-variables.md new file mode 100644 index 0000000..f4ce7c6 --- /dev/null +++ b/docs-site/docs/guides/environment-variables.md @@ -0,0 +1,280 @@ +--- +title: Environment Variables +toc_max_heading_level: 3 +--- + +This is the single reference page for OpenReader WebUI environment variables. + +## Quick Reference Table + +| Variable | Area | Default | When to set | +| --- | --- | --- | --- | +| `NEXT_PUBLIC_NODE_ENV` | Runtime mode | `development` | Set `production` for production builds | +| `API_BASE` | TTS provider | none | Point to your OpenAI-compatible TTS base URL | +| `API_KEY` | TTS provider | `none` fallback in TTS route | Set when provider requires auth | +| `TTS_CACHE_MAX_SIZE_BYTES` | TTS caching | `268435456` (256 MB) | Tune in-memory TTS cache size | +| `TTS_CACHE_TTL_MS` | TTS caching | `1800000` (30 min) | Tune in-memory TTS cache TTL | +| `TTS_MAX_RETRIES` | TTS retry | `2` | Tune retry attempts for upstream 429/5xx | +| `TTS_RETRY_INITIAL_MS` | TTS retry | `250` | Tune initial retry delay | +| `TTS_RETRY_MAX_MS` | TTS retry | `2000` | Tune max retry delay | +| `TTS_RETRY_BACKOFF` | TTS retry | `2` | Tune exponential backoff factor | +| `TTS_ENABLE_RATE_LIMIT` | Rate limiting | `false` | Set `true` to enable TTS per-user/IP daily character limits | +| `TTS_DAILY_LIMIT_ANONYMOUS` | Rate limiting | `50000` | Override anonymous per-user daily character limit | +| `TTS_DAILY_LIMIT_AUTHENTICATED` | Rate limiting | `500000` | Override authenticated per-user daily character limit | +| `TTS_IP_DAILY_LIMIT_ANONYMOUS` | Rate limiting | `100000` | Override anonymous IP backstop daily limit | +| `TTS_IP_DAILY_LIMIT_AUTHENTICATED` | Rate limiting | `1000000` | Override authenticated IP backstop daily limit | +| `WHISPER_CPP_BIN` | Word timing | unset | Set to enable `whisper.cpp` timestamps | +| `BASE_URL` | Auth | unset | Required (with `AUTH_SECRET`) to enable auth | +| `AUTH_SECRET` | Auth | unset | Required (with `BASE_URL`) to enable auth | +| `AUTH_TRUSTED_ORIGINS` | Auth | empty | Add extra allowed origins | +| `GITHUB_CLIENT_ID` | Auth/OAuth | unset | Set with `GITHUB_CLIENT_SECRET` to enable GitHub sign-in | +| `GITHUB_CLIENT_SECRET` | Auth/OAuth | unset | Set with `GITHUB_CLIENT_ID` to enable GitHub sign-in | +| `DISABLE_AUTH_RATE_LIMIT` | Rate limiting | `false` | Set `true` to disable auth-layer rate limiting | +| `POSTGRES_URL` | Database | unset (SQLite mode) | Set to switch metadata/auth DB to Postgres | +| `RUN_DB_MIGRATIONS` | Database | `true` | Set `false` to skip startup migrations | +| `USE_EMBEDDED_WEED_MINI` | Storage | `true` when unset | Set `false` to use external S3-compatible storage only | +| `WEED_MINI_DIR` | Storage | `docstore/seaweedfs` | Override embedded SeaweedFS data directory | +| `WEED_MINI_WAIT_SEC` | Storage | `20` | Tune SeaweedFS startup wait timeout | +| `S3_ACCESS_KEY_ID` | Storage | auto-generated in embedded mode | Set explicitly for stable/external credentials | +| `S3_SECRET_ACCESS_KEY` | Storage | auto-generated in embedded mode | Set explicitly for stable/external credentials | +| `S3_BUCKET` | Storage | `openreader-documents` in embedded mode | Required for external S3-compatible storage | +| `S3_REGION` | Storage | `us-east-1` in embedded mode | Required for external S3-compatible storage | +| `S3_ENDPOINT` | Storage | derived in embedded mode | Set for S3-compatible providers (MinIO/SeaweedFS/R2/etc.) | +| `S3_FORCE_PATH_STYLE` | Storage | `true` in embedded mode | Set per provider requirement | +| `S3_PREFIX` | Storage | `openreader` | Customize object key prefix | +| `IMPORT_LIBRARY_DIR` | Library import | `docstore/library` fallback | Set a single server library root | +| `IMPORT_LIBRARY_DIRS` | Library import | unset | Set multiple roots (comma/colon/semicolon separated) | + +## Detailed Reference + +### NEXT_PUBLIC_NODE_ENV + +Controls development vs production behavior in client/server code paths. + +- Typical values: `development`, `production` +- In production builds, set `production` + +### API_BASE + +Base URL for OpenAI-compatible TTS API requests. + +- Example: `http://host.docker.internal:8880/v1` +- Can be overridden per request from UI settings + +### API_KEY + +Default API key for TTS provider requests. + +- Example: `none` or your provider token +- Can be overridden by request headers from app settings + +### TTS_CACHE_MAX_SIZE_BYTES + +Maximum in-memory TTS audio cache size in bytes. + +- Default: `268435456` (256 MB) + +### TTS_CACHE_TTL_MS + +In-memory TTS audio cache TTL in milliseconds. + +- Default: `1800000` (30 minutes) + +### TTS_MAX_RETRIES + +Maximum retries for upstream TTS failures (429/5xx). + +- Default: `2` + +### TTS_RETRY_INITIAL_MS + +Initial retry delay in milliseconds for TTS upstream requests. + +- Default: `250` + +### TTS_RETRY_MAX_MS + +Maximum retry delay in milliseconds. + +- Default: `2000` + +### TTS_RETRY_BACKOFF + +Exponential backoff multiplier between retries. + +- Default: `2` + +### TTS_ENABLE_RATE_LIMIT + +Controls TTS character rate limiting in the TTS API. + +- Default: `false` (TTS char limits disabled) +- Set to `true` to enforce `TTS_DAILY_LIMIT_*` and `TTS_IP_DAILY_LIMIT_*` +- For behavior details and examples, see [TTS Rate Limiting](./tts-rate-limiting) + +### TTS_DAILY_LIMIT_ANONYMOUS + +Anonymous per-user daily character limit. + +- Default: `50000` + +### TTS_DAILY_LIMIT_AUTHENTICATED + +Authenticated per-user daily character limit. + +- Default: `500000` + +### TTS_IP_DAILY_LIMIT_ANONYMOUS + +Anonymous IP backstop daily character limit. + +- Default: `100000` + +### TTS_IP_DAILY_LIMIT_AUTHENTICATED + +Authenticated IP backstop daily character limit. + +- Default: `1000000` + +### WHISPER_CPP_BIN + +Absolute path to compiled `whisper.cpp` binary for word-level timestamps. + +- Example: `/whisper.cpp/build/bin/whisper-cli` +- Required only for optional word-by-word highlighting + +### BASE_URL + +External base URL for this OpenReader instance. + +- Required with `AUTH_SECRET` to enable auth +- Example: `http://localhost:3003` or `https://reader.example.com` + +### AUTH_SECRET + +Secret key used by auth/session handling. + +- Required with `BASE_URL` to enable auth +- Generate with `openssl rand -base64 32` + +### AUTH_TRUSTED_ORIGINS + +Additional allowed origins for auth requests. + +- Comma-separated list +- `BASE_URL` origin is always trusted automatically + +### GITHUB_CLIENT_ID + +GitHub OAuth client ID. + +- Enable only with `GITHUB_CLIENT_SECRET` + +### GITHUB_CLIENT_SECRET + +GitHub OAuth client secret. + +- Enable only with `GITHUB_CLIENT_ID` + +### DISABLE_AUTH_RATE_LIMIT + +Controls Better Auth rate limiting. + +- Default behavior: auth-layer rate limiting enabled +- Set to `true` to disable auth-layer rate limiting +- This does not affect TTS character rate limiting + +### POSTGRES_URL + +Switches metadata/auth storage from SQLite to Postgres. + +- Unset: SQLite at `docstore/sqlite3.db` +- Set: Postgres mode + +### RUN_DB_MIGRATIONS + +Controls startup migration execution in shared entrypoint. + +- Default: `true` +- Set `false` to skip automatic startup migrations + +### USE_EMBEDDED_WEED_MINI + +Controls embedded SeaweedFS startup. + +- Default behavior: treated as enabled when unset +- Set `false` to rely on external S3-compatible storage + +### WEED_MINI_DIR + +Data directory for embedded SeaweedFS (`weed mini`). + +- Default: `docstore/seaweedfs` + +### WEED_MINI_WAIT_SEC + +Maximum seconds to wait for embedded SeaweedFS startup. + +- Default: `20` + +### S3_ACCESS_KEY_ID + +Access key for S3-compatible storage. + +- Auto-generated in embedded mode if unset +- Set explicitly for stable credentials or external providers + +### S3_SECRET_ACCESS_KEY + +Secret key for S3-compatible storage. + +- Auto-generated in embedded mode if unset +- Set explicitly for stable credentials or external providers + +### S3_BUCKET + +Bucket name used for document blobs. + +- Default in embedded mode: `openreader-documents` +- Required for external S3-compatible storage + +### S3_REGION + +Region used by the S3 client. + +- Default in embedded mode: `us-east-1` + +### S3_ENDPOINT + +Endpoint URL for S3-compatible storage. + +- In embedded mode, defaults to `http://:8333` (or detected host) +- For AWS S3, usually leave unset +- For MinIO/SeaweedFS/R2/B2-style APIs, typically set explicitly + +### S3_FORCE_PATH_STYLE + +Path-style S3 addressing toggle. + +- Default in embedded mode: `true` +- Set according to provider requirements + +### S3_PREFIX + +Prefix prepended to stored object keys. + +- Default: `openreader` + +### IMPORT_LIBRARY_DIR + +Single directory root for server library import. + +- Used when `IMPORT_LIBRARY_DIRS` is unset +- Default fallback root: `docstore/library` + +### IMPORT_LIBRARY_DIRS + +Multiple library roots for server library import. + +- Separator: comma, colon, or semicolon +- Takes precedence over `IMPORT_LIBRARY_DIR` diff --git a/docs-site/docs/guides/storage-and-blob-behavior.md b/docs-site/docs/guides/storage-and-blob-behavior.md new file mode 100644 index 0000000..fba6c6e --- /dev/null +++ b/docs-site/docs/guides/storage-and-blob-behavior.md @@ -0,0 +1,44 @@ +--- +title: Object / Blob Storage +--- + +This page documents storage backends, blob upload routing, and Docker mount behavior. + +## Storage backends + +- Default: embedded SQLite metadata + embedded SeaweedFS (`weed mini`) blobs +- External option: Postgres + external S3-compatible object storage + +Storage variables are documented in [Environment Variables](./environment-variables) under the storage sections. + +## Ports + +- `3003`: OpenReader app and API routes +- `8333`: Embedded SeaweedFS S3 endpoint for direct browser blob access + +## Upload behavior + +- Primary path: browser uploads to presigned URL from `/api/documents/blob/upload/presign` +- Fallback path: `/api/documents/blob/upload/fallback` if direct upload fails or endpoint is unreachable +- Content serving path: `/api/documents/blob` + +## Recommended Docker mounts + +| Mount | Type | Recommended | Purpose | Example | +| --- | --- | --- | --- | --- | +| `/app/docstore` | Docker named volume | Yes (for persistence) | Persists SeaweedFS blob data, SQLite metadata DB, audiobook artifacts, migration/runtime files | `-v openreader_docstore:/app/docstore` | +| `/app/docstore/library` | Bind mount | Optional + `:ro` | Read-only source for server library import (files are copied/imported into client storage) | `-v /path/to/your/library:/app/docstore/library:ro` | + +To import from mounted library: **Settings -> Documents -> Server Library Import**. + +:::note +Every file in the mounted library is imported into client browser storage. Keep the library reasonably sized. +::: + +## Private blob endpoint mode + +If `8333` is not published externally: + +- Document uploads still work through upload fallback proxy +- Reads/snippets continue through app API routes +- Direct presigned browser upload/download to embedded endpoint is unavailable diff --git a/docs-site/docs/guides/tts-providers.md b/docs-site/docs/guides/tts-providers.md new file mode 100644 index 0000000..7cf42bf --- /dev/null +++ b/docs-site/docs/guides/tts-providers.md @@ -0,0 +1,48 @@ +--- +title: TTS Providers +--- + +OpenReader WebUI supports OpenAI-compatible TTS providers through a common API shape. + +## Supported provider patterns + +- OpenAI API +- DeepInfra +- Kokoro-FastAPI +- Orpheus-FastAPI +- Custom OpenAI-compatible endpoints + +## Provider dropdown behavior + +In Settings, the provider dropdown includes: + +- `OpenAI` +- `Deepinfra` +- `Custom OpenAI-Like` (for Kokoro, Orpheus, and other compatible endpoints) + +`API_BASE` guidance: + +- For `OpenAI` and `Deepinfra`, OpenReader auto-fills the default endpoint. +- For `Custom OpenAI-Like`, set `API_BASE` to your server endpoint. +- In practice, you usually only set `API_BASE` when using a provider/endpoint that is not directly covered by the built-in dropdown defaults. + +## Custom provider compatibility + +For custom providers, OpenReader expects these endpoints: + +- `GET /v1/audio/voices` +- `POST /v1/audio/speech` + +If your provider exposes this interface, it can be used as an OpenAI-compatible TTS backend. + +## Setup flow + +1. Select your provider in the OpenReader Settings modal. +2. If using `Custom OpenAI-Like` (or overriding a default), set `API_BASE`. +3. Set `API_KEY` if required by your provider. +4. Choose model and voice. + +For environment variables, see [Environment Variables](./environment-variables). +For TTS quota behavior, see [TTS Rate Limiting](./tts-rate-limiting). +For auth behavior, see [Auth](./configuration). +For provider-specific integration guides, see [Kokoro-FastAPI](../integrations/kokoro-fastapi), [Orpheus-FastAPI](../integrations/orpheus-fastapi), [Deepinfra](../integrations/deepinfra), [OpenAI](../integrations/openai), and [Custom OpenAI](../integrations/custom-openai). diff --git a/docs-site/docs/guides/tts-rate-limiting.md b/docs-site/docs/guides/tts-rate-limiting.md new file mode 100644 index 0000000..851c4c8 --- /dev/null +++ b/docs-site/docs/guides/tts-rate-limiting.md @@ -0,0 +1,51 @@ +--- +title: TTS Rate Limiting +--- + +This page explains OpenReader's TTS character rate limiting controls. + +## Overview + +- TTS rate limiting is disabled by default. +- To enable it, set `TTS_ENABLE_RATE_LIMIT=true`. +- Limits are enforced per day in UTC. +- Enforcement applies only when auth is enabled. + +## How enforcement works + +When enabled, OpenReader enforces: + +- Per-user daily character limits. +- IP backstop daily character limits. +- Anonymous device backstop tracking (cookie-based) to reduce limit resets. + +If a request exceeds the active limit, the TTS API returns `429` with reset metadata for the next UTC day. + +## Required auth behavior + +- Auth must be enabled (`BASE_URL` + `AUTH_SECRET`) for TTS char limits to apply. +- If auth is disabled, TTS character limits are effectively unlimited. +- `DISABLE_AUTH_RATE_LIMIT` only affects Better Auth's own request throttling. +- `DISABLE_AUTH_RATE_LIMIT` does not disable TTS character limits. + +## Environment variables + +Enable/disable: + +- `TTS_ENABLE_RATE_LIMIT` (default: `false`) + +Per-user daily limits: + +- `TTS_DAILY_LIMIT_ANONYMOUS` (default: `50000`) +- `TTS_DAILY_LIMIT_AUTHENTICATED` (default: `500000`) + +IP backstop daily limits: + +- `TTS_IP_DAILY_LIMIT_ANONYMOUS` (default: `100000`) +- `TTS_IP_DAILY_LIMIT_AUTHENTICATED` (default: `1000000`) + +## Related docs + +- Full variable list: [Environment Variables](./environment-variables) +- Auth configuration: [Auth](./configuration) +- Provider setup: [TTS Providers](./tts-providers) diff --git a/docs-site/docs/integrations/custom-openai.md b/docs-site/docs/integrations/custom-openai.md new file mode 100644 index 0000000..2a23fe4 --- /dev/null +++ b/docs-site/docs/integrations/custom-openai.md @@ -0,0 +1,28 @@ +--- +title: Custom OpenAI +--- + +Use any custom OpenAI-compatible TTS service with OpenReader. + +Use this integration when your endpoint is not directly covered by built-in dropdown defaults. + +## Compatibility requirements + +Your provider should expose: + +- `GET /v1/audio/voices` +- `POST /v1/audio/speech` + +## OpenReader setup + +1. In OpenReader settings, choose provider `Custom OpenAI-Like`. +2. Pick model `Kokoro`, `Orpheus`, or `Other` as appropriate. +3. Set `API_BASE` to your service base URL. +4. Set `API_KEY` if required by your service. +5. Choose voice. + +## Notes + +- `API_BASE` is required for this provider path because OpenReader cannot infer your custom host. +- If voices do not load, verify the `/v1/audio/voices` response format. +- For variable details, see [Environment Variables](../guides/environment-variables). diff --git a/docs-site/docs/integrations/deepinfra.md b/docs-site/docs/integrations/deepinfra.md new file mode 100644 index 0000000..d080f61 --- /dev/null +++ b/docs-site/docs/integrations/deepinfra.md @@ -0,0 +1,19 @@ +--- +title: Deepinfra +--- + +Use Deepinfra as a hosted OpenAI-compatible TTS provider. + +## OpenReader setup + +1. In OpenReader settings, choose provider `Deepinfra`. +2. Leave `API_BASE` unset unless you want to override the default endpoint. + - Default value: `https://api.deepinfra.com/v1/openai` +3. Set `API_KEY` to your Deepinfra API key if needed. +4. Choose model and voice. + +## Notes + +- `Deepinfra` is a built-in provider in the dropdown, so `API_BASE` is usually not required. +- Deepinfra supports multiple TTS models, including Kokoro-family options. +- For variable details, see [Environment Variables](../guides/environment-variables). diff --git a/docs-site/docs/integrations/kokoro-fastapi.md b/docs-site/docs/integrations/kokoro-fastapi.md new file mode 100644 index 0000000..2a39a32 --- /dev/null +++ b/docs-site/docs/integrations/kokoro-fastapi.md @@ -0,0 +1,49 @@ +--- +title: Kokoro-FastAPI +--- + +You can run the Kokoro TTS API server directly with Docker. + +:::warning +For Kokoro issues and support, use the upstream repository: [remsky/Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI). +::: + +## CPU image + +```bash +docker run -d \ + --name kokoro-tts \ + --restart unless-stopped \ + -p 8880:8880 \ + -e ONNX_NUM_THREADS=8 \ + -e ONNX_INTER_OP_THREADS=4 \ + -e ONNX_EXECUTION_MODE=parallel \ + -e ONNX_OPTIMIZATION_LEVEL=all \ + -e ONNX_MEMORY_PATTERN=true \ + -e ONNX_ARENA_EXTEND_STRATEGY=kNextPowerOfTwo \ + -e API_LOG_LEVEL=DEBUG \ + ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.4 +``` + +## GPU image + +```bash +docker run -d \ + --name kokoro-tts \ + --gpus all \ + --user 1001:1001 \ + --restart unless-stopped \ + -p 8880:8880 \ + -e USE_GPU=true \ + -e PYTHONUNBUFFERED=1 \ + -e API_LOG_LEVEL=DEBUG \ + ghcr.io/remsky/kokoro-fastapi-gpu:v0.2.4 +``` + +## OpenReader integration notes + +- In OpenReader settings, choose provider `Custom OpenAI-Like` and model `Kokoro`. +- Set OpenReader `API_BASE` to your Kokoro endpoint (for Docker Compose, commonly `http://kokoro-tts:8880/v1`). +- `API_BASE` is needed here because Kokoro is used via the custom provider path, not a built-in provider endpoint. +- GPU mode requires NVIDIA Docker support and is best on NVIDIA hardware. +- CPU mode works best on Apple Silicon or modern x86 CPUs. diff --git a/docs-site/docs/integrations/openai.md b/docs-site/docs/integrations/openai.md new file mode 100644 index 0000000..72e97fd --- /dev/null +++ b/docs-site/docs/integrations/openai.md @@ -0,0 +1,18 @@ +--- +title: OpenAI +--- + +Use OpenAI directly as an OpenAI-compatible TTS provider. + +## OpenReader setup + +1. In OpenReader settings, choose provider `OpenAI`. +2. Leave `API_BASE` unset unless you want to override the default `https://api.openai.com/v1`. +3. Set `API_KEY` to your OpenAI API key. +4. Choose model and voice. + +## Notes + +- `OpenAI` is a built-in provider in the dropdown, so `API_BASE` is usually not required. +- OpenReader routes TTS calls through its server API. +- For variable details, see [Environment Variables](../guides/environment-variables). diff --git a/docs-site/docs/integrations/orpheus-fastapi.md b/docs-site/docs/integrations/orpheus-fastapi.md new file mode 100644 index 0000000..a3f0c24 --- /dev/null +++ b/docs-site/docs/integrations/orpheus-fastapi.md @@ -0,0 +1,23 @@ +--- +title: Orpheus-FastAPI +--- + +Use Orpheus-FastAPI as an OpenAI-compatible TTS backend for OpenReader. + +## Upstream project + +- [Lex-au/Orpheus-FastAPI](https://github.com/Lex-au/Orpheus-FastAPI) + +## OpenReader setup + +1. Start your Orpheus-FastAPI server. +2. In OpenReader settings, choose provider `Custom OpenAI-Like` and model `Orpheus`. +3. Set OpenReader `API_BASE` to your Orpheus base URL (typically ending with `/v1`). +4. Set `API_KEY` if your Orpheus deployment requires one. +5. Choose voice. + +## Notes + +- `API_BASE` is needed here because Orpheus is configured through the custom provider path. +- OpenReader expects OpenAI-compatible audio endpoints. +- For variable details, see [Environment Variables](../guides/environment-variables). diff --git a/docs-site/docs/intro.md b/docs-site/docs/intro.md new file mode 100644 index 0000000..34a3c36 --- /dev/null +++ b/docs-site/docs/intro.md @@ -0,0 +1,40 @@ +--- +id: intro +title: Introduction +slug: / +--- + +OpenReader WebUI is an open source text-to-speech document reader built with Next.js. It provides a read-along experience with narration for **EPUB, PDF, TXT, MD, and DOCX documents**. + +It supports multiple TTS providers including OpenAI, DeepInfra, and custom OpenAI-compatible endpoints such as [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI) and [Orpheus-FastAPI](https://github.com/Lex-au/Orpheus-FastAPI). + +## Highlights + +- Multi-provider TTS support + - [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI) (including multi-voice combinations) + - [Orpheus-FastAPI](https://github.com/Lex-au/Orpheus-FastAPI) + - Custom OpenAI-compatible endpoints (`/v1/audio/voices` and `/v1/audio/speech`) + - Cloud providers such as [DeepInfra](https://deepinfra.com/models/text-to-speech) and [OpenAI API](https://platform.openai.com/docs/pricing#transcription-and-speech) +- Server-side sync and storage + - External library import from a server-mounted folder + - Sync documents between browser and server for multi-device use +- Server-side audiobook export in `m4b`/`mp3`, with resumable chapter-based export +- Read-along highlighting for PDF and EPUB + - Optional word-by-word highlighting using server-side timestamps from [whisper.cpp](https://github.com/ggml-org/whisper.cpp) +- Sentence-aware narration that merges across pages/chapters for smoother playback +- Optimized Next.js TTS proxy with caching for faster repeat playback +- Customizable themes, TTS settings, and document handling + +## Start Here + +- [Docker Quick Start](./getting-started/docker-quick-start) +- [Local Development](./getting-started/local-development) +- [Environment Variables](./guides/environment-variables) +- [Auth](./guides/configuration) +- [SQL Database](./operations/database-and-migrations) +- [Object / Blob Storage](./guides/storage-and-blob-behavior) +- [TTS Providers](./guides/tts-providers) + +## Source Repository + +- GitHub: [richardr1126/OpenReader-WebUI](https://github.com/richardr1126/OpenReader-WebUI) diff --git a/docs-site/docs/operations/database-and-migrations.md b/docs-site/docs/operations/database-and-migrations.md new file mode 100644 index 0000000..e38eba5 --- /dev/null +++ b/docs-site/docs/operations/database-and-migrations.md @@ -0,0 +1,52 @@ +--- +title: SQL Database +--- + +This page covers database mode selection and migration behavior for OpenReader WebUI. + +## Database mode + +- Default mode: embedded SQLite at `docstore/sqlite3.db` +- External mode: Postgres when `POSTGRES_URL` is set + +## Startup migration behavior + +By default, the shared entrypoint runs DB migrations automatically before app startup in: + +- Docker container startup +- `pnpm dev` +- `pnpm start` + +To skip automatic startup migrations: + +- Set `RUN_DB_MIGRATIONS=false` + +Database variables are documented in [Environment Variables](../guides/environment-variables). + +## Common project commands + +In most cases, you do not need manual migration commands because startup runs migrations automatically. + +```bash +# Run pending migrations (uses Postgres config when POSTGRES_URL is set, otherwise SQLite) +pnpm migrate + +# Generate new migration files for both SQLite and Postgres outputs +pnpm generate +``` + +## Manual Drizzle commands (advanced) + +```bash +# Migrate SQLite +npx drizzle-kit migrate --config drizzle.config.sqlite.ts + +# Migrate Postgres +npx drizzle-kit migrate --config drizzle.config.pg.ts + +# Generate SQLite migrations +npx drizzle-kit generate --config drizzle.config.sqlite.ts + +# Generate Postgres migrations +npx drizzle-kit generate --config drizzle.config.pg.ts +``` diff --git a/docs-site/docs/reference/stack.md b/docs-site/docs/reference/stack.md new file mode 100644 index 0000000..757591d --- /dev/null +++ b/docs-site/docs/reference/stack.md @@ -0,0 +1,41 @@ +--- +title: Stack +--- + +## Framework + +- [Next.js](https://nextjs.org/) 15 (App Router) +- [React](https://react.dev/) 19 +- [TypeScript](https://www.typescriptlang.org/) + +## Containerization and runtime + +- [Docker](https://www.docker.com/) (linux/amd64 and linux/arm64) +- Shared entrypoint that runs DB migrations by default and can bootstrap embedded SeaweedFS before app startup + +## Next.js client + +- UI: [Tailwind CSS](https://tailwindcss.com), [Headless UI](https://headlessui.com), [@tailwindcss/typography](https://tailwindcss.com/docs/typography-plugin) +- Interactions: `react-dnd`, `react-dropzone` +- Authentication: [Better Auth](https://www.better-auth.com/) client SDK +- Local storage/cache: [Dexie.js](https://dexie.org/) (IndexedDB) +- Document rendering: + - PDF: [react-pdf](https://github.com/wojtekmaj/react-pdf), [pdf.js](https://mozilla.github.io/pdf.js/) + - EPUB: [react-reader](https://github.com/gerhardsletten/react-reader), [epubjs](https://github.com/futurepress/epub.js/) + - Markdown/Text: [react-markdown](https://github.com/remarkjs/react-markdown), [remark-gfm](https://github.com/remarkjs/remark-gfm) +- Text preprocessing/matching: [compromise](https://github.com/spencermountain/compromise), [cmpstr](https://github.com/remsky/cmpstr) + +## Next.js server + +- APIs: Route Handlers for sync, blob/content access, migrations, audiobook export, TTS/Whisper proxying +- Authentication: [Better Auth](https://www.better-auth.com/) server handlers/adapters +- Metadata DB: [Drizzle ORM](https://orm.drizzle.team/) with SQLite (`better-sqlite3`) by default and optional Postgres (`pg`) +- Blob storage: embedded [SeaweedFS](https://github.com/seaweedfs/seaweedfs) (`weed mini`) by default, or external S3-compatible storage via AWS SDK v3 +- Audio/processing pipeline: OpenAI-compatible TTS providers, [ffmpeg](https://ffmpeg.org/) for audiobook assembly, optional [whisper.cpp](https://github.com/ggerganov/whisper.cpp) for word timestamps + +## Tooling and testing + +- ESLint +- TypeScript +- [Playwright](https://playwright.dev/) end-to-end tests +- Drizzle migration/generation scripts diff --git a/docs-site/docusaurus.config.ts b/docs-site/docusaurus.config.ts new file mode 100644 index 0000000..d1f1813 --- /dev/null +++ b/docs-site/docusaurus.config.ts @@ -0,0 +1,122 @@ +import { themes as prismThemes } from 'prism-react-renderer'; +import type { Config } from '@docusaurus/types'; +import type * as Preset from '@docusaurus/preset-classic'; + +const config: Config = { + title: 'OpenReader WebUI Docs', + tagline: 'Docs for OpenReader', + + future: { + v4: true, + }, + + url: 'https://docs.openreader.richardr.dev', + baseUrl: '/', + + organizationName: 'richardr1126', + projectName: 'OpenReader-WebUI', + + onBrokenLinks: 'throw', + markdown: { + hooks: { + onBrokenMarkdownLinks: 'throw', + }, + }, + + i18n: { + defaultLocale: 'en', + locales: ['en'], + }, + + presets: [ + [ + 'classic', + { + docs: { + routeBasePath: '/', + sidebarPath: './sidebars.ts', + editUrl: 'https://github.com/richardr1126/OpenReader-WebUI/tree/main/docs-site/', + // lastVersion: 'current', + // versions: { + // current: { + // label: 'Current', + // }, + // }, + }, + blog: false, + theme: { + customCss: './custom.css', + }, + } satisfies Preset.Options, + ], + ], + + plugins: [ + [ + '@easyops-cn/docusaurus-search-local', + { + indexDocs: true, + indexBlog: false, + docsRouteBasePath: '/', + language: ['en'], + hashed: true, + explicitSearchResultPath: true, + highlightSearchTermsOnTargetPage: true, + }, + ], + ], + + themeConfig: { + colorMode: { + defaultMode: 'light', + disableSwitch: false, + respectPrefersColorScheme: true, + }, + navbar: { + title: 'OpenReader WebUI', + items: [ + { + type: 'docSidebar', + sidebarId: 'tutorialSidebar', + position: 'left', + label: 'Docs', + }, + { + type: 'docsVersionDropdown', + position: 'left', + }, + { + href: 'https://github.com/richardr1126/OpenReader-WebUI', + label: 'GitHub', + position: 'right', + }, + ], + }, + footer: { + links: [ + { + title: 'Community', + items: [ + { label: 'Support', to: '/community/support' }, + { label: 'GitHub Discussions', href: 'https://github.com/richardr1126/OpenReader-WebUI/discussions' }, + { label: 'Issues', href: 'https://github.com/richardr1126/OpenReader-WebUI/issues' }, + ], + }, + { + title: 'Project', + items: [ + { label: 'GitHub', href: 'https://github.com/richardr1126/OpenReader-WebUI' }, + { label: 'Releases', href: 'https://github.com/richardr1126/OpenReader-WebUI/releases' }, + ], + }, + ], + copyright: `Copyright © ${new Date().getFullYear()} OpenReader contributors.`, + }, + prism: { + theme: prismThemes.github, + darkTheme: prismThemes.dracula, + }, + } satisfies Preset.ThemeConfig, +}; + +export default config; diff --git a/docs-site/package.json b/docs-site/package.json new file mode 100644 index 0000000..5f1344b --- /dev/null +++ b/docs-site/package.json @@ -0,0 +1,32 @@ +{ + "name": "openreader-docs", + "version": "0.0.0", + "private": true, + "scripts": { + "start": "docusaurus start --host 0.0.0.0 --port 3004", + "build": "docusaurus build", + "serve": "docusaurus serve", + "clear": "docusaurus clear", + "docusaurus": "docusaurus", + "swizzle": "docusaurus swizzle", + "deploy": "docusaurus deploy" + }, + "dependencies": { + "@docusaurus/core": "^3.9.2", + "@docusaurus/preset-classic": "^3.9.2", + "@easyops-cn/docusaurus-search-local": "^0.54.1", + "clsx": "^2.1.1", + "prism-react-renderer": "^2.4.1", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@docusaurus/module-type-aliases": "^3.9.2", + "@docusaurus/tsconfig": "^3.9.2", + "@docusaurus/types": "^3.9.2", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=20" + } +} diff --git a/docs-site/pnpm-lock.yaml b/docs-site/pnpm-lock.yaml new file mode 100644 index 0000000..5325d7d --- /dev/null +++ b/docs-site/pnpm-lock.yaml @@ -0,0 +1,12621 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@docusaurus/core': + specifier: ^3.9.2 + version: 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/preset-classic': + specifier: ^3.9.2 + version: 3.9.2(@algolia/client-search@5.48.0)(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3) + '@easyops-cn/docusaurus-search-local': + specifier: ^0.54.1 + version: 0.54.1(@docusaurus/theme-common@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + clsx: + specifier: ^2.1.1 + version: 2.1.1 + prism-react-renderer: + specifier: ^2.4.1 + version: 2.4.1(react@18.3.1) + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@docusaurus/module-type-aliases': + specifier: ^3.9.2 + version: 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/tsconfig': + specifier: ^3.9.2 + version: 3.9.2 + '@docusaurus/types': + specifier: ^3.9.2 + version: 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + +packages: + + '@algolia/abtesting@1.14.0': + resolution: {integrity: sha512-cZfj+1Z1dgrk3YPtNQNt0H9Rr67P8b4M79JjUKGS0d7/EbFbGxGgSu6zby5f22KXo3LT0LZa4O2c6VVbupJuDg==} + engines: {node: '>= 14.0.0'} + + '@algolia/autocomplete-core@1.19.2': + resolution: {integrity: sha512-mKv7RyuAzXvwmq+0XRK8HqZXt9iZ5Kkm2huLjgn5JoCPtDy+oh9yxUMfDDaVCw0oyzZ1isdJBc7l9nuCyyR7Nw==} + + '@algolia/autocomplete-plugin-algolia-insights@1.19.2': + resolution: {integrity: sha512-TjxbcC/r4vwmnZaPwrHtkXNeqvlpdyR+oR9Wi2XyfORkiGkLTVhX2j+O9SaCCINbKoDfc+c2PB8NjfOnz7+oKg==} + peerDependencies: + search-insights: '>= 1 < 3' + + '@algolia/autocomplete-shared@1.19.2': + resolution: {integrity: sha512-jEazxZTVD2nLrC+wYlVHQgpBoBB5KPStrJxLzsIFl6Kqd1AlG9sIAGl39V5tECLpIQzB3Qa2T6ZPJ1ChkwMK/w==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/client-abtesting@5.48.0': + resolution: {integrity: sha512-n17WSJ7vazmM6yDkWBAjY12J8ERkW9toOqNgQ1GEZu/Kc4dJDJod1iy+QP5T/UlR3WICgZDi/7a/VX5TY5LAPQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-analytics@5.48.0': + resolution: {integrity: sha512-v5bMZMEqW9U2l40/tTAaRyn4AKrYLio7KcRuHmLaJtxuJAhvZiE7Y62XIsF070juz4MN3eyvfQmI+y5+OVbZuA==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-common@5.48.0': + resolution: {integrity: sha512-7H3DgRyi7UByScc0wz7EMrhgNl7fKPDjKX9OcWixLwCj7yrRXDSIzwunykuYUUO7V7HD4s319e15FlJ9CQIIFQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-insights@5.48.0': + resolution: {integrity: sha512-tXmkB6qrIGAXrtRYHQNpfW0ekru/qymV02bjT0w5QGaGw0W91yT+53WB6dTtRRsIrgS30Al6efBvyaEosjZ5uw==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-personalization@5.48.0': + resolution: {integrity: sha512-4tXEsrdtcBZbDF73u14Kb3otN+xUdTVGop1tBjict+Rc/FhsJQVIwJIcTrOJqmvhtBfc56Bu65FiVOnpAZCxcw==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-query-suggestions@5.48.0': + resolution: {integrity: sha512-unzSUwWFpsDrO8935RhMAlyK0Ttua/5XveVIwzfjs5w+GVBsHgIkbOe8VbBJccMU/z1LCwvu1AY3kffuSLAR5Q==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-search@5.48.0': + resolution: {integrity: sha512-RB9bKgYTVUiOcEb5bOcZ169jiiVW811dCsJoLT19DcbbFmU4QaK0ghSTssij35QBQ3SCOitXOUrHcGgNVwS7sQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/events@4.0.1': + resolution: {integrity: sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ==} + + '@algolia/ingestion@1.48.0': + resolution: {integrity: sha512-rhoSoPu+TDzDpvpk3cY/pYgbeWXr23DxnAIH/AkN0dUC+GCnVIeNSQkLaJ+CL4NZ51cjLIjksrzb4KC5Xu+ktw==} + engines: {node: '>= 14.0.0'} + + '@algolia/monitoring@1.48.0': + resolution: {integrity: sha512-aSe6jKvWt+8VdjOaq2ERtsXp9+qMXNJ3mTyTc1VMhNfgPl7ArOhRMRSQ8QBnY8ZL4yV5Xpezb7lAg8pdGrrulg==} + engines: {node: '>= 14.0.0'} + + '@algolia/recommend@5.48.0': + resolution: {integrity: sha512-p9tfI1bimAaZrdiVExL/dDyGUZ8gyiSHsktP1ZWGzt5hXpM3nhv4tSjyHtXjEKtA0UvsaHKwSfFE8aAAm1eIQA==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-browser-xhr@5.48.0': + resolution: {integrity: sha512-XshyfpsQB7BLnHseMinp3fVHOGlTv6uEHOzNK/3XrEF9mjxoZAcdVfY1OCXObfwRWX5qXZOq8FnrndFd44iVsQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-fetch@5.48.0': + resolution: {integrity: sha512-Q4XNSVQU89bKNAPuvzSYqTH9AcbOOiIo6AeYMQTxgSJ2+uvT78CLPMG89RIIloYuAtSfE07s40OLV50++l1Bbw==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-node-http@5.48.0': + resolution: {integrity: sha512-ZgxV2+5qt3NLeUYBTsi6PLyHcENQWC0iFppFZekHSEDA2wcLdTUjnaJzimTEULHIvJuLRCkUs4JABdhuJktEag==} + engines: {node: '>= 14.0.0'} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.28.6': + resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-create-regexp-features-plugin@7.28.5': + resolution: {integrity: sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-define-polyfill-provider@0.6.6': + resolution: {integrity: sha512-mOAsxeeKkUKayvZR3HeTYD/fICpCPLJrU5ZjelT/PA6WHtNDBOE436YiaEUvHN454bRM3CebhDsIpieCc4texA==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-remap-async-to-generator@7.27.1': + resolution: {integrity: sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-wrap-function@7.28.6': + resolution: {integrity: sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5': + resolution: {integrity: sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1': + resolution: {integrity: sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1': + resolution: {integrity: sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1': + resolution: {integrity: sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.13.0 + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.6': + resolution: {integrity: sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2': + resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-dynamic-import@7.8.3': + resolution: {integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-assertions@7.28.6': + resolution: {integrity: sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.28.6': + resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6': + resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-arrow-functions@7.27.1': + resolution: {integrity: sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-generator-functions@7.29.0': + resolution: {integrity: sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-to-generator@7.28.6': + resolution: {integrity: sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoped-functions@7.27.1': + resolution: {integrity: sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoping@7.28.6': + resolution: {integrity: sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-properties@7.28.6': + resolution: {integrity: sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-static-block@7.28.6': + resolution: {integrity: sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.12.0 + + '@babel/plugin-transform-classes@7.28.6': + resolution: {integrity: sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-computed-properties@7.28.6': + resolution: {integrity: sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@7.28.5': + resolution: {integrity: sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-dotall-regex@7.28.6': + resolution: {integrity: sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-keys@7.27.1': + resolution: {integrity: sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.0': + resolution: {integrity: sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-dynamic-import@7.27.1': + resolution: {integrity: sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-explicit-resource-management@7.28.6': + resolution: {integrity: sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-exponentiation-operator@7.28.6': + resolution: {integrity: sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-export-namespace-from@7.27.1': + resolution: {integrity: sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-for-of@7.27.1': + resolution: {integrity: sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-function-name@7.27.1': + resolution: {integrity: sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-json-strings@7.28.6': + resolution: {integrity: sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-literals@7.27.1': + resolution: {integrity: sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-logical-assignment-operators@7.28.6': + resolution: {integrity: sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-member-expression-literals@7.27.1': + resolution: {integrity: sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-amd@7.27.1': + resolution: {integrity: sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.28.6': + resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-systemjs@7.29.0': + resolution: {integrity: sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-umd@7.27.1': + resolution: {integrity: sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.0': + resolution: {integrity: sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-new-target@7.27.1': + resolution: {integrity: sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6': + resolution: {integrity: sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-numeric-separator@7.28.6': + resolution: {integrity: sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-rest-spread@7.28.6': + resolution: {integrity: sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-object-super@7.27.1': + resolution: {integrity: sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-catch-binding@7.28.6': + resolution: {integrity: sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.28.6': + resolution: {integrity: sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-parameters@7.27.7': + resolution: {integrity: sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-methods@7.28.6': + resolution: {integrity: sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-property-in-object@7.28.6': + resolution: {integrity: sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-property-literals@7.27.1': + resolution: {integrity: sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-constant-elements@7.27.1': + resolution: {integrity: sha512-edoidOjl/ZxvYo4lSBOQGDSyToYVkTAwyVoa2tkuYTSmjrB1+uAedoL5iROVLXkxH+vRgA7uP4tMg2pUJpZ3Ug==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-display-name@7.28.0': + resolution: {integrity: sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-development@7.27.1': + resolution: {integrity: sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx@7.28.6': + resolution: {integrity: sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-pure-annotations@7.27.1': + resolution: {integrity: sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regenerator@7.29.0': + resolution: {integrity: sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regexp-modifiers@7.28.6': + resolution: {integrity: sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-reserved-words@7.27.1': + resolution: {integrity: sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-runtime@7.29.0': + resolution: {integrity: sha512-jlaRT5dJtMaMCV6fAuLbsQMSwz/QkvaHOHOSXRitGGwSpR1blCY4KUKoyP2tYO8vJcqYe8cEj96cqSztv3uF9w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-shorthand-properties@7.27.1': + resolution: {integrity: sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-spread@7.28.6': + resolution: {integrity: sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-sticky-regex@7.27.1': + resolution: {integrity: sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-template-literals@7.27.1': + resolution: {integrity: sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typeof-symbol@7.27.1': + resolution: {integrity: sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.28.6': + resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-escapes@7.27.1': + resolution: {integrity: sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-property-regex@7.28.6': + resolution: {integrity: sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-regex@7.27.1': + resolution: {integrity: sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-sets-regex@7.28.6': + resolution: {integrity: sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/preset-env@7.29.0': + resolution: {integrity: sha512-fNEdfc0yi16lt6IZo2Qxk3knHVdfMYX33czNb4v8yWhemoBhibCpQK/uYHtSKIiO+p/zd3+8fYVXhQdOVV608w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-modules@0.1.6-no-external-plugins': + resolution: {integrity: sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==} + peerDependencies: + '@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0 + + '@babel/preset-react@7.28.5': + resolution: {integrity: sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.28.5': + resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime-corejs3@7.29.0': + resolution: {integrity: sha512-TgUkdp71C9pIbBcHudc+gXZnihEDOjUAmXO1VO4HHGES7QLZcShR0stfKIxLSNIYx2fqhmJChOjm/wkF8wv4gA==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + + '@csstools/cascade-layer-name-parser@2.0.5': + resolution: {integrity: sha512-p1ko5eHgV+MgXFVa4STPKpvPxr6ReS8oS2jzTukjR74i5zJNyWO1ZM1m8YKBXnzDKWfBN1ztLYlHxbVemDD88A==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@csstools/media-query-list-parser@4.0.3': + resolution: {integrity: sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/postcss-alpha-function@1.0.1': + resolution: {integrity: sha512-isfLLwksH3yHkFXfCI2Gcaqg7wGGHZZwunoJzEZk0yKYIokgre6hYVFibKL3SYAoR1kBXova8LB+JoO5vZzi9w==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-cascade-layers@5.0.2': + resolution: {integrity: sha512-nWBE08nhO8uWl6kSAeCx4im7QfVko3zLrtgWZY4/bP87zrSPpSyN/3W3TDqz1jJuH+kbKOHXg5rJnK+ZVYcFFg==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-color-function-display-p3-linear@1.0.1': + resolution: {integrity: sha512-E5qusdzhlmO1TztYzDIi8XPdPoYOjoTY6HBYBCYSj+Gn4gQRBlvjgPQXzfzuPQqt8EhkC/SzPKObg4Mbn8/xMg==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-color-function@4.0.12': + resolution: {integrity: sha512-yx3cljQKRaSBc2hfh8rMZFZzChaFgwmO2JfFgFr1vMcF3C/uyy5I4RFIBOIWGq1D+XbKCG789CGkG6zzkLpagA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-color-mix-function@3.0.12': + resolution: {integrity: sha512-4STERZfCP5Jcs13P1U5pTvI9SkgLgfMUMhdXW8IlJWkzOOOqhZIjcNhWtNJZes2nkBDsIKJ0CJtFtuaZ00moag==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-color-mix-variadic-function-arguments@1.0.2': + resolution: {integrity: sha512-rM67Gp9lRAkTo+X31DUqMEq+iK+EFqsidfecmhrteErxJZb6tUoJBVQca1Vn1GpDql1s1rD1pKcuYzMsg7Z1KQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-content-alt-text@2.0.8': + resolution: {integrity: sha512-9SfEW9QCxEpTlNMnpSqFaHyzsiRpZ5J5+KqCu1u5/eEJAWsMhzT40qf0FIbeeglEvrGRMdDzAxMIz3wqoGSb+Q==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-contrast-color-function@2.0.12': + resolution: {integrity: sha512-YbwWckjK3qwKjeYz/CijgcS7WDUCtKTd8ShLztm3/i5dhh4NaqzsbYnhm4bjrpFpnLZ31jVcbK8YL77z3GBPzA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-exponential-functions@2.0.9': + resolution: {integrity: sha512-abg2W/PI3HXwS/CZshSa79kNWNZHdJPMBXeZNyPQFbbj8sKO3jXxOt/wF7juJVjyDTc6JrvaUZYFcSBZBhaxjw==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-font-format-keywords@4.0.0': + resolution: {integrity: sha512-usBzw9aCRDvchpok6C+4TXC57btc4bJtmKQWOHQxOVKen1ZfVqBUuCZ/wuqdX5GHsD0NRSr9XTP+5ID1ZZQBXw==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-gamut-mapping@2.0.11': + resolution: {integrity: sha512-fCpCUgZNE2piVJKC76zFsgVW1apF6dpYsqGyH8SIeCcM4pTEsRTWTLCaJIMKFEundsCKwY1rwfhtrio04RJ4Dw==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-gradients-interpolation-method@5.0.12': + resolution: {integrity: sha512-jugzjwkUY0wtNrZlFeyXzimUL3hN4xMvoPnIXxoZqxDvjZRiSh+itgHcVUWzJ2VwD/VAMEgCLvtaJHX+4Vj3Ow==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-hwb-function@4.0.12': + resolution: {integrity: sha512-mL/+88Z53KrE4JdePYFJAQWFrcADEqsLprExCM04GDNgHIztwFzj0Mbhd/yxMBngq0NIlz58VVxjt5abNs1VhA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-ic-unit@4.0.4': + resolution: {integrity: sha512-yQ4VmossuOAql65sCPppVO1yfb7hDscf4GseF0VCA/DTDaBc0Wtf8MTqVPfjGYlT5+2buokG0Gp7y0atYZpwjg==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-initial@2.0.1': + resolution: {integrity: sha512-L1wLVMSAZ4wovznquK0xmC7QSctzO4D0Is590bxpGqhqjboLXYA16dWZpfwImkdOgACdQ9PqXsuRroW6qPlEsg==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-is-pseudo-class@5.0.3': + resolution: {integrity: sha512-jS/TY4SpG4gszAtIg7Qnf3AS2pjcUM5SzxpApOrlndMeGhIbaTzWBzzP/IApXoNWEW7OhcjkRT48jnAUIFXhAQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-light-dark-function@2.0.11': + resolution: {integrity: sha512-fNJcKXJdPM3Lyrbmgw2OBbaioU7yuKZtiXClf4sGdQttitijYlZMD5K7HrC/eF83VRWRrYq6OZ0Lx92leV2LFA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-logical-float-and-clear@3.0.0': + resolution: {integrity: sha512-SEmaHMszwakI2rqKRJgE+8rpotFfne1ZS6bZqBoQIicFyV+xT1UF42eORPxJkVJVrH9C0ctUgwMSn3BLOIZldQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-logical-overflow@2.0.0': + resolution: {integrity: sha512-spzR1MInxPuXKEX2csMamshR4LRaSZ3UXVaRGjeQxl70ySxOhMpP2252RAFsg8QyyBXBzuVOOdx1+bVO5bPIzA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-logical-overscroll-behavior@2.0.0': + resolution: {integrity: sha512-e/webMjoGOSYfqLunyzByZj5KKe5oyVg/YSbie99VEaSDE2kimFm0q1f6t/6Jo+VVCQ/jbe2Xy+uX+C4xzWs4w==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-logical-resize@3.0.0': + resolution: {integrity: sha512-DFbHQOFW/+I+MY4Ycd/QN6Dg4Hcbb50elIJCfnwkRTCX05G11SwViI5BbBlg9iHRl4ytB7pmY5ieAFk3ws7yyg==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-logical-viewport-units@3.0.4': + resolution: {integrity: sha512-q+eHV1haXA4w9xBwZLKjVKAWn3W2CMqmpNpZUk5kRprvSiBEGMgrNH3/sJZ8UA3JgyHaOt3jwT9uFa4wLX4EqQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-media-minmax@2.0.9': + resolution: {integrity: sha512-af9Qw3uS3JhYLnCbqtZ9crTvvkR+0Se+bBqSr7ykAnl9yKhk6895z9rf+2F4dClIDJWxgn0iZZ1PSdkhrbs2ig==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-media-queries-aspect-ratio-number-values@3.0.5': + resolution: {integrity: sha512-zhAe31xaaXOY2Px8IYfoVTB3wglbJUVigGphFLj6exb7cjZRH9A6adyE22XfFK3P2PzwRk0VDeTJmaxpluyrDg==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-nested-calc@4.0.0': + resolution: {integrity: sha512-jMYDdqrQQxE7k9+KjstC3NbsmC063n1FTPLCgCRS2/qHUbHM0mNy9pIn4QIiQGs9I/Bg98vMqw7mJXBxa0N88A==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-normalize-display-values@4.0.1': + resolution: {integrity: sha512-TQUGBuRvxdc7TgNSTevYqrL8oItxiwPDixk20qCB5me/W8uF7BPbhRrAvFuhEoywQp/woRsUZ6SJ+sU5idZAIA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-oklab-function@4.0.12': + resolution: {integrity: sha512-HhlSmnE1NKBhXsTnNGjxvhryKtO7tJd1w42DKOGFD6jSHtYOrsJTQDKPMwvOfrzUAk8t7GcpIfRyM7ssqHpFjg==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-position-area-property@1.0.0': + resolution: {integrity: sha512-fUP6KR8qV2NuUZV3Cw8itx0Ep90aRjAZxAEzC3vrl6yjFv+pFsQbR18UuQctEKmA72K9O27CoYiKEgXxkqjg8Q==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-progressive-custom-properties@4.2.1': + resolution: {integrity: sha512-uPiiXf7IEKtUQXsxu6uWtOlRMXd2QWWy5fhxHDnPdXKCQckPP3E34ZgDoZ62r2iT+UOgWsSbM4NvHE5m3mAEdw==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-property-rule-prelude-list@1.0.0': + resolution: {integrity: sha512-IxuQjUXq19fobgmSSvUDO7fVwijDJaZMvWQugxfEUxmjBeDCVaDuMpsZ31MsTm5xbnhA+ElDi0+rQ7sQQGisFA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-random-function@2.0.1': + resolution: {integrity: sha512-q+FQaNiRBhnoSNo+GzqGOIBKoHQ43lYz0ICrV+UudfWnEF6ksS6DsBIJSISKQT2Bvu3g4k6r7t0zYrk5pDlo8w==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-relative-color-syntax@3.0.12': + resolution: {integrity: sha512-0RLIeONxu/mtxRtf3o41Lq2ghLimw0w9ByLWnnEVuy89exmEEq8bynveBxNW3nyHqLAFEeNtVEmC1QK9MZ8Huw==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-scope-pseudo-class@4.0.1': + resolution: {integrity: sha512-IMi9FwtH6LMNuLea1bjVMQAsUhFxJnyLSgOp/cpv5hrzWmrUYU5fm0EguNDIIOHUqzXode8F/1qkC/tEo/qN8Q==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-sign-functions@1.1.4': + resolution: {integrity: sha512-P97h1XqRPcfcJndFdG95Gv/6ZzxUBBISem0IDqPZ7WMvc/wlO+yU0c5D/OCpZ5TJoTt63Ok3knGk64N+o6L2Pg==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-stepped-value-functions@4.0.9': + resolution: {integrity: sha512-h9btycWrsex4dNLeQfyU3y3w40LMQooJWFMm/SK9lrKguHDcFl4VMkncKKoXi2z5rM9YGWbUQABI8BT2UydIcA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-syntax-descriptor-syntax-production@1.0.1': + resolution: {integrity: sha512-GneqQWefjM//f4hJ/Kbox0C6f2T7+pi4/fqTqOFGTL3EjnvOReTqO1qUQ30CaUjkwjYq9qZ41hzarrAxCc4gow==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-system-ui-font-family@1.0.0': + resolution: {integrity: sha512-s3xdBvfWYfoPSBsikDXbuorcMG1nN1M6GdU0qBsGfcmNR0A/qhloQZpTxjA3Xsyrk1VJvwb2pOfiOT3at/DuIQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-text-decoration-shorthand@4.0.3': + resolution: {integrity: sha512-KSkGgZfx0kQjRIYnpsD7X2Om9BUXX/Kii77VBifQW9Ih929hK0KNjVngHDH0bFB9GmfWcR9vJYJJRvw/NQjkrA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-trigonometric-functions@4.0.9': + resolution: {integrity: sha512-Hnh5zJUdpNrJqK9v1/E3BbrQhaDTj5YiX7P61TOvUhoDHnUmsNNxcDAgkQ32RrcWx9GVUvfUNPcUkn8R3vIX6A==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-unset-value@4.0.0': + resolution: {integrity: sha512-cBz3tOCI5Fw6NIFEwU3RiwK6mn3nKegjpJuzCndoGq3BZPkUjnsq7uQmIeMNeMbMk7YD2MfKcgCpZwX5jyXqCA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/selector-resolve-nested@3.1.0': + resolution: {integrity: sha512-mf1LEW0tJLKfWyvn5KdDrhpxHyuxpbNwTIwOYLIvsTffeyOf85j5oIzfG0yosxDgx/sswlqBnESYUcQH0vgZ0g==} + engines: {node: '>=18'} + peerDependencies: + postcss-selector-parser: ^7.0.0 + + '@csstools/selector-specificity@5.0.0': + resolution: {integrity: sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==} + engines: {node: '>=18'} + peerDependencies: + postcss-selector-parser: ^7.0.0 + + '@csstools/utilities@2.0.0': + resolution: {integrity: sha512-5VdOr0Z71u+Yp3ozOx8T11N703wIFGVRgOWbOZMKgglPJsWA54MRIoMNVMa7shUToIhx5J8vX4sOZgD2XiihiQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@discoveryjs/json-ext@0.5.7': + resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} + engines: {node: '>=10.0.0'} + + '@docsearch/core@4.5.4': + resolution: {integrity: sha512-DbkfZbJyYAPFJtF71eAFOTQSy5z5c/hdSN0UrErORKDwXKLTJBR0c+5WxE5l+IKZx4xIaEa8RkrL7T28DTCOYw==} + peerDependencies: + '@types/react': '>= 16.8.0 < 20.0.0' + react: '>= 16.8.0 < 20.0.0' + react-dom: '>= 16.8.0 < 20.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + react-dom: + optional: true + + '@docsearch/css@4.5.4': + resolution: {integrity: sha512-gzO4DJwyM9c4YEPHwaLV1nUCDC2N6yoh0QJj44dce2rcfN71mB+jpu3+F+Y/KMDF1EKV0C3m54leSWsraE94xg==} + + '@docsearch/react@4.5.4': + resolution: {integrity: sha512-iBNFfvWoUFRUJmGQ/r+0AEp2OJgJMoYIKRiRcTDON0hObBRSLlrv2ktb7w3nc1MeNm1JIpbPA99i59TiIR49fA==} + peerDependencies: + '@types/react': '>= 16.8.0 < 20.0.0' + react: '>= 16.8.0 < 20.0.0' + react-dom: '>= 16.8.0 < 20.0.0' + search-insights: '>= 1 < 3' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + react-dom: + optional: true + search-insights: + optional: true + + '@docusaurus/babel@3.9.2': + resolution: {integrity: sha512-GEANdi/SgER+L7Japs25YiGil/AUDnFFHaCGPBbundxoWtCkA2lmy7/tFmgED4y1htAy6Oi4wkJEQdGssnw9MA==} + engines: {node: '>=20.0'} + + '@docusaurus/bundler@3.9.2': + resolution: {integrity: sha512-ZOVi6GYgTcsZcUzjblpzk3wH1Fya2VNpd5jtHoCCFcJlMQ1EYXZetfAnRHLcyiFeBABaI1ltTYbOBtH/gahGVA==} + engines: {node: '>=20.0'} + peerDependencies: + '@docusaurus/faster': '*' + peerDependenciesMeta: + '@docusaurus/faster': + optional: true + + '@docusaurus/core@3.9.2': + resolution: {integrity: sha512-HbjwKeC+pHUFBfLMNzuSjqFE/58+rLVKmOU3lxQrpsxLBOGosYco/Q0GduBb0/jEMRiyEqjNT/01rRdOMWq5pw==} + engines: {node: '>=20.0'} + hasBin: true + peerDependencies: + '@mdx-js/react': ^3.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@docusaurus/cssnano-preset@3.9.2': + resolution: {integrity: sha512-8gBKup94aGttRduABsj7bpPFTX7kbwu+xh3K9NMCF5K4bWBqTFYW+REKHF6iBVDHRJ4grZdIPbvkiHd/XNKRMQ==} + engines: {node: '>=20.0'} + + '@docusaurus/logger@3.9.2': + resolution: {integrity: sha512-/SVCc57ByARzGSU60c50rMyQlBuMIJCjcsJlkphxY6B0GV4UH3tcA1994N8fFfbJ9kX3jIBe/xg3XP5qBtGDbA==} + engines: {node: '>=20.0'} + + '@docusaurus/mdx-loader@3.9.2': + resolution: {integrity: sha512-wiYoGwF9gdd6rev62xDU8AAM8JuLI/hlwOtCzMmYcspEkzecKrP8J8X+KpYnTlACBUUtXNJpSoCwFWJhLRevzQ==} + engines: {node: '>=20.0'} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@docusaurus/module-type-aliases@3.9.2': + resolution: {integrity: sha512-8qVe2QA9hVLzvnxP46ysuofJUIc/yYQ82tvA/rBTrnpXtCjNSFLxEZfd5U8cYZuJIVlkPxamsIgwd5tGZXfvew==} + peerDependencies: + react: '*' + react-dom: '*' + + '@docusaurus/plugin-content-blog@3.9.2': + resolution: {integrity: sha512-3I2HXy3L1QcjLJLGAoTvoBnpOwa6DPUa3Q0dMK19UTY9mhPkKQg/DYhAGTiBUKcTR0f08iw7kLPqOhIgdV3eVQ==} + engines: {node: '>=20.0'} + peerDependencies: + '@docusaurus/plugin-content-docs': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@docusaurus/plugin-content-docs@3.9.2': + resolution: {integrity: sha512-C5wZsGuKTY8jEYsqdxhhFOe1ZDjH0uIYJ9T/jebHwkyxqnr4wW0jTkB72OMqNjsoQRcb0JN3PcSeTwFlVgzCZg==} + engines: {node: '>=20.0'} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@docusaurus/plugin-content-pages@3.9.2': + resolution: {integrity: sha512-s4849w/p4noXUrGpPUF0BPqIAfdAe76BLaRGAGKZ1gTDNiGxGcpsLcwJ9OTi1/V8A+AzvsmI9pkjie2zjIQZKA==} + engines: {node: '>=20.0'} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@docusaurus/plugin-css-cascade-layers@3.9.2': + resolution: {integrity: sha512-w1s3+Ss+eOQbscGM4cfIFBlVg/QKxyYgj26k5AnakuHkKxH6004ZtuLe5awMBotIYF2bbGDoDhpgQ4r/kcj4rQ==} + engines: {node: '>=20.0'} + + '@docusaurus/plugin-debug@3.9.2': + resolution: {integrity: sha512-j7a5hWuAFxyQAkilZwhsQ/b3T7FfHZ+0dub6j/GxKNFJp2h9qk/P1Bp7vrGASnvA9KNQBBL1ZXTe7jlh4VdPdA==} + engines: {node: '>=20.0'} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@docusaurus/plugin-google-analytics@3.9.2': + resolution: {integrity: sha512-mAwwQJ1Us9jL/lVjXtErXto4p4/iaLlweC54yDUK1a97WfkC6Z2k5/769JsFgwOwOP+n5mUQGACXOEQ0XDuVUw==} + engines: {node: '>=20.0'} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@docusaurus/plugin-google-gtag@3.9.2': + resolution: {integrity: sha512-YJ4lDCphabBtw19ooSlc1MnxtYGpjFV9rEdzjLsUnBCeis2djUyCozZaFhCg6NGEwOn7HDDyMh0yzcdRpnuIvA==} + engines: {node: '>=20.0'} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@docusaurus/plugin-google-tag-manager@3.9.2': + resolution: {integrity: sha512-LJtIrkZN/tuHD8NqDAW1Tnw0ekOwRTfobWPsdO15YxcicBo2ykKF0/D6n0vVBfd3srwr9Z6rzrIWYrMzBGrvNw==} + engines: {node: '>=20.0'} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@docusaurus/plugin-sitemap@3.9.2': + resolution: {integrity: sha512-WLh7ymgDXjG8oPoM/T4/zUP7KcSuFYRZAUTl8vR6VzYkfc18GBM4xLhcT+AKOwun6kBivYKUJf+vlqYJkm+RHw==} + engines: {node: '>=20.0'} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@docusaurus/plugin-svgr@3.9.2': + resolution: {integrity: sha512-n+1DE+5b3Lnf27TgVU5jM1d4x5tUh2oW5LTsBxJX4PsAPV0JGcmI6p3yLYtEY0LRVEIJh+8RsdQmRE66wSV8mw==} + engines: {node: '>=20.0'} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@docusaurus/preset-classic@3.9.2': + resolution: {integrity: sha512-IgyYO2Gvaigi21LuDIe+nvmN/dfGXAiMcV/murFqcpjnZc7jxFAxW+9LEjdPt61uZLxG4ByW/oUmX/DDK9t/8w==} + engines: {node: '>=20.0'} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@docusaurus/react-loadable@6.0.0': + resolution: {integrity: sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==} + peerDependencies: + react: '*' + + '@docusaurus/theme-classic@3.9.2': + resolution: {integrity: sha512-IGUsArG5hhekXd7RDb11v94ycpJpFdJPkLnt10fFQWOVxAtq5/D7hT6lzc2fhyQKaaCE62qVajOMKL7OiAFAIA==} + engines: {node: '>=20.0'} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@docusaurus/theme-common@3.9.2': + resolution: {integrity: sha512-6c4DAbR6n6nPbnZhY2V3tzpnKnGL+6aOsLvFL26VRqhlczli9eWG0VDUNoCQEPnGwDMhPS42UhSAnz5pThm5Ag==} + engines: {node: '>=20.0'} + peerDependencies: + '@docusaurus/plugin-content-docs': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@docusaurus/theme-search-algolia@3.9.2': + resolution: {integrity: sha512-GBDSFNwjnh5/LdkxCKQHkgO2pIMX1447BxYUBG2wBiajS21uj64a+gH/qlbQjDLxmGrbrllBrtJkUHxIsiwRnw==} + engines: {node: '>=20.0'} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@docusaurus/theme-translations@3.9.2': + resolution: {integrity: sha512-vIryvpP18ON9T9rjgMRFLr2xJVDpw1rtagEGf8Ccce4CkTrvM/fRB8N2nyWYOW5u3DdjkwKw5fBa+3tbn9P4PA==} + engines: {node: '>=20.0'} + + '@docusaurus/tsconfig@3.9.2': + resolution: {integrity: sha512-j6/Fp4Rlpxsc632cnRnl5HpOWeb6ZKssDj6/XzzAzVGXXfm9Eptx3rxCC+fDzySn9fHTS+CWJjPineCR1bB5WQ==} + + '@docusaurus/types@3.9.2': + resolution: {integrity: sha512-Ux1JUNswg+EfUEmajJjyhIohKceitY/yzjRUpu04WXgvVz+fbhVC0p+R0JhvEu4ytw8zIAys2hrdpQPBHRIa8Q==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@docusaurus/utils-common@3.9.2': + resolution: {integrity: sha512-I53UC1QctruA6SWLvbjbhCpAw7+X7PePoe5pYcwTOEXD/PxeP8LnECAhTHHwWCblyUX5bMi4QLRkxvyZ+IT8Aw==} + engines: {node: '>=20.0'} + + '@docusaurus/utils-validation@3.9.2': + resolution: {integrity: sha512-l7yk3X5VnNmATbwijJkexdhulNsQaNDwoagiwujXoxFbWLcxHQqNQ+c/IAlzrfMMOfa/8xSBZ7KEKDesE/2J7A==} + engines: {node: '>=20.0'} + + '@docusaurus/utils@3.9.2': + resolution: {integrity: sha512-lBSBiRruFurFKXr5Hbsl2thmGweAPmddhF3jb99U4EMDA5L+e5Y1rAkOS07Nvrup7HUMBDrCV45meaxZnt28nQ==} + engines: {node: '>=20.0'} + + '@easyops-cn/autocomplete.js@0.38.1': + resolution: {integrity: sha512-drg76jS6syilOUmVNkyo1c7ZEBPcPuK+aJA7AksM5ZIIbV57DMHCywiCr+uHyv8BE5jUTU98j/H7gVrkHrWW3Q==} + + '@easyops-cn/docusaurus-search-local@0.54.1': + resolution: {integrity: sha512-CexWCS1ktR7ZtvKWvbBhY7+NBRzQPVCAReBed6k5U9M2hdAARI3cK+NBUCegbd4F8VV54Re1HpIb2+GZwl8zEQ==} + engines: {node: '>=12'} + peerDependencies: + '@docusaurus/theme-common': ^2 || ^3 + react: ^16.14.0 || ^17 || ^18 || ^19 + react-dom: ^16.14.0 || 17 || ^18 || ^19 + + '@emnapi/core@1.8.1': + resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + + '@hapi/hoek@9.3.0': + resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} + + '@hapi/topo@5.1.0': + resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@jsonjoy.com/base64@1.1.2': + resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/base64@17.67.0': + resolution: {integrity: sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/buffers@1.2.1': + resolution: {integrity: sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/buffers@17.67.0': + resolution: {integrity: sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/codegen@1.0.0': + resolution: {integrity: sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/codegen@17.67.0': + resolution: {integrity: sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-core@4.56.10': + resolution: {integrity: sha512-PyAEA/3cnHhsGcdY+AmIU+ZPqTuZkDhCXQ2wkXypdLitSpd6d5Ivxhnq4wa2ETRWFVJGabYynBWxIijOswSmOw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-fsa@4.56.10': + resolution: {integrity: sha512-/FVK63ysNzTPOnCCcPoPHt77TOmachdMS422txM4KhxddLdbW1fIbFMYH0AM0ow/YchCyS5gqEjKLNyv71j/5Q==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node-builtins@4.56.10': + resolution: {integrity: sha512-uUnKz8R0YJyKq5jXpZtkGV9U0pJDt8hmYcLRrPjROheIfjMXsz82kXMgAA/qNg0wrZ1Kv+hrg7azqEZx6XZCVw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node-to-fsa@4.56.10': + resolution: {integrity: sha512-oH+O6Y4lhn9NyG6aEoFwIBNKZeYy66toP5LJcDOMBgL99BKQMUf/zWJspdRhMdn/3hbzQsZ8EHHsuekbFLGUWw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node-utils@4.56.10': + resolution: {integrity: sha512-8EuPBgVI2aDPwFdaNQeNpHsyqPi3rr+85tMNG/lHvQLiVjzoZsvxA//Xd8aB567LUhy4QS03ptT+unkD/DIsNg==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node@4.56.10': + resolution: {integrity: sha512-7R4Gv3tkUdW3dXfXiOkqxkElxKNVdd8BDOWC0/dbERd0pXpPY+s2s1Mino+aTvkGrFPiY+mmVxA7zhskm4Ue4Q==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-print@4.56.10': + resolution: {integrity: sha512-JW4fp5mAYepzFsSGrQ48ep8FXxpg4niFWHdF78wDrFGof7F3tKDJln72QFDEn/27M1yHd4v7sKHHVPh78aWcEw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-snapshot@4.56.10': + resolution: {integrity: sha512-DkR6l5fj7+qj0+fVKm/OOXMGfDFCGXLfyHkORH3DF8hxkpDgIHbhf/DwncBMs2igu/ST7OEkexn1gIqoU6Y+9g==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pack@1.21.0': + resolution: {integrity: sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pack@17.67.0': + resolution: {integrity: sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pointer@1.0.2': + resolution: {integrity: sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pointer@17.67.0': + resolution: {integrity: sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/util@1.9.0': + resolution: {integrity: sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/util@17.67.0': + resolution: {integrity: sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@leichtgewicht/ip-codec@2.0.5': + resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} + + '@mdx-js/mdx@3.1.1': + resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + + '@mdx-js/react@3.1.1': + resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==} + peerDependencies: + '@types/react': '>=16' + react: '>=16' + + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + + '@noble/hashes@1.4.0': + resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} + engines: {node: '>= 16'} + + '@node-rs/jieba-android-arm-eabi@1.10.4': + resolution: {integrity: sha512-MhyvW5N3Fwcp385d0rxbCWH42kqDBatQTyP8XbnYbju2+0BO/eTeCCLYj7Agws4pwxn2LtdldXRSKavT7WdzNA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@node-rs/jieba-android-arm64@1.10.4': + resolution: {integrity: sha512-XyDwq5+rQ+Tk55A+FGi6PtJbzf974oqnpyCcCPzwU3QVXJCa2Rr4Lci+fx8oOpU4plT3GuD+chXMYLsXipMgJA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@node-rs/jieba-darwin-arm64@1.10.4': + resolution: {integrity: sha512-G++RYEJ2jo0rxF9626KUy90wp06TRUjAsvY/BrIzEOX/ingQYV/HjwQzNPRR1P1o32a6/U8RGo7zEBhfdybL6w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@node-rs/jieba-darwin-x64@1.10.4': + resolution: {integrity: sha512-MmDNeOb2TXIZCPyWCi2upQnZpPjAxw5ZGEj6R8kNsPXVFALHIKMa6ZZ15LCOkSTsKXVC17j2t4h+hSuyYb6qfQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@node-rs/jieba-freebsd-x64@1.10.4': + resolution: {integrity: sha512-/x7aVQ8nqUWhpXU92RZqd333cq639i/olNpd9Z5hdlyyV5/B65LLy+Je2B2bfs62PVVm5QXRpeBcZqaHelp/bg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@node-rs/jieba-linux-arm-gnueabihf@1.10.4': + resolution: {integrity: sha512-crd2M35oJBRLkoESs0O6QO3BBbhpv+tqXuKsqhIG94B1d02RVxtRIvSDwO33QurxqSdvN9IeSnVpHbDGkuXm3g==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@node-rs/jieba-linux-arm64-gnu@1.10.4': + resolution: {integrity: sha512-omIzNX1psUzPcsdnUhGU6oHeOaTCuCjUgOA/v/DGkvWC1jLcnfXe4vdYbtXMh4XOCuIgS1UCcvZEc8vQLXFbXQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@node-rs/jieba-linux-arm64-musl@1.10.4': + resolution: {integrity: sha512-Y/tiJ1+HeS5nnmLbZOE+66LbsPOHZ/PUckAYVeLlQfpygLEpLYdlh0aPpS5uiaWMjAXYZYdFkpZHhxDmSLpwpw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@node-rs/jieba-linux-x64-gnu@1.10.4': + resolution: {integrity: sha512-WZO8ykRJpWGE9MHuZpy1lu3nJluPoeB+fIJJn5CWZ9YTVhNDWoCF4i/7nxz1ntulINYGQ8VVuCU9LD86Mek97g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@node-rs/jieba-linux-x64-musl@1.10.4': + resolution: {integrity: sha512-uBBD4S1rGKcgCyAk6VCKatEVQb6EDD5I40v/DxODi5CuZVCANi9m5oee/MQbAoaX7RydA2f0OSCE9/tcwXEwUg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@node-rs/jieba-wasm32-wasi@1.10.4': + resolution: {integrity: sha512-Y2umiKHjuIJy0uulNDz9SDYHdfq5Hmy7jY5nORO99B4pySKkcrMjpeVrmWXJLIsEKLJwcCXHxz8tjwU5/uhz0A==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@node-rs/jieba-win32-arm64-msvc@1.10.4': + resolution: {integrity: sha512-nwMtViFm4hjqhz1it/juQnxpXgqlGltCuWJ02bw70YUDMDlbyTy3grCJPpQQpueeETcALUnTxda8pZuVrLRcBA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@node-rs/jieba-win32-ia32-msvc@1.10.4': + resolution: {integrity: sha512-DCAvLx7Z+W4z5oKS+7vUowAJr0uw9JBw8x1Y23Xs/xMA4Em+OOSiaF5/tCJqZUCJ8uC4QeImmgDFiBqGNwxlyA==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@node-rs/jieba-win32-x64-msvc@1.10.4': + resolution: {integrity: sha512-+sqemSfS1jjb+Tt7InNbNzrRh1Ua3vProVvC4BZRPg010/leCbGFFiQHpzcPRfpxAXZrzG5Y0YBTsPzN/I4yHQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@node-rs/jieba@1.10.4': + resolution: {integrity: sha512-GvDgi8MnBiyWd6tksojej8anIx18244NmIOc1ovEw8WKNUejcccLfyu8vj66LWSuoZuKILVtNsOy4jvg3aoxIw==} + engines: {node: '>= 10'} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@peculiar/asn1-cms@2.6.0': + resolution: {integrity: sha512-2uZqP+ggSncESeUF/9Su8rWqGclEfEiz1SyU02WX5fUONFfkjzS2Z/F1Li0ofSmf4JqYXIOdCAZqIXAIBAT1OA==} + + '@peculiar/asn1-csr@2.6.0': + resolution: {integrity: sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ==} + + '@peculiar/asn1-ecc@2.6.0': + resolution: {integrity: sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw==} + + '@peculiar/asn1-pfx@2.6.0': + resolution: {integrity: sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ==} + + '@peculiar/asn1-pkcs8@2.6.0': + resolution: {integrity: sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA==} + + '@peculiar/asn1-pkcs9@2.6.0': + resolution: {integrity: sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw==} + + '@peculiar/asn1-rsa@2.6.0': + resolution: {integrity: sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w==} + + '@peculiar/asn1-schema@2.6.0': + resolution: {integrity: sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==} + + '@peculiar/asn1-x509-attr@2.6.0': + resolution: {integrity: sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA==} + + '@peculiar/asn1-x509@2.6.0': + resolution: {integrity: sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==} + + '@peculiar/x509@1.14.3': + resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} + engines: {node: '>=20.0.0'} + + '@pnpm/config.env-replace@1.1.0': + resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} + engines: {node: '>=12.22.0'} + + '@pnpm/network.ca-file@1.0.2': + resolution: {integrity: sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==} + engines: {node: '>=12.22.0'} + + '@pnpm/npm-conf@3.0.2': + resolution: {integrity: sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==} + engines: {node: '>=12'} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.10': + resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@sideway/address@4.1.5': + resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} + + '@sideway/formula@3.0.1': + resolution: {integrity: sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==} + + '@sideway/pinpoint@2.0.0': + resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} + + '@sinclair/typebox@0.27.10': + resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + + '@sindresorhus/is@5.6.0': + resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==} + engines: {node: '>=14.16'} + + '@slorber/react-helmet-async@1.3.0': + resolution: {integrity: sha512-e9/OK8VhwUSc67diWI8Rb3I0YgI9/SBQtnhe9aEuK6MhZm7ntZZimXgwXnd8W96YTmSOb9M4d8LwhRZyhWr/1A==} + peerDependencies: + react: ^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@slorber/remark-comment@1.0.0': + resolution: {integrity: sha512-RCE24n7jsOj1M0UPvIQCHTe7fI0sFL4S2nwKVWwHyVr/wI/H8GosgsJGyhnsZoGFnD/P2hLf1mSbrrgSLN93NA==} + + '@svgr/babel-plugin-add-jsx-attribute@8.0.0': + resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-remove-jsx-attribute@8.0.0': + resolution: {integrity: sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0': + resolution: {integrity: sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0': + resolution: {integrity: sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-svg-dynamic-title@8.0.0': + resolution: {integrity: sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-svg-em-dimensions@8.0.0': + resolution: {integrity: sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-transform-react-native-svg@8.1.0': + resolution: {integrity: sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-transform-svg-component@8.0.0': + resolution: {integrity: sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==} + engines: {node: '>=12'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-preset@8.1.0': + resolution: {integrity: sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/core@8.1.0': + resolution: {integrity: sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==} + engines: {node: '>=14'} + + '@svgr/hast-util-to-babel-ast@8.0.0': + resolution: {integrity: sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==} + engines: {node: '>=14'} + + '@svgr/plugin-jsx@8.1.0': + resolution: {integrity: sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==} + engines: {node: '>=14'} + peerDependencies: + '@svgr/core': '*' + + '@svgr/plugin-svgo@8.1.0': + resolution: {integrity: sha512-Ywtl837OGO9pTLIN/onoWLmDQ4zFUycI1g76vuKGEz6evR/ZTJlJuz3G/fIkb6OVBJ2g0o6CGJzaEjfmEo3AHA==} + engines: {node: '>=14'} + peerDependencies: + '@svgr/core': '*' + + '@svgr/webpack@8.1.0': + resolution: {integrity: sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA==} + engines: {node: '>=14'} + + '@szmarczak/http-timer@5.0.1': + resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} + engines: {node: '>=14.16'} + + '@trysound/sax@0.2.0': + resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} + engines: {node: '>=10.13.0'} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/bonjour@3.5.13': + resolution: {integrity: sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==} + + '@types/connect-history-api-fallback@1.5.4': + resolution: {integrity: sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/debug@4.1.12': + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + + '@types/eslint-scope@3.7.7': + resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} + + '@types/eslint@9.6.1': + resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/express-serve-static-core@4.19.8': + resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==} + + '@types/express@4.17.25': + resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + + '@types/gtag.js@0.0.12': + resolution: {integrity: sha512-YQV9bUsemkzG81Ea295/nF/5GijnD2Af7QhEofh7xu+kvCN6RdodgNwwGWXB5GMI3NoyvQo0odNctoH/qLMIpg==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/history@4.7.11': + resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==} + + '@types/html-minifier-terser@6.1.0': + resolution: {integrity: sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==} + + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/http-proxy@1.17.17': + resolution: {integrity: sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdx@2.0.13': + resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} + + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@17.0.45': + resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==} + + '@types/node@25.2.3': + resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} + + '@types/prismjs@1.26.5': + resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} + + '@types/qs@6.14.0': + resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/react-router-config@5.0.11': + resolution: {integrity: sha512-WmSAg7WgqW7m4x8Mt4N6ZyKz0BubSj/2tVUMsAHp+Yd2AMwcSbeFq9WympT19p5heCFmF97R9eD5uUR/t4HEqw==} + + '@types/react-router-dom@5.3.3': + resolution: {integrity: sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==} + + '@types/react-router@5.1.20': + resolution: {integrity: sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==} + + '@types/react@19.2.13': + resolution: {integrity: sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==} + + '@types/retry@0.12.2': + resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==} + + '@types/sax@1.2.7': + resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} + + '@types/send@0.17.6': + resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-index@1.9.4': + resolution: {integrity: sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==} + + '@types/serve-static@1.15.10': + resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + + '@types/sockjs@0.3.36': + resolution: {integrity: sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@webassemblyjs/ast@1.14.1': + resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} + + '@webassemblyjs/floating-point-hex-parser@1.13.2': + resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} + + '@webassemblyjs/helper-api-error@1.13.2': + resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} + + '@webassemblyjs/helper-buffer@1.14.1': + resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} + + '@webassemblyjs/helper-numbers@1.13.2': + resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': + resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} + + '@webassemblyjs/helper-wasm-section@1.14.1': + resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} + + '@webassemblyjs/ieee754@1.13.2': + resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} + + '@webassemblyjs/leb128@1.13.2': + resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} + + '@webassemblyjs/utf8@1.13.2': + resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} + + '@webassemblyjs/wasm-edit@1.14.1': + resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} + + '@webassemblyjs/wasm-gen@1.14.1': + resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} + + '@webassemblyjs/wasm-opt@1.14.1': + resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} + + '@webassemblyjs/wasm-parser@1.14.1': + resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} + + '@webassemblyjs/wast-printer@1.14.1': + resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + + '@xtuc/ieee754@1.2.0': + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + + '@xtuc/long@4.2.2': + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + acorn-import-phases@1.0.4: + resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} + engines: {node: '>=10.13.0'} + peerDependencies: + acorn: ^8.14.0 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + address@1.2.2: + resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==} + engines: {node: '>= 10.0.0'} + + aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-keywords@3.5.2: + resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + peerDependencies: + ajv: ^6.9.1 + + ajv-keywords@5.1.0: + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + algoliasearch-helper@3.27.1: + resolution: {integrity: sha512-XXGr02Cz285vLbqM6vPfb39xqV1ptpFr1xn9mqaW+nUvYTvFTdKgYTC/Cg1VzgRTQqNkq9+LlUVv8cfCeOoKig==} + peerDependencies: + algoliasearch: '>= 3.1 < 6' + + algoliasearch@5.48.0: + resolution: {integrity: sha512-aD8EQC6KEman6/S79FtPdQmB7D4af/etcRL/KwiKFKgAE62iU8c5PeEQvpvIcBPurC3O/4Lj78nOl7ZcoazqSw==} + engines: {node: '>= 14.0.0'} + + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-html-community@0.0.8: + resolution: {integrity: sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==} + engines: {'0': node >= 0.8.0} + hasBin: true + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + asn1js@3.0.7: + resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==} + engines: {node: '>=12.0.0'} + + astring@1.9.0: + resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} + hasBin: true + + autoprefixer@10.4.24: + resolution: {integrity: sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + babel-loader@9.2.1: + resolution: {integrity: sha512-fqe8naHt46e0yIdkjUZYqddSXfej3AHajX+CSO5X7oy0EmPc6o5Xh+RClNoHjnieWz9AW4kZxW9yyFMhVB1QLA==} + engines: {node: '>= 14.15.0'} + peerDependencies: + '@babel/core': ^7.12.0 + webpack: '>=5' + + babel-plugin-dynamic-import-node@2.3.3: + resolution: {integrity: sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==} + + babel-plugin-polyfill-corejs2@0.4.15: + resolution: {integrity: sha512-hR3GwrRwHUfYwGfrisXPIDP3JcYfBrW7wKE7+Au6wDYl7fm/ka1NEII6kORzxNU556JjfidZeBsO10kYvtV1aw==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-corejs3@0.13.0: + resolution: {integrity: sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-corejs3@0.14.0: + resolution: {integrity: sha512-AvDcMxJ34W4Wgy4KBIIePQTAOP1Ie2WFwkQp3dB7FQ/f0lI5+nM96zUnYEOE1P9sEg0es5VCP0HxiWu5fUHZAQ==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-regenerator@0.6.6: + resolution: {integrity: sha512-hYm+XLYRMvupxiQzrvXUj7YyvFFVfv5gI0R71AJzudg1g2AI2vyCPPIFEBjk162/wFzti3inBHo7isWFuEVS/A==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + baseline-browser-mapping@2.9.19: + resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} + hasBin: true + + batch@0.6.1: + resolution: {integrity: sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==} + + big.js@5.2.2: + resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + body-parser@1.20.4: + resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + bonjour-service@1.3.0: + resolution: {integrity: sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + boxen@6.2.1: + resolution: {integrity: sha512-H4PEsJXfFI/Pt8sjDWbHlQPx4zL/bvSQjcilJmaulGt5mLDorHOHpmdXAJcBcmru7PhYSp/cDMWRko4ZUMFkSw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + boxen@7.1.1: + resolution: {integrity: sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==} + engines: {node: '>=14.16'} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + bytes@3.0.0: + resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} + engines: {node: '>= 0.8'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + bytestreamjs@2.0.1: + resolution: {integrity: sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==} + engines: {node: '>=6.0.0'} + + cacheable-lookup@7.0.0: + resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==} + engines: {node: '>=14.16'} + + cacheable-request@10.2.14: + resolution: {integrity: sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==} + engines: {node: '>=14.16'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camel-case@4.1.2: + resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + camelcase@7.0.1: + resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==} + engines: {node: '>=14.16'} + + caniuse-api@3.0.0: + resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} + + caniuse-lite@1.0.30001769: + resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.0.0-rc.12: + resolution: {integrity: sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==} + engines: {node: '>= 6'} + + cheerio@1.2.0: + resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} + engines: {node: '>=20.18.1'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + clean-css@5.3.3: + resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==} + engines: {node: '>= 10.0'} + + clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + + clone-deep@4.0.1: + resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} + engines: {node: '>=6'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + collapse-white-space@2.1.0: + resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combine-promises@1.2.0: + resolution: {integrity: sha512-VcQB1ziGD0NXrhKxiwyNbCDmRzs/OShMs2GqW2DlU2A/Sd0nQxE1oWDAE5O0ygSx5mgQOn9eIFh7yKPgFRVkPQ==} + engines: {node: '>=10'} + + comlink@4.4.2: + resolution: {integrity: sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@5.1.0: + resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} + engines: {node: '>= 6'} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + common-path-prefix@3.0.0: + resolution: {integrity: sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==} + + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + + configstore@6.0.0: + resolution: {integrity: sha512-cD31W1v3GqUlQvbBCGcXmd2Nj9SvLDOP1oQ0YFuLETufzSPaKp11rYBsSOm7rCsW3OnIRAFM3OxRhceaXNYHkA==} + engines: {node: '>=12'} + + connect-history-api-fallback@2.0.0: + resolution: {integrity: sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==} + engines: {node: '>=0.8'} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + content-disposition@0.5.2: + resolution: {integrity: sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==} + engines: {node: '>= 0.6'} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + copy-webpack-plugin@11.0.0: + resolution: {integrity: sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==} + engines: {node: '>= 14.15.0'} + peerDependencies: + webpack: ^5.1.0 + + core-js-compat@3.48.0: + resolution: {integrity: sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==} + + core-js-pure@3.48.0: + resolution: {integrity: sha512-1slJgk89tWC51HQ1AEqG+s2VuwpTRr8ocu4n20QUcH1v9lAN0RXen0Q0AABa/DK1I7RrNWLucplOHMx8hfTGTw==} + + core-js@3.48.0: + resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cosmiconfig@8.3.6: + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + crypto-random-string@4.0.0: + resolution: {integrity: sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==} + engines: {node: '>=12'} + + css-blank-pseudo@7.0.1: + resolution: {integrity: sha512-jf+twWGDf6LDoXDUode+nc7ZlrqfaNphrBIBrcmeP3D8yw1uPaix1gCC8LUQUGQ6CycuK2opkbFFWFuq/a94ag==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + css-declaration-sorter@7.3.1: + resolution: {integrity: sha512-gz6x+KkgNCjxq3Var03pRYLhyNfwhkKF1g/yoLgDNtFvVu0/fOLV9C8fFEZRjACp/XQLumjAYo7JVjzH3wLbxA==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.0.9 + + css-has-pseudo@7.0.3: + resolution: {integrity: sha512-oG+vKuGyqe/xvEMoxAQrhi7uY16deJR3i7wwhBerVrGQKSqUC5GiOVxTpM9F9B9hw0J+eKeOWLH7E9gZ1Dr5rA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + css-loader@6.11.0: + resolution: {integrity: sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g==} + engines: {node: '>= 12.13.0'} + peerDependencies: + '@rspack/core': 0.x || 1.x + webpack: ^5.0.0 + peerDependenciesMeta: + '@rspack/core': + optional: true + webpack: + optional: true + + css-minimizer-webpack-plugin@5.0.1: + resolution: {integrity: sha512-3caImjKFQkS+ws1TGcFn0V1HyDJFq1Euy589JlD6/3rV2kj+w7r5G9WDMgSHvpvXHNZ2calVypZWuEDQd9wfLg==} + engines: {node: '>= 14.15.0'} + peerDependencies: + '@parcel/css': '*' + '@swc/css': '*' + clean-css: '*' + csso: '*' + esbuild: '*' + lightningcss: '*' + webpack: ^5.0.0 + peerDependenciesMeta: + '@parcel/css': + optional: true + '@swc/css': + optional: true + clean-css: + optional: true + csso: + optional: true + esbuild: + optional: true + lightningcss: + optional: true + + css-prefers-color-scheme@10.0.0: + resolution: {integrity: sha512-VCtXZAWivRglTZditUfB4StnsWr6YVZ2PRtuxQLKTNRdtAf8tpzaVPE9zXIF3VaSc7O70iK/j1+NXxyQCqdPjQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + css-select@4.3.0: + resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==} + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + cssdb@8.7.1: + resolution: {integrity: sha512-+F6LKx48RrdGOtE4DT5jz7Uo+VeyKXpK797FAevIkzjV8bMHz6xTO5F7gNDcRCHmPgD5jj2g6QCsY9zmVrh38A==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssnano-preset-advanced@6.1.2: + resolution: {integrity: sha512-Nhao7eD8ph2DoHolEzQs5CfRpiEP0xa1HBdnFZ82kvqdmbwVBUr2r1QuQ4t1pi+D1ZpqpcO4T+wy/7RxzJ/WPQ==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + cssnano-preset-default@6.1.2: + resolution: {integrity: sha512-1C0C+eNaeN8OcHQa193aRgYexyJtU8XwbdieEjClw+J9d94E41LwT6ivKH0WT+fYwYWB0Zp3I3IZ7tI/BbUbrg==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + cssnano-utils@4.0.2: + resolution: {integrity: sha512-ZR1jHg+wZ8o4c3zqf1SIUSTIvm/9mU343FMR6Obe/unskbvpGhZOo1J6d/r8D1pzkRQYuwbcH3hToOuoA2G7oQ==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + cssnano@6.1.2: + resolution: {integrity: sha512-rYk5UeX7VAM/u0lNqewCdasdtPK81CgX8wJFLEIXHbV2oldWRgJAsZrdhRXkV1NJzA2g850KiFm9mMU2HxNxMA==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debounce@1.2.1: + resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + defer-to-connect@2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + depd@1.1.2: + resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} + engines: {node: '>= 0.6'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + + detect-port@1.6.1: + resolution: {integrity: sha512-CmnVc+Hek2egPx1PeTFVta2W78xy2K/9Rkf6cC4T59S50tVnzKj+tnx5mmx5lwvCkujZ4uRrpRSuV+IVs3f90Q==} + engines: {node: '>= 4.0.0'} + hasBin: true + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dns-packet@5.6.1: + resolution: {integrity: sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==} + engines: {node: '>=6'} + + dom-converter@0.2.0: + resolution: {integrity: sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==} + + dom-serializer@1.4.1: + resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@4.3.1: + resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} + engines: {node: '>= 4'} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@2.8.0: + resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dot-case@3.0.4: + resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + + dot-prop@6.0.1: + resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} + engines: {node: '>=10'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + duplexer@0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.286: + resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + emojilib@2.4.0: + resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==} + + emojis-list@3.0.0: + resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==} + engines: {node: '>= 4'} + + emoticon@4.1.0: + resolution: {integrity: sha512-VWZfnxqwNcc51hIy/sbOdEem6D+cVtpPzEEtVAFdaas30+1dgkyaOQ4sQ6Bp0tOMqWO1v+HQfYaoodOkdhK6SQ==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + encoding-sniffer@0.2.1: + resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} + + enhanced-resolve@5.19.0: + resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} + engines: {node: '>=10.13.0'} + + entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + esast-util-from-estree@2.0.0: + resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} + + esast-util-from-js@2.0.1: + resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-goat@4.0.0: + resolution: {integrity: sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==} + engines: {node: '>=12'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-util-attach-comments@3.0.0: + resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} + + estree-util-build-jsx@3.0.1: + resolution: {integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + estree-util-scope@1.0.0: + resolution: {integrity: sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==} + + estree-util-to-js@2.0.0: + resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==} + + estree-util-value-to-estree@3.5.0: + resolution: {integrity: sha512-aMV56R27Gv3QmfmF1MY12GWkGzzeAezAX+UplqHVASfjc9wNzI/X6hC0S9oxq61WT4aQesLGslWP9tKk6ghRZQ==} + + estree-util-visit@2.0.0: + resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + eta@2.2.0: + resolution: {integrity: sha512-UVQ72Rqjy/ZKQalzV5dCCJP80GrmPrMxh6NlNf+erV6ObL0ZFkhCstWRawS85z3smdr3d2wXPsZEY7rDPfGd2g==} + engines: {node: '>=6.0.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eval@0.1.8: + resolution: {integrity: sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw==} + engines: {node: '>= 0.8'} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + express@4.22.1: + resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} + engines: {node: '>= 0.10.0'} + + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fault@2.0.1: + resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + + faye-websocket@0.11.4: + resolution: {integrity: sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==} + engines: {node: '>=0.8.0'} + + feed@4.2.2: + resolution: {integrity: sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ==} + engines: {node: '>=0.4.0'} + + figures@3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + + file-loader@6.2.0: + resolution: {integrity: sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==} + engines: {node: '>= 10.13.0'} + peerDependencies: + webpack: ^4.0.0 || ^5.0.0 + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + + find-cache-dir@4.0.0: + resolution: {integrity: sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==} + engines: {node: '>=14.16'} + + find-up@6.3.0: + resolution: {integrity: sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + flat@5.0.2: + resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} + hasBin: true + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data-encoder@2.1.4: + resolution: {integrity: sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==} + engines: {node: '>= 14.17'} + + format@0.2.2: + resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} + engines: {node: '>=0.4.x'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs-extra@11.3.3: + resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} + engines: {node: '>=14.14'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-own-enumerable-property-symbols@3.0.2: + resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + github-slugger@1.5.0: + resolution: {integrity: sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob-to-regex.js@1.2.0: + resolution: {integrity: sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + + global-dirs@3.0.1: + resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} + engines: {node: '>=10'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + globby@13.2.2: + resolution: {integrity: sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + got@12.6.1: + resolution: {integrity: sha512-mThBblvlAF1d4O5oqyvN+ZxLAYwIJK7bpMxgYqPD9okW0C3qm5FFn7k811QrcuEBwaogR3ngOFoCfs6mRv7teQ==} + engines: {node: '>=14.16'} + + graceful-fs@4.2.10: + resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + gray-matter@4.0.3: + resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} + engines: {node: '>=6.0'} + + gzip-size@6.0.0: + resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} + engines: {node: '>=10'} + + handle-thing@2.0.1: + resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-yarn@3.0.0: + resolution: {integrity: sha512-IrsVwUHhEULx3R8f/aA8AHuEzAorplsab/v8HBzEiIukwq5i/EC+xmOW+HfP1OaDP+2JkgT1yILHN2O3UFIbcA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-to-estree@3.1.3: + resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + + history@4.10.1: + resolution: {integrity: sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==} + + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + + hpack.js@2.1.6: + resolution: {integrity: sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + html-minifier-terser@6.1.0: + resolution: {integrity: sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==} + engines: {node: '>=12'} + hasBin: true + + html-minifier-terser@7.2.0: + resolution: {integrity: sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==} + engines: {node: ^14.13.1 || >=16.0.0} + hasBin: true + + html-tags@3.3.1: + resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} + engines: {node: '>=8'} + + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + html-webpack-plugin@5.6.6: + resolution: {integrity: sha512-bLjW01UTrvoWTJQL5LsMRo1SypHW80FTm12OJRSnr3v6YHNhfe+1r0MYUZJMACxnCHURVnBWRwAsWs2yPU9Ezw==} + engines: {node: '>=10.13.0'} + peerDependencies: + '@rspack/core': 0.x || 1.x + webpack: ^5.20.0 + peerDependenciesMeta: + '@rspack/core': + optional: true + webpack: + optional: true + + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + + htmlparser2@6.1.0: + resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==} + + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + http-deceiver@1.2.7: + resolution: {integrity: sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==} + + http-errors@1.8.1: + resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==} + engines: {node: '>= 0.6'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-parser-js@0.5.10: + resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==} + + http-proxy-middleware@2.0.9: + resolution: {integrity: sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/express': ^4.17.13 + peerDependenciesMeta: + '@types/express': + optional: true + + http-proxy@1.18.1: + resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} + engines: {node: '>=8.0.0'} + + http2-wrapper@2.2.1: + resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} + engines: {node: '>=10.19.0'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + hyperdyperid@1.2.0: + resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} + engines: {node: '>=10.18'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + icss-utils@5.1.0: + resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + image-size@2.0.2: + resolution: {integrity: sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==} + engines: {node: '>=16.x'} + hasBin: true + + immediate@3.3.0: + resolution: {integrity: sha512-HR7EVodfFUdQCTIeySw+WDRFJlPcLOJbXfwwZ7Oom6tjsvZ3bOkCDJHehQC3nxJrv7+f9XecwazynjU8e4Vw3Q==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-lazy@4.0.0: + resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} + engines: {node: '>=8'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + infima@0.2.0-alpha.45: + resolution: {integrity: sha512-uyH0zfr1erU1OohLk0fT4Rrb94AOhguWNOcD9uGrSpRvNB+6gZXUoJX5J0NtvzBO10YZ9PgvA4NFgt+fYg8ojw==} + engines: {node: '>=12'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ini@2.0.0: + resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==} + engines: {node: '>=10'} + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + ipaddr.js@2.3.0: + resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} + engines: {node: '>= 10'} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-ci@3.0.1: + resolution: {integrity: sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==} + hasBin: true + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-installed-globally@0.4.0: + resolution: {integrity: sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==} + engines: {node: '>=10'} + + is-network-error@1.3.0: + resolution: {integrity: sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==} + engines: {node: '>=16'} + + is-npm@6.1.0: + resolution: {integrity: sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-obj@1.0.1: + resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==} + engines: {node: '>=0.10.0'} + + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-plain-obj@3.0.0: + resolution: {integrity: sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==} + engines: {node: '>=10'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-plain-object@2.0.4: + resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} + engines: {node: '>=0.10.0'} + + is-regexp@1.0.0: + resolution: {integrity: sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==} + engines: {node: '>=0.10.0'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-typedarray@1.0.0: + resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + is-wsl@3.1.0: + resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} + engines: {node: '>=16'} + + is-yarn-global@0.4.1: + resolution: {integrity: sha512-/kppl+R+LO5VmhYSEWARUFjodS25D68gvj8W7z0I7OWhUla5xWu8KL6CtB2V0R6yqhnRgbcaREMr4EEM6htLPQ==} + engines: {node: '>=12'} + + isarray@0.0.1: + resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isobject@3.0.1: + resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} + engines: {node: '>=0.10.0'} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + + jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + joi@17.13.3: + resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + klaw-sync@6.0.0: + resolution: {integrity: sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + latest-version@7.0.0: + resolution: {integrity: sha512-KvNT4XqAMzdcL6ka6Tl3i2lYeFDgXNCuIX+xNx6ZMVR1dFq+idXd9FLKNMOIx0t9mJ9/HudyX4oZWXZQ0UJHeg==} + engines: {node: '>=14.16'} + + launch-editor@2.12.0: + resolution: {integrity: sha512-giOHXoOtifjdHqUamwKq6c49GzBdLjvxrd2D+Q4V6uOHopJv7p9VJxikDsQ/CBXZbEITgUqSVHXLTG3VhPP1Dg==} + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + loader-runner@4.3.1: + resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} + engines: {node: '>=6.11.5'} + + loader-utils@2.0.4: + resolution: {integrity: sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==} + engines: {node: '>=8.9.0'} + + locate-path@7.2.0: + resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lower-case@2.0.2: + resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + + lowercase-keys@3.0.0: + resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + lowlight@3.3.0: + resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.563.0: + resolution: {integrity: sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + lunr-languages@1.14.0: + resolution: {integrity: sha512-hWUAb2KqM3L7J5bcrngszzISY4BxrXn/Xhbb9TTCJYEGqlR1nG67/M14sp09+PTIRklobrn57IAxcdcO/ZFyNA==} + + lunr@2.3.9: + resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + + mark.js@8.11.1: + resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + + markdown-extensions@2.0.0: + resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} + engines: {node: '>=16'} + + markdown-table@2.0.0: + resolution: {integrity: sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdast-util-directive@3.1.0: + resolution: {integrity: sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.2: + resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} + + mdast-util-frontmatter@2.0.1: + resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdx@3.0.0: + resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + memfs@4.56.10: + resolution: {integrity: sha512-eLvzyrwqLHnLYalJP7YZ3wBe79MXktMdfQbvMrVD80K+NhrIukCVBvgP30zTJYEEDh9hZ/ep9z0KOdD7FSHo7w==} + peerDependencies: + tslib: '2' + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-directive@3.0.2: + resolution: {integrity: sha512-wjcXHgk+PPdmvR58Le9d7zQYWy+vKEU9Se44p2CrCDPiLr2FMyiT4Fyb5UFKFC66wGB3kPlgD7q3TnoqPS7SZA==} + + micromark-extension-frontmatter@2.0.0: + resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-extension-mdx-expression@3.0.1: + resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} + + micromark-extension-mdx-jsx@3.0.2: + resolution: {integrity: sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==} + + micromark-extension-mdx-md@2.0.0: + resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==} + + micromark-extension-mdxjs-esm@3.0.0: + resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} + + micromark-extension-mdxjs@3.0.0: + resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-mdx-expression@2.0.3: + resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==} + + micromark-factory-space@1.1.0: + resolution: {integrity: sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@1.2.0: + resolution: {integrity: sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-events-to-acorn@2.0.3: + resolution: {integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@1.1.0: + resolution: {integrity: sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@1.1.0: + resolution: {integrity: sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.33.0: + resolution: {integrity: sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.18: + resolution: {integrity: sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + mimic-response@4.0.0: + resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + mini-css-extract-plugin@2.10.0: + resolution: {integrity: sha512-540P2c5dYnJlyJxTaSloliZexv8rji6rY8FhQN+WF/82iHQfA23j/xtJx97L+mXOML27EqksSek/g4eK7jaL3g==} + engines: {node: '>= 12.13.0'} + peerDependencies: + webpack: ^5.0.0 + + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multicast-dns@7.2.5: + resolution: {integrity: sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==} + hasBin: true + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + no-case@3.0.4: + resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + + node-emoji@2.2.0: + resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} + engines: {node: '>=18'} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-url@8.1.1: + resolution: {integrity: sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==} + engines: {node: '>=14.16'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + nprogress@0.2.0: + resolution: {integrity: sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + null-loader@4.0.1: + resolution: {integrity: sha512-pxqVbi4U6N26lq+LmgIbB5XATP0VdZKOG25DhHi8btMmJJefGArFyDg1yc4U3hWCJbMqSrw0qyrz1UQX+qYXqg==} + engines: {node: '>= 10.13.0'} + peerDependencies: + webpack: ^4.0.0 || ^5.0.0 + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + obuf@1.1.2: + resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + open-ask-ai@0.7.3: + resolution: {integrity: sha512-UxV0HGds4TEUTKQ4B2Ip2uI0sE4UnKhE8Rj1rjfheOXKKWflpgAMsB+jBbiT648zzUfkuS6MHrMrrF5Ee8RiIA==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + + opener@1.5.2: + resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} + hasBin: true + + p-cancelable@3.0.0: + resolution: {integrity: sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==} + engines: {node: '>=12.20'} + + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + + p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + p-map@4.0.0: + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} + + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + + p-retry@6.2.1: + resolution: {integrity: sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==} + engines: {node: '>=16.17'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + + package-json@8.1.1: + resolution: {integrity: sha512-cbH9IAIJHNj9uXi196JVsRlt7cHKak6u/e6AkL/bkRelZ7rlL3X1YKxsZwa36xipOEKAsdtmaG6aAJoM1fx2zA==} + engines: {node: '>=14.16'} + + param-case@3.0.4: + resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-numeric-range@1.3.0: + resolution: {integrity: sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==} + + parse5-htmlparser2-tree-adapter@7.1.0: + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} + + parse5-parser-stream@7.1.2: + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + pascal-case@3.1.2: + resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} + + path-exists@5.0.0: + resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + path-is-inside@1.0.2: + resolution: {integrity: sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-to-regexp@0.1.12: + resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + + path-to-regexp@1.9.0: + resolution: {integrity: sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==} + + path-to-regexp@3.3.0: + resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + pkg-dir@7.0.0: + resolution: {integrity: sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==} + engines: {node: '>=14.16'} + + pkijs@3.3.3: + resolution: {integrity: sha512-+KD8hJtqQMYoTuL1bbGOqxb4z+nZkTAwVdNtWwe8Tc2xNbEmdJYIYoc6Qt0uF55e6YW6KuTHw1DjQ18gMhzepw==} + engines: {node: '>=16.0.0'} + + postcss-attribute-case-insensitive@7.0.1: + resolution: {integrity: sha512-Uai+SupNSqzlschRyNx3kbCTWgY/2hcwtHEI/ej2LJWc9JJ77qKgGptd8DHwY1mXtZ7Aoh4z4yxfwMBue9eNgw==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-calc@9.0.1: + resolution: {integrity: sha512-TipgjGyzP5QzEhsOZUaIkeO5mKeMFpebWzRogWG/ysonUlnHcq5aJe0jOjpfzUU8PeSaBQnrE8ehR0QA5vs8PQ==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.2.2 + + postcss-clamp@4.1.0: + resolution: {integrity: sha512-ry4b1Llo/9zz+PKC+030KUnPITTJAHeOwjfAyyB60eT0AorGLdzp52s31OsPRHRf8NchkgFoG2y6fCfn1IV1Ow==} + engines: {node: '>=7.6.0'} + peerDependencies: + postcss: ^8.4.6 + + postcss-color-functional-notation@7.0.12: + resolution: {integrity: sha512-TLCW9fN5kvO/u38/uesdpbx3e8AkTYhMvDZYa9JpmImWuTE99bDQ7GU7hdOADIZsiI9/zuxfAJxny/khknp1Zw==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-color-hex-alpha@10.0.0: + resolution: {integrity: sha512-1kervM2cnlgPs2a8Vt/Qbe5cQ++N7rkYo/2rz2BkqJZIHQwaVuJgQH38REHrAi4uM0b1fqxMkWYmese94iMp3w==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-color-rebeccapurple@10.0.0: + resolution: {integrity: sha512-JFta737jSP+hdAIEhk1Vs0q0YF5P8fFcj+09pweS8ktuGuZ8pPlykHsk6mPxZ8awDl4TrcxUqJo9l1IhVr/OjQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-colormin@6.1.0: + resolution: {integrity: sha512-x9yX7DOxeMAR+BgGVnNSAxmAj98NX/YxEMNFP+SDCEeNLb2r3i6Hh1ksMsnW8Ub5SLCpbescQqn9YEbE9554Sw==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-convert-values@6.1.0: + resolution: {integrity: sha512-zx8IwP/ts9WvUM6NkVSkiU902QZL1bwPhaVaLynPtCsOTqp+ZKbNi+s6XJg3rfqpKGA/oc7Oxk5t8pOQJcwl/w==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-custom-media@11.0.6: + resolution: {integrity: sha512-C4lD4b7mUIw+RZhtY7qUbf4eADmb7Ey8BFA2px9jUbwg7pjTZDl4KY4bvlUV+/vXQvzQRfiGEVJyAbtOsCMInw==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-custom-properties@14.0.6: + resolution: {integrity: sha512-fTYSp3xuk4BUeVhxCSJdIPhDLpJfNakZKoiTDx7yRGCdlZrSJR7mWKVOBS4sBF+5poPQFMj2YdXx1VHItBGihQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-custom-selectors@8.0.5: + resolution: {integrity: sha512-9PGmckHQswiB2usSO6XMSswO2yFWVoCAuih1yl9FVcwkscLjRKjwsjM3t+NIWpSU2Jx3eOiK2+t4vVTQaoCHHg==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-dir-pseudo-class@9.0.1: + resolution: {integrity: sha512-tRBEK0MHYvcMUrAuYMEOa0zg9APqirBcgzi6P21OhxtJyJADo/SWBwY1CAwEohQ/6HDaa9jCjLRG7K3PVQYHEA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-discard-comments@6.0.2: + resolution: {integrity: sha512-65w/uIqhSBBfQmYnG92FO1mWZjJ4GL5b8atm5Yw2UgrwD7HiNiSSNwJor1eCFGzUgYnN/iIknhNRVqjrrpuglw==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-discard-duplicates@6.0.3: + resolution: {integrity: sha512-+JA0DCvc5XvFAxwx6f/e68gQu/7Z9ud584VLmcgto28eB8FqSFZwtrLwB5Kcp70eIoWP/HXqz4wpo8rD8gpsTw==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-discard-empty@6.0.3: + resolution: {integrity: sha512-znyno9cHKQsK6PtxL5D19Fj9uwSzC2mB74cpT66fhgOadEUPyXFkbgwm5tvc3bt3NAy8ltE5MrghxovZRVnOjQ==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-discard-overridden@6.0.2: + resolution: {integrity: sha512-j87xzI4LUggC5zND7KdjsI25APtyMuynXZSujByMaav2roV6OZX+8AaCUcZSWqckZpjAjRyFDdpqybgjFO0HJQ==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-discard-unused@6.0.5: + resolution: {integrity: sha512-wHalBlRHkaNnNwfC8z+ppX57VhvS+HWgjW508esjdaEYr3Mx7Gnn2xA4R/CKf5+Z9S5qsqC+Uzh4ueENWwCVUA==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-double-position-gradients@6.0.4: + resolution: {integrity: sha512-m6IKmxo7FxSP5nF2l63QbCC3r+bWpFUWmZXZf096WxG0m7Vl1Q1+ruFOhpdDRmKrRS+S3Jtk+TVk/7z0+BVK6g==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-focus-visible@10.0.1: + resolution: {integrity: sha512-U58wyjS/I1GZgjRok33aE8juW9qQgQUNwTSdxQGuShHzwuYdcklnvK/+qOWX1Q9kr7ysbraQ6ht6r+udansalA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-focus-within@9.0.1: + resolution: {integrity: sha512-fzNUyS1yOYa7mOjpci/bR+u+ESvdar6hk8XNK/TRR0fiGTp2QT5N+ducP0n3rfH/m9I7H/EQU6lsa2BrgxkEjw==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-font-variant@5.0.0: + resolution: {integrity: sha512-1fmkBaCALD72CK2a9i468mA/+tr9/1cBxRRMXOUaZqO43oWPR5imcyPjXwuv7PXbCid4ndlP5zWhidQVVa3hmA==} + peerDependencies: + postcss: ^8.1.0 + + postcss-gap-properties@6.0.0: + resolution: {integrity: sha512-Om0WPjEwiM9Ru+VhfEDPZJAKWUd0mV1HmNXqp2C29z80aQ2uP9UVhLc7e3aYMIor/S5cVhoPgYQ7RtfeZpYTRw==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-image-set-function@7.0.0: + resolution: {integrity: sha512-QL7W7QNlZuzOwBTeXEmbVckNt1FSmhQtbMRvGGqqU4Nf4xk6KUEQhAoWuMzwbSv5jxiRiSZ5Tv7eiDB9U87znA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-lab-function@7.0.12: + resolution: {integrity: sha512-tUcyRk1ZTPec3OuKFsqtRzW2Go5lehW29XA21lZ65XmzQkz43VY2tyWEC202F7W3mILOjw0voOiuxRGTsN+J9w==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-loader@7.3.4: + resolution: {integrity: sha512-iW5WTTBSC5BfsBJ9daFMPVrLT36MrNiC6fqOZTTaHjBNX6Pfd5p+hSBqe/fEeNd7pc13QiAyGt7VdGMw4eRC4A==} + engines: {node: '>= 14.15.0'} + peerDependencies: + postcss: ^7.0.0 || ^8.0.1 + webpack: ^5.0.0 + + postcss-logical@8.1.0: + resolution: {integrity: sha512-pL1hXFQ2fEXNKiNiAgtfA005T9FBxky5zkX6s4GZM2D8RkVgRqz3f4g1JUoq925zXv495qk8UNldDwh8uGEDoA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-merge-idents@6.0.3: + resolution: {integrity: sha512-1oIoAsODUs6IHQZkLQGO15uGEbK3EAl5wi9SS8hs45VgsxQfMnxvt+L+zIr7ifZFIH14cfAeVe2uCTa+SPRa3g==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-merge-longhand@6.0.5: + resolution: {integrity: sha512-5LOiordeTfi64QhICp07nzzuTDjNSO8g5Ksdibt44d+uvIIAE1oZdRn8y/W5ZtYgRH/lnLDlvi9F8btZcVzu3w==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-merge-rules@6.1.1: + resolution: {integrity: sha512-KOdWF0gju31AQPZiD+2Ar9Qjowz1LTChSjFFbS+e2sFgc4uHOp3ZvVX4sNeTlk0w2O31ecFGgrFzhO0RSWbWwQ==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-minify-font-values@6.1.0: + resolution: {integrity: sha512-gklfI/n+9rTh8nYaSJXlCo3nOKqMNkxuGpTn/Qm0gstL3ywTr9/WRKznE+oy6fvfolH6dF+QM4nCo8yPLdvGJg==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-minify-gradients@6.0.3: + resolution: {integrity: sha512-4KXAHrYlzF0Rr7uc4VrfwDJ2ajrtNEpNEuLxFgwkhFZ56/7gaE4Nr49nLsQDZyUe+ds+kEhf+YAUolJiYXF8+Q==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-minify-params@6.1.0: + resolution: {integrity: sha512-bmSKnDtyyE8ujHQK0RQJDIKhQ20Jq1LYiez54WiaOoBtcSuflfK3Nm596LvbtlFcpipMjgClQGyGr7GAs+H1uA==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-minify-selectors@6.0.4: + resolution: {integrity: sha512-L8dZSwNLgK7pjTto9PzWRoMbnLq5vsZSTu8+j1P/2GB8qdtGQfn+K1uSvFgYvgh83cbyxT5m43ZZhUMTJDSClQ==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-modules-extract-imports@3.1.0: + resolution: {integrity: sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules-local-by-default@4.2.0: + resolution: {integrity: sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules-scope@3.2.1: + resolution: {integrity: sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules-values@4.0.0: + resolution: {integrity: sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-nesting@13.0.2: + resolution: {integrity: sha512-1YCI290TX+VP0U/K/aFxzHzQWHWURL+CtHMSbex1lCdpXD1SoR2sYuxDu5aNI9lPoXpKTCggFZiDJbwylU0LEQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-normalize-charset@6.0.2: + resolution: {integrity: sha512-a8N9czmdnrjPHa3DeFlwqst5eaL5W8jYu3EBbTTkI5FHkfMhFZh1EGbku6jhHhIzTA6tquI2P42NtZ59M/H/kQ==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-normalize-display-values@6.0.2: + resolution: {integrity: sha512-8H04Mxsb82ON/aAkPeq8kcBbAtI5Q2a64X/mnRRfPXBq7XeogoQvReqxEfc0B4WPq1KimjezNC8flUtC3Qz6jg==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-normalize-positions@6.0.2: + resolution: {integrity: sha512-/JFzI441OAB9O7VnLA+RtSNZvQ0NCFZDOtp6QPFo1iIyawyXg0YI3CYM9HBy1WvwCRHnPep/BvI1+dGPKoXx/Q==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-normalize-repeat-style@6.0.2: + resolution: {integrity: sha512-YdCgsfHkJ2jEXwR4RR3Tm/iOxSfdRt7jplS6XRh9Js9PyCR/aka/FCb6TuHT2U8gQubbm/mPmF6L7FY9d79VwQ==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-normalize-string@6.0.2: + resolution: {integrity: sha512-vQZIivlxlfqqMp4L9PZsFE4YUkWniziKjQWUtsxUiVsSSPelQydwS8Wwcuw0+83ZjPWNTl02oxlIvXsmmG+CiQ==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-normalize-timing-functions@6.0.2: + resolution: {integrity: sha512-a+YrtMox4TBtId/AEwbA03VcJgtyW4dGBizPl7e88cTFULYsprgHWTbfyjSLyHeBcK/Q9JhXkt2ZXiwaVHoMzA==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-normalize-unicode@6.1.0: + resolution: {integrity: sha512-QVC5TQHsVj33otj8/JD869Ndr5Xcc/+fwRh4HAsFsAeygQQXm+0PySrKbr/8tkDKzW+EVT3QkqZMfFrGiossDg==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-normalize-url@6.0.2: + resolution: {integrity: sha512-kVNcWhCeKAzZ8B4pv/DnrU1wNh458zBNp8dh4y5hhxih5RZQ12QWMuQrDgPRw3LRl8mN9vOVfHl7uhvHYMoXsQ==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-normalize-whitespace@6.0.2: + resolution: {integrity: sha512-sXZ2Nj1icbJOKmdjXVT9pnyHQKiSAyuNQHSgRCUgThn2388Y9cGVDR+E9J9iAYbSbLHI+UUwLVl1Wzco/zgv0Q==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-opacity-percentage@3.0.0: + resolution: {integrity: sha512-K6HGVzyxUxd/VgZdX04DCtdwWJ4NGLG212US4/LA1TLAbHgmAsTWVR86o+gGIbFtnTkfOpb9sCRBx8K7HO66qQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-ordered-values@6.0.2: + resolution: {integrity: sha512-VRZSOB+JU32RsEAQrO94QPkClGPKJEL/Z9PCBImXMhIeK5KAYo6slP/hBYlLgrCjFxyqvn5VC81tycFEDBLG1Q==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-overflow-shorthand@6.0.0: + resolution: {integrity: sha512-BdDl/AbVkDjoTofzDQnwDdm/Ym6oS9KgmO7Gr+LHYjNWJ6ExORe4+3pcLQsLA9gIROMkiGVjjwZNoL/mpXHd5Q==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-page-break@3.0.4: + resolution: {integrity: sha512-1JGu8oCjVXLa9q9rFTo4MbeeA5FMe00/9C7lN4va606Rdb+HkxXtXsmEDrIraQ11fGz/WvKWa8gMuCKkrXpTsQ==} + peerDependencies: + postcss: ^8 + + postcss-place@10.0.0: + resolution: {integrity: sha512-5EBrMzat2pPAxQNWYavwAfoKfYcTADJ8AXGVPcUZ2UkNloUTWzJQExgrzrDkh3EKzmAx1evfTAzF9I8NGcc+qw==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-preset-env@10.6.1: + resolution: {integrity: sha512-yrk74d9EvY+W7+lO9Aj1QmjWY9q5NsKjK2V9drkOPZB/X6KZ0B3igKsHUYakb7oYVhnioWypQX3xGuePf89f3g==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-pseudo-class-any-link@10.0.1: + resolution: {integrity: sha512-3el9rXlBOqTFaMFkWDOkHUTQekFIYnaQY55Rsp8As8QQkpiSgIYEcF/6Ond93oHiDsGb4kad8zjt+NPlOC1H0Q==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-reduce-idents@6.0.3: + resolution: {integrity: sha512-G3yCqZDpsNPoQgbDUy3T0E6hqOQ5xigUtBQyrmq3tn2GxlyiL0yyl7H+T8ulQR6kOcHJ9t7/9H4/R2tv8tJbMA==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-reduce-initial@6.1.0: + resolution: {integrity: sha512-RarLgBK/CrL1qZags04oKbVbrrVK2wcxhvta3GCxrZO4zveibqbRPmm2VI8sSgCXwoUHEliRSbOfpR0b/VIoiw==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-reduce-transforms@6.0.2: + resolution: {integrity: sha512-sB+Ya++3Xj1WaT9+5LOOdirAxP7dJZms3GRcYheSPi1PiTMigsxHAdkrbItHxwYHr4kt1zL7mmcHstgMYT+aiA==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-replace-overflow-wrap@4.0.0: + resolution: {integrity: sha512-KmF7SBPphT4gPPcKZc7aDkweHiKEEO8cla/GjcBK+ckKxiZslIu3C4GCRW3DNfL0o7yW7kMQu9xlZ1kXRXLXtw==} + peerDependencies: + postcss: ^8.0.3 + + postcss-selector-not@8.0.1: + resolution: {integrity: sha512-kmVy/5PYVb2UOhy0+LqUYAhKj7DUGDpSWa5LZqlkWJaaAV+dxxsOG3+St0yNLu6vsKD7Dmqx+nWQt0iil89+WA==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss-sort-media-queries@5.2.0: + resolution: {integrity: sha512-AZ5fDMLD8SldlAYlvi8NIqo0+Z8xnXU2ia0jxmuhxAU+Lqt9K+AlmLNJ/zWEnE9x+Zx3qL3+1K20ATgNOr3fAA==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.4.23 + + postcss-svgo@6.0.3: + resolution: {integrity: sha512-dlrahRmxP22bX6iKEjOM+c8/1p+81asjKT+V5lrgOH944ryx/OHpclnIbGsKVd3uWOXFLYJwCVf0eEkJGvO96g==} + engines: {node: ^14 || ^16 || >= 18} + peerDependencies: + postcss: ^8.4.31 + + postcss-unique-selectors@6.0.4: + resolution: {integrity: sha512-K38OCaIrO8+PzpArzkLKB42dSARtC2tmG6PvD4b1o1Q2E9Os8jzfWFfSy/rixsHwohtsDdFtAWGjFVFUdwYaMg==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss-zindex@6.0.2: + resolution: {integrity: sha512-5BxW9l1evPB/4ZIc+2GobEBoKC+h8gPGCMi+jxsYvd2x0mjq7wazk6DrP71pStqxE9Foxh5TVnonbWpFZzXaYg==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + pretty-error@4.0.0: + resolution: {integrity: sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==} + + pretty-time@1.1.0: + resolution: {integrity: sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA==} + engines: {node: '>=4'} + + prism-react-renderer@2.4.1: + resolution: {integrity: sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==} + peerDependencies: + react: '>=16.0.0' + + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pupa@3.3.0: + resolution: {integrity: sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==} + engines: {node: '>=12.20'} + + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.1.5: + resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} + engines: {node: '>=16.0.0'} + + qs@6.14.1: + resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + + range-parser@1.2.0: + resolution: {integrity: sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==} + engines: {node: '>= 0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-fast-compare@3.2.2: + resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-json-view-lite@2.5.0: + resolution: {integrity: sha512-tk7o7QG9oYyELWHL8xiMQ8x4WzjCzbWNyig3uexmkLb54r8jO0yH3WCWx8UZS0c49eSA4QUmG5caiRJ8fAn58g==} + engines: {node: '>=18'} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + + react-loadable-ssr-addon-v5-slorber@1.0.1: + resolution: {integrity: sha512-lq3Lyw1lGku8zUEJPDxsNm1AfYHBrO9Y1+olAYwpUJ2IGFBskM0DMKok97A6LWUpHm+o7IvQBOWu9MLenp9Z+A==} + engines: {node: '>=10.13.0'} + peerDependencies: + react-loadable: '*' + webpack: '>=4.41.1 || 5.x' + + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-router-config@5.1.1: + resolution: {integrity: sha512-DuanZjaD8mQp1ppHjgnnUnyOlqYXZVjnov/JzFhjLEwd3Z4dYjMSnqrEzzGThH47vpCOqPPwJM2FtthLeJ8Pbg==} + peerDependencies: + react: '>=15' + react-router: '>=5' + + react-router-dom@5.3.4: + resolution: {integrity: sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==} + peerDependencies: + react: '>=15' + + react-router@5.3.4: + resolution: {integrity: sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==} + peerDependencies: + react: '>=15' + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + recma-build-jsx@1.0.0: + resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} + + recma-jsx@1.0.1: + resolution: {integrity: sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + recma-parse@1.0.0: + resolution: {integrity: sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==} + + recma-stringify@1.0.0: + resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + regenerate-unicode-properties@10.2.2: + resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} + engines: {node: '>=4'} + + regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + + regexpu-core@6.4.0: + resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} + engines: {node: '>=4'} + + registry-auth-token@5.1.1: + resolution: {integrity: sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==} + engines: {node: '>=14'} + + registry-url@6.0.1: + resolution: {integrity: sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==} + engines: {node: '>=12'} + + regjsgen@0.8.0: + resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} + + regjsparser@0.13.0: + resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==} + hasBin: true + + rehype-highlight@7.0.2: + resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==} + + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-recma@1.0.0: + resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + + relateurl@0.2.7: + resolution: {integrity: sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==} + engines: {node: '>= 0.10'} + + remark-directive@3.0.1: + resolution: {integrity: sha512-gwglrEQEZcZYgVyG1tQuA+h58EZfq5CSULw7J90AFuCTyib1thgHPoqQ+h9iFvU6R+vnZ5oNFQR5QKgGpk741A==} + + remark-emoji@4.0.1: + resolution: {integrity: sha512-fHdvsTR1dHkWKev9eNyhTo4EFwbUvJ8ka9SgeWkMPYFX4WoI7ViVBms3PjlQYgw5TLvNQso3GUB/b/8t3yo+dg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + remark-frontmatter@5.0.0: + resolution: {integrity: sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-mdx@3.1.1: + resolution: {integrity: sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + renderkid@3.0.0: + resolution: {integrity: sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==} + + repeat-string@1.6.1: + resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} + engines: {node: '>=0.10'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + require-like@0.1.2: + resolution: {integrity: sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==} + + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-pathname@3.0.0: + resolution: {integrity: sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + responselike@3.0.0: + resolution: {integrity: sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==} + engines: {node: '>=14.16'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rtlcss@4.3.0: + resolution: {integrity: sha512-FI+pHEn7Wc4NqKXMXFM+VAYKEj/mRIcW4h24YVwVtyjI+EqGrLc2Hx/Ny0lrZ21cBWU2goLy36eqMcNj3AQJig==} + engines: {node: '>=12.0.0'} + hasBin: true + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sax@1.4.4: + resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==} + engines: {node: '>=11.0.0'} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + schema-dts@1.1.5: + resolution: {integrity: sha512-RJr9EaCmsLzBX2NDiO5Z3ux2BVosNZN5jo0gWgsyKvxKIUL5R3swNvoorulAeL9kLB0iTSX7V6aokhla2m7xbg==} + + schema-utils@3.3.0: + resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} + engines: {node: '>= 10.13.0'} + + schema-utils@4.3.3: + resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} + engines: {node: '>= 10.13.0'} + + search-insights@2.17.3: + resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==} + + section-matter@1.0.0: + resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} + engines: {node: '>=4'} + + select-hose@2.0.0: + resolution: {integrity: sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==} + + selfsigned@5.5.0: + resolution: {integrity: sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==} + engines: {node: '>=18'} + + semver-diff@4.0.0: + resolution: {integrity: sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==} + engines: {node: '>=12'} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + + serve-handler@6.1.6: + resolution: {integrity: sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==} + + serve-index@1.9.2: + resolution: {integrity: sha512-KDj11HScOaLmrPxl70KYNW1PksP4Nb/CLL2yvC+Qd2kHMPEEpfc4Re2e4FOay+bC/+XQl/7zAcWON3JVo5v3KQ==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shallow-clone@3.0.1: + resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} + engines: {node: '>=8'} + + shallowequal@1.1.0: + resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + engines: {node: '>= 0.4'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + sirv@2.0.4: + resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} + engines: {node: '>= 10'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + sitemap@7.1.2: + resolution: {integrity: sha512-ARCqzHJ0p4gWt+j7NlU5eDlIO9+Rkr/JhPFZKKQ1l5GCus7rJH4UdrlVAh0xC/gDS/Qir2UMxqYNHtsKr2rpCw==} + engines: {node: '>=12.0.0', npm: '>=5.6.0'} + hasBin: true + + skin-tone@2.0.0: + resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==} + engines: {node: '>=8'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slash@4.0.0: + resolution: {integrity: sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==} + engines: {node: '>=12'} + + snake-case@3.0.4: + resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} + + sockjs@0.3.24: + resolution: {integrity: sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==} + + sort-css-media-queries@2.2.0: + resolution: {integrity: sha512-0xtkGhWCC9MGt/EzgnvbbbKhqWjl1+/rncmhTh5qCpbYguXh6S/qwePfv/JQ8jePXXmqingylxoC49pCkSPIbA==} + engines: {node: '>= 6.3.0'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + spdy-transport@3.0.0: + resolution: {integrity: sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==} + + spdy@4.0.2: + resolution: {integrity: sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==} + engines: {node: '>=6.0.0'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + srcset@4.0.0: + resolution: {integrity: sha512-wvLeHgcVHKO8Sc/H/5lkGreJQVeYMm9rlmt8PuR1xE31rIuXhuzznUUqAt8MqLhB3MqJdFzlNAfpcWnxiFUcPw==} + engines: {node: '>=12'} + + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + stringify-object@3.3.0: + resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==} + engines: {node: '>=4'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + strip-bom-string@1.0.0: + resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} + engines: {node: '>=0.10.0'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + stylehacks@6.1.1: + resolution: {integrity: sha512-gSTTEQ670cJNoaeIp9KX6lZmm8LJ3jPB5yJmX8Zq/wQxOsAFXV3qjWzHas3YYk1qesuVIyYWWUpZ0vSE/dTSGg==} + engines: {node: ^14 || ^16 || >=18.0} + peerDependencies: + postcss: ^8.4.31 + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svg-parser@2.0.4: + resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} + + svgo@3.3.2: + resolution: {integrity: sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==} + engines: {node: '>=14.0.0'} + hasBin: true + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + terser-webpack-plugin@5.3.16: + resolution: {integrity: sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@swc/core': '*' + esbuild: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@swc/core': + optional: true + esbuild: + optional: true + uglify-js: + optional: true + + terser@5.46.0: + resolution: {integrity: sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==} + engines: {node: '>=10'} + hasBin: true + + thingies@2.5.0: + resolution: {integrity: sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw==} + engines: {node: '>=10.18'} + peerDependencies: + tslib: ^2 + + thunky@1.1.0: + resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tiny-warning@1.0.3: + resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tree-dump@1.1.0: + resolution: {integrity: sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsyringe@4.10.0: + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} + engines: {node: '>= 6.0.0'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@1.4.0: + resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==} + engines: {node: '>=10'} + + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typedarray-to-buffer@3.1.5: + resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + undici@7.21.0: + resolution: {integrity: sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==} + engines: {node: '>=20.18.1'} + + unicode-canonical-property-names-ecmascript@2.0.1: + resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} + engines: {node: '>=4'} + + unicode-emoji-modifier-base@1.0.0: + resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} + engines: {node: '>=4'} + + unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + + unicode-match-property-value-ecmascript@2.2.1: + resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} + engines: {node: '>=4'} + + unicode-property-aliases-ecmascript@2.2.0: + resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} + engines: {node: '>=4'} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unique-string@3.0.0: + resolution: {integrity: sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==} + engines: {node: '>=12'} + + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position-from-estree@2.0.0: + resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + update-notifier@6.0.2: + resolution: {integrity: sha512-EDxhTEVPZZRLWYcJ4ZXjGFN0oP7qYvbXWzEgRm/Yql4dHX5wDbvh89YHP6PK1lzZJYrMtXUuZZz8XGK+U6U1og==} + engines: {node: '>=14.16'} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + url-loader@4.1.1: + resolution: {integrity: sha512-3BTV812+AVHHOJQO8O5MkWgZ5aosP7GnROJwvzLS9hWDj00lZ6Z0wNak423Lp9PBZN05N+Jk/N5Si8jRAlGyWA==} + engines: {node: '>= 10.13.0'} + peerDependencies: + file-loader: '*' + webpack: ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + file-loader: + optional: true + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utila@0.4.0: + resolution: {integrity: sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==} + + utility-types@3.11.0: + resolution: {integrity: sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==} + engines: {node: '>= 4'} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + + value-equal@1.0.1: + resolution: {integrity: sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + watchpack@2.5.1: + resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} + engines: {node: '>=10.13.0'} + + wbuf@1.7.3: + resolution: {integrity: sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==} + + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + + webpack-bundle-analyzer@4.10.2: + resolution: {integrity: sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==} + engines: {node: '>= 10.13.0'} + hasBin: true + + webpack-dev-middleware@7.4.5: + resolution: {integrity: sha512-uxQ6YqGdE4hgDKNf7hUiPXOdtkXvBJXrfEGYSx7P7LC8hnUYGK70X6xQXUvXeNyBDDcsiQXpG2m3G9vxowaEuA==} + engines: {node: '>= 18.12.0'} + peerDependencies: + webpack: ^5.0.0 + peerDependenciesMeta: + webpack: + optional: true + + webpack-dev-server@5.2.3: + resolution: {integrity: sha512-9Gyu2F7+bg4Vv+pjbovuYDhHX+mqdqITykfzdM9UyKqKHlsE5aAjRhR+oOEfXW5vBeu8tarzlJFIZva4ZjAdrQ==} + engines: {node: '>= 18.12.0'} + hasBin: true + peerDependencies: + webpack: ^5.0.0 + webpack-cli: '*' + peerDependenciesMeta: + webpack: + optional: true + webpack-cli: + optional: true + + webpack-merge@5.10.0: + resolution: {integrity: sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==} + engines: {node: '>=10.0.0'} + + webpack-merge@6.0.1: + resolution: {integrity: sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==} + engines: {node: '>=18.0.0'} + + webpack-sources@3.3.3: + resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} + engines: {node: '>=10.13.0'} + + webpack@5.105.1: + resolution: {integrity: sha512-Gdj3X74CLJJ8zy4URmK42W7wTZUJrqL+z8nyGEr4dTN0kb3nVs+ZvjbTOqRYPD7qX4tUmwyHL9Q9K6T1seW6Yw==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + + webpackbar@6.0.1: + resolution: {integrity: sha512-TnErZpmuKdwWBdMoexjio3KKX6ZtoKHRVvLIU0A47R0VVBDtx3ZyOJDktgYixhoJokZTYTt1Z37OkO9pnGJa9Q==} + engines: {node: '>=14.21.3'} + peerDependencies: + webpack: 3 || 4 || 5 + + websocket-driver@0.7.4: + resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} + engines: {node: '>=0.8.0'} + + websocket-extensions@0.1.4: + resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==} + engines: {node: '>=0.8.0'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + widest-line@4.0.1: + resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==} + engines: {node: '>=12'} + + wildcard@2.0.1: + resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + write-file-atomic@3.0.3: + resolution: {integrity: sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==} + + ws@7.5.10: + resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + + xdg-basedir@5.1.0: + resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==} + engines: {node: '>=12'} + + xml-js@1.6.11: + resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} + hasBin: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@algolia/abtesting@1.14.0': + dependencies: + '@algolia/client-common': 5.48.0 + '@algolia/requester-browser-xhr': 5.48.0 + '@algolia/requester-fetch': 5.48.0 + '@algolia/requester-node-http': 5.48.0 + + '@algolia/autocomplete-core@1.19.2(@algolia/client-search@5.48.0)(algoliasearch@5.48.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-plugin-algolia-insights': 1.19.2(@algolia/client-search@5.48.0)(algoliasearch@5.48.0)(search-insights@2.17.3) + '@algolia/autocomplete-shared': 1.19.2(@algolia/client-search@5.48.0)(algoliasearch@5.48.0) + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + - search-insights + + '@algolia/autocomplete-plugin-algolia-insights@1.19.2(@algolia/client-search@5.48.0)(algoliasearch@5.48.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-shared': 1.19.2(@algolia/client-search@5.48.0)(algoliasearch@5.48.0) + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + + '@algolia/autocomplete-shared@1.19.2(@algolia/client-search@5.48.0)(algoliasearch@5.48.0)': + dependencies: + '@algolia/client-search': 5.48.0 + algoliasearch: 5.48.0 + + '@algolia/client-abtesting@5.48.0': + dependencies: + '@algolia/client-common': 5.48.0 + '@algolia/requester-browser-xhr': 5.48.0 + '@algolia/requester-fetch': 5.48.0 + '@algolia/requester-node-http': 5.48.0 + + '@algolia/client-analytics@5.48.0': + dependencies: + '@algolia/client-common': 5.48.0 + '@algolia/requester-browser-xhr': 5.48.0 + '@algolia/requester-fetch': 5.48.0 + '@algolia/requester-node-http': 5.48.0 + + '@algolia/client-common@5.48.0': {} + + '@algolia/client-insights@5.48.0': + dependencies: + '@algolia/client-common': 5.48.0 + '@algolia/requester-browser-xhr': 5.48.0 + '@algolia/requester-fetch': 5.48.0 + '@algolia/requester-node-http': 5.48.0 + + '@algolia/client-personalization@5.48.0': + dependencies: + '@algolia/client-common': 5.48.0 + '@algolia/requester-browser-xhr': 5.48.0 + '@algolia/requester-fetch': 5.48.0 + '@algolia/requester-node-http': 5.48.0 + + '@algolia/client-query-suggestions@5.48.0': + dependencies: + '@algolia/client-common': 5.48.0 + '@algolia/requester-browser-xhr': 5.48.0 + '@algolia/requester-fetch': 5.48.0 + '@algolia/requester-node-http': 5.48.0 + + '@algolia/client-search@5.48.0': + dependencies: + '@algolia/client-common': 5.48.0 + '@algolia/requester-browser-xhr': 5.48.0 + '@algolia/requester-fetch': 5.48.0 + '@algolia/requester-node-http': 5.48.0 + + '@algolia/events@4.0.1': {} + + '@algolia/ingestion@1.48.0': + dependencies: + '@algolia/client-common': 5.48.0 + '@algolia/requester-browser-xhr': 5.48.0 + '@algolia/requester-fetch': 5.48.0 + '@algolia/requester-node-http': 5.48.0 + + '@algolia/monitoring@1.48.0': + dependencies: + '@algolia/client-common': 5.48.0 + '@algolia/requester-browser-xhr': 5.48.0 + '@algolia/requester-fetch': 5.48.0 + '@algolia/requester-node-http': 5.48.0 + + '@algolia/recommend@5.48.0': + dependencies: + '@algolia/client-common': 5.48.0 + '@algolia/requester-browser-xhr': 5.48.0 + '@algolia/requester-fetch': 5.48.0 + '@algolia/requester-node-http': 5.48.0 + + '@algolia/requester-browser-xhr@5.48.0': + dependencies: + '@algolia/client-common': 5.48.0 + + '@algolia/requester-fetch@5.48.0': + dependencies: + '@algolia/client-common': 5.48.0 + + '@algolia/requester-node-http@5.48.0': + dependencies: + '@algolia/client-common': 5.48.0 + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + regexpu-core: 6.4.0 + semver: 6.3.1 + + '@babel/helper-define-polyfill-provider@0.6.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + debug: 4.4.3 + lodash.debounce: 4.0.8 + resolve: 1.22.11 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-wrap-function': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helper-wrap-function@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helpers@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + + '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-assertions@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-async-generator-functions@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-async-to-generator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-remap-async-to-generator': 7.27.1(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-block-scoped-functions@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-block-scoping@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-class-properties@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-class-static-block@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-classes@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-globals': 7.28.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-computed-properties@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/template': 7.28.6 + + '@babel/plugin-transform-destructuring@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-dotall-regex@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-duplicate-keys@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-dynamic-import@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-explicit-resource-management@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-exponentiation-operator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-export-namespace-from@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-for-of@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-function-name@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-json-strings@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-logical-assignment-operators@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-member-expression-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-modules-amd@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-systemjs@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-umd@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-new-target@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-nullish-coalescing-operator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-numeric-separator@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-object-rest-spread@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-optional-catch-binding@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-optional-chaining@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-private-methods@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-private-property-in-object@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-property-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-constant-elements@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-pure-annotations@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-regenerator@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-regexp-modifiers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-reserved-words@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-runtime@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + babel-plugin-polyfill-corejs2: 0.4.15(@babel/core@7.29.0) + babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.0) + babel-plugin-polyfill-regenerator: 0.6.6(@babel/core@7.29.0) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-shorthand-properties@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-spread@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-sticky-regex@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-template-literals@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-typeof-symbol@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-unicode-property-regex@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-unicode-regex@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-unicode-sets-regex@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-create-regexp-features-plugin': 7.28.5(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/preset-env@7.29.0(@babel/core@7.29.0)': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/core': 7.29.0 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.0) + '@babel/plugin-syntax-import-assertions': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.0) + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-async-generator-functions': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-async-to-generator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoped-functions': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-block-scoping': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-class-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-classes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-computed-properties': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-destructuring': 7.28.5(@babel/core@7.29.0) + '@babel/plugin-transform-dotall-regex': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-duplicate-keys': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-explicit-resource-management': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-exponentiation-operator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-for-of': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-function-name': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-json-strings': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-logical-assignment-operators': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-member-expression-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-amd': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-systemjs': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-modules-umd': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-new-target': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-nullish-coalescing-operator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-numeric-separator': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-object-super': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-optional-catch-binding': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-optional-chaining': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.0) + '@babel/plugin-transform-private-methods': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-private-property-in-object': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-regenerator': 7.29.0(@babel/core@7.29.0) + '@babel/plugin-transform-regexp-modifiers': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-reserved-words': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-spread': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-sticky-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-typeof-symbol': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-escapes': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-property-regex': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-unicode-sets-regex': 7.28.6(@babel/core@7.29.0) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.0) + babel-plugin-polyfill-corejs2: 0.4.15(@babel/core@7.29.0) + babel-plugin-polyfill-corejs3: 0.14.0(@babel/core@7.29.0) + babel-plugin-polyfill-regenerator: 0.6.6(@babel/core@7.29.0) + core-js-compat: 3.48.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/types': 7.29.0 + esutils: 2.0.3 + + '@babel/preset-react@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-transform-react-display-name': 7.28.0(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-pure-annotations': 7.27.1(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/runtime-corejs3@7.29.0': + dependencies: + core-js-pure: 3.48.0 + + '@babel/runtime@7.28.6': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@colors/colors@1.5.0': + optional: true + + '@csstools/cascade-layer-name-parser@2.0.5(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@csstools/media-query-list-parser@4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/postcss-alpha-function@1.0.1(postcss@8.5.6)': + dependencies: + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) + '@csstools/utilities': 2.0.0(postcss@8.5.6) + postcss: 8.5.6 + + '@csstools/postcss-cascade-layers@5.0.2(postcss@8.5.6)': + dependencies: + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1) + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + + '@csstools/postcss-color-function-display-p3-linear@1.0.1(postcss@8.5.6)': + dependencies: + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) + '@csstools/utilities': 2.0.0(postcss@8.5.6) + postcss: 8.5.6 + + '@csstools/postcss-color-function@4.0.12(postcss@8.5.6)': + dependencies: + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) + '@csstools/utilities': 2.0.0(postcss@8.5.6) + postcss: 8.5.6 + + '@csstools/postcss-color-mix-function@3.0.12(postcss@8.5.6)': + dependencies: + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) + '@csstools/utilities': 2.0.0(postcss@8.5.6) + postcss: 8.5.6 + + '@csstools/postcss-color-mix-variadic-function-arguments@1.0.2(postcss@8.5.6)': + dependencies: + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) + '@csstools/utilities': 2.0.0(postcss@8.5.6) + postcss: 8.5.6 + + '@csstools/postcss-content-alt-text@2.0.8(postcss@8.5.6)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) + '@csstools/utilities': 2.0.0(postcss@8.5.6) + postcss: 8.5.6 + + '@csstools/postcss-contrast-color-function@2.0.12(postcss@8.5.6)': + dependencies: + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) + '@csstools/utilities': 2.0.0(postcss@8.5.6) + postcss: 8.5.6 + + '@csstools/postcss-exponential-functions@2.0.9(postcss@8.5.6)': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + postcss: 8.5.6 + + '@csstools/postcss-font-format-keywords@4.0.0(postcss@8.5.6)': + dependencies: + '@csstools/utilities': 2.0.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-gamut-mapping@2.0.11(postcss@8.5.6)': + dependencies: + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + postcss: 8.5.6 + + '@csstools/postcss-gradients-interpolation-method@5.0.12(postcss@8.5.6)': + dependencies: + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) + '@csstools/utilities': 2.0.0(postcss@8.5.6) + postcss: 8.5.6 + + '@csstools/postcss-hwb-function@4.0.12(postcss@8.5.6)': + dependencies: + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) + '@csstools/utilities': 2.0.0(postcss@8.5.6) + postcss: 8.5.6 + + '@csstools/postcss-ic-unit@4.0.4(postcss@8.5.6)': + dependencies: + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) + '@csstools/utilities': 2.0.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-initial@2.0.1(postcss@8.5.6)': + dependencies: + postcss: 8.5.6 + + '@csstools/postcss-is-pseudo-class@5.0.3(postcss@8.5.6)': + dependencies: + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1) + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + + '@csstools/postcss-light-dark-function@2.0.11(postcss@8.5.6)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) + '@csstools/utilities': 2.0.0(postcss@8.5.6) + postcss: 8.5.6 + + '@csstools/postcss-logical-float-and-clear@3.0.0(postcss@8.5.6)': + dependencies: + postcss: 8.5.6 + + '@csstools/postcss-logical-overflow@2.0.0(postcss@8.5.6)': + dependencies: + postcss: 8.5.6 + + '@csstools/postcss-logical-overscroll-behavior@2.0.0(postcss@8.5.6)': + dependencies: + postcss: 8.5.6 + + '@csstools/postcss-logical-resize@3.0.0(postcss@8.5.6)': + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-logical-viewport-units@3.0.4(postcss@8.5.6)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + '@csstools/utilities': 2.0.0(postcss@8.5.6) + postcss: 8.5.6 + + '@csstools/postcss-media-minmax@2.0.9(postcss@8.5.6)': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + postcss: 8.5.6 + + '@csstools/postcss-media-queries-aspect-ratio-number-values@3.0.5(postcss@8.5.6)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + postcss: 8.5.6 + + '@csstools/postcss-nested-calc@4.0.0(postcss@8.5.6)': + dependencies: + '@csstools/utilities': 2.0.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-normalize-display-values@4.0.1(postcss@8.5.6)': + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-oklab-function@4.0.12(postcss@8.5.6)': + dependencies: + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) + '@csstools/utilities': 2.0.0(postcss@8.5.6) + postcss: 8.5.6 + + '@csstools/postcss-position-area-property@1.0.0(postcss@8.5.6)': + dependencies: + postcss: 8.5.6 + + '@csstools/postcss-progressive-custom-properties@4.2.1(postcss@8.5.6)': + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-property-rule-prelude-list@1.0.0(postcss@8.5.6)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + postcss: 8.5.6 + + '@csstools/postcss-random-function@2.0.1(postcss@8.5.6)': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + postcss: 8.5.6 + + '@csstools/postcss-relative-color-syntax@3.0.12(postcss@8.5.6)': + dependencies: + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) + '@csstools/utilities': 2.0.0(postcss@8.5.6) + postcss: 8.5.6 + + '@csstools/postcss-scope-pseudo-class@4.0.1(postcss@8.5.6)': + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + + '@csstools/postcss-sign-functions@1.1.4(postcss@8.5.6)': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + postcss: 8.5.6 + + '@csstools/postcss-stepped-value-functions@4.0.9(postcss@8.5.6)': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + postcss: 8.5.6 + + '@csstools/postcss-syntax-descriptor-syntax-production@1.0.1(postcss@8.5.6)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + postcss: 8.5.6 + + '@csstools/postcss-system-ui-font-family@1.0.0(postcss@8.5.6)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + postcss: 8.5.6 + + '@csstools/postcss-text-decoration-shorthand@4.0.3(postcss@8.5.6)': + dependencies: + '@csstools/color-helpers': 5.1.0 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + '@csstools/postcss-trigonometric-functions@4.0.9(postcss@8.5.6)': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + postcss: 8.5.6 + + '@csstools/postcss-unset-value@4.0.0(postcss@8.5.6)': + dependencies: + postcss: 8.5.6 + + '@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.1)': + dependencies: + postcss-selector-parser: 7.1.1 + + '@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.1)': + dependencies: + postcss-selector-parser: 7.1.1 + + '@csstools/utilities@2.0.0(postcss@8.5.6)': + dependencies: + postcss: 8.5.6 + + '@discoveryjs/json-ext@0.5.7': {} + + '@docsearch/core@4.5.4(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + optionalDependencies: + '@types/react': 19.2.13 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@docsearch/css@4.5.4': {} + + '@docsearch/react@4.5.4(@algolia/client-search@5.48.0)(@types/react@19.2.13)(algoliasearch@5.48.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-core': 1.19.2(@algolia/client-search@5.48.0)(algoliasearch@5.48.0)(search-insights@2.17.3) + '@docsearch/core': 4.5.4(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docsearch/css': 4.5.4 + optionalDependencies: + '@types/react': 19.2.13 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + + '@docusaurus/babel@3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-transform-runtime': 7.29.0(@babel/core@7.29.0) + '@babel/preset-env': 7.29.0(@babel/core@7.29.0) + '@babel/preset-react': 7.28.5(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@babel/runtime': 7.28.6 + '@babel/runtime-corejs3': 7.29.0 + '@babel/traverse': 7.29.0 + '@docusaurus/logger': 3.9.2 + '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + babel-plugin-dynamic-import-node: 2.3.3 + fs-extra: 11.3.3 + tslib: 2.8.1 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - react + - react-dom + - supports-color + - uglify-js + - webpack-cli + + '@docusaurus/bundler@3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + dependencies: + '@babel/core': 7.29.0 + '@docusaurus/babel': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/cssnano-preset': 3.9.2 + '@docusaurus/logger': 3.9.2 + '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + babel-loader: 9.2.1(@babel/core@7.29.0)(webpack@5.105.1) + clean-css: 5.3.3 + copy-webpack-plugin: 11.0.0(webpack@5.105.1) + css-loader: 6.11.0(webpack@5.105.1) + css-minimizer-webpack-plugin: 5.0.1(clean-css@5.3.3)(webpack@5.105.1) + cssnano: 6.1.2(postcss@8.5.6) + file-loader: 6.2.0(webpack@5.105.1) + html-minifier-terser: 7.2.0 + mini-css-extract-plugin: 2.10.0(webpack@5.105.1) + null-loader: 4.0.1(webpack@5.105.1) + postcss: 8.5.6 + postcss-loader: 7.3.4(postcss@8.5.6)(typescript@5.9.3)(webpack@5.105.1) + postcss-preset-env: 10.6.1(postcss@8.5.6) + terser-webpack-plugin: 5.3.16(webpack@5.105.1) + tslib: 2.8.1 + url-loader: 4.1.1(file-loader@6.2.0(webpack@5.105.1))(webpack@5.105.1) + webpack: 5.105.1 + webpackbar: 6.0.1(webpack@5.105.1) + transitivePeerDependencies: + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - csso + - esbuild + - lightningcss + - react + - react-dom + - supports-color + - typescript + - uglify-js + - webpack-cli + + '@docusaurus/core@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + dependencies: + '@docusaurus/babel': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/bundler': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/logger': 3.9.2 + '@docusaurus/mdx-loader': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mdx-js/react': 3.1.1(@types/react@19.2.13)(react@18.3.1) + boxen: 6.2.1 + chalk: 4.1.2 + chokidar: 3.6.0 + cli-table3: 0.6.5 + combine-promises: 1.2.0 + commander: 5.1.0 + core-js: 3.48.0 + detect-port: 1.6.1 + escape-html: 1.0.3 + eta: 2.2.0 + eval: 0.1.8 + execa: 5.1.1 + fs-extra: 11.3.3 + html-tags: 3.3.1 + html-webpack-plugin: 5.6.6(webpack@5.105.1) + leven: 3.1.0 + lodash: 4.17.23 + open: 8.4.2 + p-map: 4.0.0 + prompts: 2.4.2 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-helmet-async: '@slorber/react-helmet-async@1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)' + react-loadable: '@docusaurus/react-loadable@6.0.0(react@18.3.1)' + react-loadable-ssr-addon-v5-slorber: 1.0.1(@docusaurus/react-loadable@6.0.0(react@18.3.1))(webpack@5.105.1) + react-router: 5.3.4(react@18.3.1) + react-router-config: 5.1.1(react-router@5.3.4(react@18.3.1))(react@18.3.1) + react-router-dom: 5.3.4(react@18.3.1) + semver: 7.7.4 + serve-handler: 6.1.6 + tinypool: 1.1.1 + tslib: 2.8.1 + update-notifier: 6.0.2 + webpack: 5.105.1 + webpack-bundle-analyzer: 4.10.2 + webpack-dev-server: 5.2.3(debug@4.4.3)(tslib@2.8.1)(webpack@5.105.1) + webpack-merge: 6.0.1 + transitivePeerDependencies: + - '@docusaurus/faster' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - bufferutil + - csso + - debug + - esbuild + - lightningcss + - supports-color + - typescript + - uglify-js + - utf-8-validate + - webpack-cli + + '@docusaurus/cssnano-preset@3.9.2': + dependencies: + cssnano-preset-advanced: 6.1.2(postcss@8.5.6) + postcss: 8.5.6 + postcss-sort-media-queries: 5.2.0(postcss@8.5.6) + tslib: 2.8.1 + + '@docusaurus/logger@3.9.2': + dependencies: + chalk: 4.1.2 + tslib: 2.8.1 + + '@docusaurus/mdx-loader@3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@docusaurus/logger': 3.9.2 + '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mdx-js/mdx': 3.1.1 + '@slorber/remark-comment': 1.0.0 + escape-html: 1.0.3 + estree-util-value-to-estree: 3.5.0 + file-loader: 6.2.0(webpack@5.105.1) + fs-extra: 11.3.3 + image-size: 2.0.2 + mdast-util-mdx: 3.0.0 + mdast-util-to-string: 4.0.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + rehype-raw: 7.0.0 + remark-directive: 3.0.1 + remark-emoji: 4.0.1 + remark-frontmatter: 5.0.0 + remark-gfm: 4.0.1 + stringify-object: 3.3.0 + tslib: 2.8.1 + unified: 11.0.5 + unist-util-visit: 5.1.0 + url-loader: 4.1.1(file-loader@6.2.0(webpack@5.105.1))(webpack@5.105.1) + vfile: 6.0.3 + webpack: 5.105.1 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - supports-color + - uglify-js + - webpack-cli + + '@docusaurus/module-type-aliases@3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/history': 4.7.11 + '@types/react': 19.2.13 + '@types/react-router-config': 5.0.11 + '@types/react-router-dom': 5.3.3 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-helmet-async: '@slorber/react-helmet-async@1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)' + react-loadable: '@docusaurus/react-loadable@6.0.0(react@18.3.1)' + transitivePeerDependencies: + - '@swc/core' + - esbuild + - supports-color + - uglify-js + - webpack-cli + + '@docusaurus/plugin-content-blog@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + dependencies: + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/logger': 3.9.2 + '@docusaurus/mdx-loader': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + cheerio: 1.0.0-rc.12 + feed: 4.2.2 + fs-extra: 11.3.3 + lodash: 4.17.23 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + schema-dts: 1.1.5 + srcset: 4.0.0 + tslib: 2.8.1 + unist-util-visit: 5.1.0 + utility-types: 3.11.0 + webpack: 5.105.1 + transitivePeerDependencies: + - '@docusaurus/faster' + - '@mdx-js/react' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - bufferutil + - csso + - debug + - esbuild + - lightningcss + - supports-color + - typescript + - uglify-js + - utf-8-validate + - webpack-cli + + '@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + dependencies: + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/logger': 3.9.2 + '@docusaurus/mdx-loader': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/module-type-aliases': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react-router-config': 5.0.11 + combine-promises: 1.2.0 + fs-extra: 11.3.3 + js-yaml: 4.1.1 + lodash: 4.17.23 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + schema-dts: 1.1.5 + tslib: 2.8.1 + utility-types: 3.11.0 + webpack: 5.105.1 + transitivePeerDependencies: + - '@docusaurus/faster' + - '@mdx-js/react' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - bufferutil + - csso + - debug + - esbuild + - lightningcss + - supports-color + - typescript + - uglify-js + - utf-8-validate + - webpack-cli + + '@docusaurus/plugin-content-pages@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + dependencies: + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/mdx-loader': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + fs-extra: 11.3.3 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + webpack: 5.105.1 + transitivePeerDependencies: + - '@docusaurus/faster' + - '@mdx-js/react' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - bufferutil + - csso + - debug + - esbuild + - lightningcss + - supports-color + - typescript + - uglify-js + - utf-8-validate + - webpack-cli + + '@docusaurus/plugin-css-cascade-layers@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + dependencies: + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + tslib: 2.8.1 + transitivePeerDependencies: + - '@docusaurus/faster' + - '@mdx-js/react' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - bufferutil + - csso + - debug + - esbuild + - lightningcss + - react + - react-dom + - supports-color + - typescript + - uglify-js + - utf-8-validate + - webpack-cli + + '@docusaurus/plugin-debug@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + dependencies: + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + fs-extra: 11.3.3 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-json-view-lite: 2.5.0(react@18.3.1) + tslib: 2.8.1 + transitivePeerDependencies: + - '@docusaurus/faster' + - '@mdx-js/react' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - bufferutil + - csso + - debug + - esbuild + - lightningcss + - supports-color + - typescript + - uglify-js + - utf-8-validate + - webpack-cli + + '@docusaurus/plugin-google-analytics@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + dependencies: + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + transitivePeerDependencies: + - '@docusaurus/faster' + - '@mdx-js/react' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - bufferutil + - csso + - debug + - esbuild + - lightningcss + - supports-color + - typescript + - uglify-js + - utf-8-validate + - webpack-cli + + '@docusaurus/plugin-google-gtag@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + dependencies: + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/gtag.js': 0.0.12 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + transitivePeerDependencies: + - '@docusaurus/faster' + - '@mdx-js/react' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - bufferutil + - csso + - debug + - esbuild + - lightningcss + - supports-color + - typescript + - uglify-js + - utf-8-validate + - webpack-cli + + '@docusaurus/plugin-google-tag-manager@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + dependencies: + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + transitivePeerDependencies: + - '@docusaurus/faster' + - '@mdx-js/react' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - bufferutil + - csso + - debug + - esbuild + - lightningcss + - supports-color + - typescript + - uglify-js + - utf-8-validate + - webpack-cli + + '@docusaurus/plugin-sitemap@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + dependencies: + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/logger': 3.9.2 + '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + fs-extra: 11.3.3 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + sitemap: 7.1.2 + tslib: 2.8.1 + transitivePeerDependencies: + - '@docusaurus/faster' + - '@mdx-js/react' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - bufferutil + - csso + - debug + - esbuild + - lightningcss + - supports-color + - typescript + - uglify-js + - utf-8-validate + - webpack-cli + + '@docusaurus/plugin-svgr@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + dependencies: + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@svgr/core': 8.1.0(typescript@5.9.3) + '@svgr/webpack': 8.1.0(typescript@5.9.3) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + webpack: 5.105.1 + transitivePeerDependencies: + - '@docusaurus/faster' + - '@mdx-js/react' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - bufferutil + - csso + - debug + - esbuild + - lightningcss + - supports-color + - typescript + - uglify-js + - utf-8-validate + - webpack-cli + + '@docusaurus/preset-classic@3.9.2(@algolia/client-search@5.48.0)(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3)': + dependencies: + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-blog': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-pages': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-css-cascade-layers': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-debug': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-google-analytics': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-google-gtag': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-google-tag-manager': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-sitemap': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-svgr': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/theme-classic': 3.9.2(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/theme-search-algolia': 3.9.2(@algolia/client-search@5.48.0)(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3) + '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@algolia/client-search' + - '@docusaurus/faster' + - '@mdx-js/react' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - '@types/react' + - bufferutil + - csso + - debug + - esbuild + - lightningcss + - search-insights + - supports-color + - typescript + - uglify-js + - utf-8-validate + - webpack-cli + + '@docusaurus/react-loadable@6.0.0(react@18.3.1)': + dependencies: + '@types/react': 19.2.13 + react: 18.3.1 + + '@docusaurus/theme-classic@3.9.2(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + dependencies: + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/logger': 3.9.2 + '@docusaurus/mdx-loader': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/module-type-aliases': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/plugin-content-blog': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/plugin-content-pages': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/theme-translations': 3.9.2 + '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@mdx-js/react': 3.1.1(@types/react@19.2.13)(react@18.3.1) + clsx: 2.1.1 + infima: 0.2.0-alpha.45 + lodash: 4.17.23 + nprogress: 0.2.0 + postcss: 8.5.6 + prism-react-renderer: 2.4.1(react@18.3.1) + prismjs: 1.30.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router-dom: 5.3.4(react@18.3.1) + rtlcss: 4.3.0 + tslib: 2.8.1 + utility-types: 3.11.0 + transitivePeerDependencies: + - '@docusaurus/faster' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - '@types/react' + - bufferutil + - csso + - debug + - esbuild + - lightningcss + - supports-color + - typescript + - uglify-js + - utf-8-validate + - webpack-cli + + '@docusaurus/theme-common@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@docusaurus/mdx-loader': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/module-type-aliases': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/history': 4.7.11 + '@types/react': 19.2.13 + '@types/react-router-config': 5.0.11 + clsx: 2.1.1 + parse-numeric-range: 1.3.0 + prism-react-renderer: 2.4.1(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + utility-types: 3.11.0 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - supports-color + - uglify-js + - webpack-cli + + '@docusaurus/theme-search-algolia@3.9.2(@algolia/client-search@5.48.0)(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3)(typescript@5.9.3)': + dependencies: + '@docsearch/react': 4.5.4(@algolia/client-search@5.48.0)(@types/react@19.2.13)(algoliasearch@5.48.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(search-insights@2.17.3) + '@docusaurus/core': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/logger': 3.9.2 + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/theme-translations': 3.9.2 + '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + algoliasearch: 5.48.0 + algoliasearch-helper: 3.27.1(algoliasearch@5.48.0) + clsx: 2.1.1 + eta: 2.2.0 + fs-extra: 11.3.3 + lodash: 4.17.23 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + utility-types: 3.11.0 + transitivePeerDependencies: + - '@algolia/client-search' + - '@docusaurus/faster' + - '@mdx-js/react' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - '@types/react' + - bufferutil + - csso + - debug + - esbuild + - lightningcss + - search-insights + - supports-color + - typescript + - uglify-js + - utf-8-validate + - webpack-cli + + '@docusaurus/theme-translations@3.9.2': + dependencies: + fs-extra: 11.3.3 + tslib: 2.8.1 + + '@docusaurus/tsconfig@3.9.2': {} + + '@docusaurus/types@3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@mdx-js/mdx': 3.1.1 + '@types/history': 4.7.11 + '@types/mdast': 4.0.4 + '@types/react': 19.2.13 + commander: 5.1.0 + joi: 17.13.3 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-helmet-async: '@slorber/react-helmet-async@1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)' + utility-types: 3.11.0 + webpack: 5.105.1 + webpack-merge: 5.10.0 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - supports-color + - uglify-js + - webpack-cli + + '@docusaurus/utils-common@3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + tslib: 2.8.1 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - react + - react-dom + - supports-color + - uglify-js + - webpack-cli + + '@docusaurus/utils-validation@3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@docusaurus/logger': 3.9.2 + '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + fs-extra: 11.3.3 + joi: 17.13.3 + js-yaml: 4.1.1 + lodash: 4.17.23 + tslib: 2.8.1 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - react + - react-dom + - supports-color + - uglify-js + - webpack-cli + + '@docusaurus/utils@3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@docusaurus/logger': 3.9.2 + '@docusaurus/types': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + escape-string-regexp: 4.0.0 + execa: 5.1.1 + file-loader: 6.2.0(webpack@5.105.1) + fs-extra: 11.3.3 + github-slugger: 1.5.0 + globby: 11.1.0 + gray-matter: 4.0.3 + jiti: 1.21.7 + js-yaml: 4.1.1 + lodash: 4.17.23 + micromatch: 4.0.8 + p-queue: 6.6.2 + prompts: 2.4.2 + resolve-pathname: 3.0.0 + tslib: 2.8.1 + url-loader: 4.1.1(file-loader@6.2.0(webpack@5.105.1))(webpack@5.105.1) + utility-types: 3.11.0 + webpack: 5.105.1 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - react + - react-dom + - supports-color + - uglify-js + - webpack-cli + + '@easyops-cn/autocomplete.js@0.38.1': + dependencies: + cssesc: 3.0.0 + immediate: 3.3.0 + + '@easyops-cn/docusaurus-search-local@0.54.1(@docusaurus/theme-common@3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + dependencies: + '@docusaurus/plugin-content-docs': 3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(debug@4.4.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@docusaurus/theme-common': 3.9.2(@docusaurus/plugin-content-docs@3.9.2(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/theme-translations': 3.9.2 + '@docusaurus/utils': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-common': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@docusaurus/utils-validation': 3.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@easyops-cn/autocomplete.js': 0.38.1 + '@node-rs/jieba': 1.10.4 + cheerio: 1.2.0 + clsx: 2.1.1 + comlink: 4.4.2 + debug: 4.4.3 + fs-extra: 10.1.0 + klaw-sync: 6.0.0 + lunr: 2.3.9 + lunr-languages: 1.14.0 + mark.js: 8.11.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + optionalDependencies: + open-ask-ai: 0.7.3(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + transitivePeerDependencies: + - '@docusaurus/faster' + - '@mdx-js/react' + - '@parcel/css' + - '@rspack/core' + - '@swc/core' + - '@swc/css' + - '@types/react' + - '@types/react-dom' + - bufferutil + - csso + - esbuild + - lightningcss + - supports-color + - typescript + - uglify-js + - utf-8-validate + - webpack-cli + + '@emnapi/core@1.8.1': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.8.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@hapi/hoek@9.3.0': {} + + '@hapi/topo@5.1.0': + dependencies: + '@hapi/hoek': 9.3.0 + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.10 + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 25.2.3 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jsonjoy.com/base64@1.1.2(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/base64@17.67.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/buffers@1.2.1(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/buffers@17.67.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/codegen@1.0.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/codegen@17.67.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/fs-core@4.56.10(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-node-builtins': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.56.10(tslib@2.8.1) + thingies: 2.5.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-fsa@4.56.10(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-core': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.56.10(tslib@2.8.1) + thingies: 2.5.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-node-builtins@4.56.10(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/fs-node-to-fsa@4.56.10(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-fsa': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.56.10(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-node-utils@4.56.10(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-node-builtins': 4.56.10(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-node@4.56.10(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-core': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-print': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-snapshot': 4.56.10(tslib@2.8.1) + glob-to-regex.js: 1.2.0(tslib@2.8.1) + thingies: 2.5.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-print@4.56.10(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-node-utils': 4.56.10(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-snapshot@4.56.10(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/json-pack': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pack@1.21.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/base64': 1.1.2(tslib@2.8.1) + '@jsonjoy.com/buffers': 1.2.1(tslib@2.8.1) + '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) + '@jsonjoy.com/json-pointer': 1.0.2(tslib@2.8.1) + '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) + hyperdyperid: 1.2.0 + thingies: 2.5.0(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pack@17.67.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/base64': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/codegen': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/json-pointer': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) + hyperdyperid: 1.2.0 + thingies: 2.5.0(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pointer@1.0.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) + '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pointer@17.67.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/util@1.9.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/buffers': 1.2.1(tslib@2.8.1) + '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/util@17.67.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/codegen': 17.67.0(tslib@2.8.1) + tslib: 2.8.1 + + '@leichtgewicht/ip-codec@2.0.5': {} + + '@mdx-js/mdx@3.1.1': + dependencies: + '@types/estree': 1.0.8 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdx': 2.0.13 + acorn: 8.15.0 + collapse-white-space: 2.1.0 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-util-scope: 1.0.0 + estree-walker: 3.0.3 + hast-util-to-jsx-runtime: 2.3.6 + markdown-extensions: 2.0.0 + recma-build-jsx: 1.0.0 + recma-jsx: 1.0.1(acorn@8.15.0) + recma-stringify: 1.0.0 + rehype-recma: 1.0.0 + remark-mdx: 3.1.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + source-map: 0.7.6 + unified: 11.0.5 + unist-util-position-from-estree: 2.0.0 + unist-util-stringify-position: 4.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@mdx-js/react@3.1.1(@types/react@19.2.13)(react@18.3.1)': + dependencies: + '@types/mdx': 2.0.13 + '@types/react': 19.2.13 + react: 18.3.1 + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@noble/hashes@1.4.0': {} + + '@node-rs/jieba-android-arm-eabi@1.10.4': + optional: true + + '@node-rs/jieba-android-arm64@1.10.4': + optional: true + + '@node-rs/jieba-darwin-arm64@1.10.4': + optional: true + + '@node-rs/jieba-darwin-x64@1.10.4': + optional: true + + '@node-rs/jieba-freebsd-x64@1.10.4': + optional: true + + '@node-rs/jieba-linux-arm-gnueabihf@1.10.4': + optional: true + + '@node-rs/jieba-linux-arm64-gnu@1.10.4': + optional: true + + '@node-rs/jieba-linux-arm64-musl@1.10.4': + optional: true + + '@node-rs/jieba-linux-x64-gnu@1.10.4': + optional: true + + '@node-rs/jieba-linux-x64-musl@1.10.4': + optional: true + + '@node-rs/jieba-wasm32-wasi@1.10.4': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@node-rs/jieba-win32-arm64-msvc@1.10.4': + optional: true + + '@node-rs/jieba-win32-ia32-msvc@1.10.4': + optional: true + + '@node-rs/jieba-win32-x64-msvc@1.10.4': + optional: true + + '@node-rs/jieba@1.10.4': + optionalDependencies: + '@node-rs/jieba-android-arm-eabi': 1.10.4 + '@node-rs/jieba-android-arm64': 1.10.4 + '@node-rs/jieba-darwin-arm64': 1.10.4 + '@node-rs/jieba-darwin-x64': 1.10.4 + '@node-rs/jieba-freebsd-x64': 1.10.4 + '@node-rs/jieba-linux-arm-gnueabihf': 1.10.4 + '@node-rs/jieba-linux-arm64-gnu': 1.10.4 + '@node-rs/jieba-linux-arm64-musl': 1.10.4 + '@node-rs/jieba-linux-x64-gnu': 1.10.4 + '@node-rs/jieba-linux-x64-musl': 1.10.4 + '@node-rs/jieba-wasm32-wasi': 1.10.4 + '@node-rs/jieba-win32-arm64-msvc': 1.10.4 + '@node-rs/jieba-win32-ia32-msvc': 1.10.4 + '@node-rs/jieba-win32-x64-msvc': 1.10.4 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@peculiar/asn1-cms@2.6.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.0 + '@peculiar/asn1-x509-attr': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-csr@2.6.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.6.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pfx@2.6.0': + dependencies: + '@peculiar/asn1-cms': 2.6.0 + '@peculiar/asn1-pkcs8': 2.6.0 + '@peculiar/asn1-rsa': 2.6.0 + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs8@2.6.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs9@2.6.0': + dependencies: + '@peculiar/asn1-cms': 2.6.0 + '@peculiar/asn1-pfx': 2.6.0 + '@peculiar/asn1-pkcs8': 2.6.0 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.0 + '@peculiar/asn1-x509-attr': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.6.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.6.0': + dependencies: + asn1js: 3.0.7 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + '@peculiar/asn1-x509-attr@2.6.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.6.0': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.7 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + '@peculiar/x509@1.14.3': + dependencies: + '@peculiar/asn1-cms': 2.6.0 + '@peculiar/asn1-csr': 2.6.0 + '@peculiar/asn1-ecc': 2.6.0 + '@peculiar/asn1-pkcs9': 2.6.0 + '@peculiar/asn1-rsa': 2.6.0 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.0 + pvtsutils: 1.3.6 + reflect-metadata: 0.2.2 + tslib: 2.8.1 + tsyringe: 4.10.0 + + '@pnpm/config.env-replace@1.1.0': {} + + '@pnpm/network.ca-file@1.0.2': + dependencies: + graceful-fs: 4.2.10 + + '@pnpm/npm-conf@3.0.2': + dependencies: + '@pnpm/config.env-replace': 1.1.0 + '@pnpm/network.ca-file': 1.0.2 + config-chain: 1.1.13 + + '@polka/url@1.0.0-next.29': {} + + '@radix-ui/number@1.1.1': + optional: true + + '@radix-ui/primitive@1.1.3': + optional: true + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.13)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 19.2.13 + optional: true + + '@radix-ui/react-context@1.1.2(@types/react@19.2.13)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 19.2.13 + optional: true + + '@radix-ui/react-dialog@1.1.15(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.13)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.13)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.13)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.13)(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.13)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.13)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@19.2.13)(react@18.3.1) + optionalDependencies: + '@types/react': 19.2.13 + optional: true + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.13)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 19.2.13 + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.13)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.13)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.13)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 19.2.13 + optional: true + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.13)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 19.2.13 + optional: true + + '@radix-ui/react-focus-scope@1.1.7(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.13)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.13)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 19.2.13 + optional: true + + '@radix-ui/react-id@1.1.1(@types/react@19.2.13)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.13)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 19.2.13 + optional: true + + '@radix-ui/react-portal@1.1.9(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.13)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 19.2.13 + optional: true + + '@radix-ui/react-presence@1.1.5(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.13)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.13)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 19.2.13 + optional: true + + '@radix-ui/react-primitive@2.1.3(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.13)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 19.2.13 + optional: true + + '@radix-ui/react-scroll-area@1.2.10(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.13)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.13)(react@18.3.1) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.13)(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.13)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.13)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 19.2.13 + optional: true + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.13)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.13)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 19.2.13 + optional: true + + '@radix-ui/react-slot@1.2.4(@types/react@19.2.13)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.13)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 19.2.13 + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.13)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 19.2.13 + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.13)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.13)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.13)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 19.2.13 + optional: true + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.13)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.13)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 19.2.13 + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.13)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.13)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 19.2.13 + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.13)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 19.2.13 + optional: true + + '@sideway/address@4.1.5': + dependencies: + '@hapi/hoek': 9.3.0 + + '@sideway/formula@3.0.1': {} + + '@sideway/pinpoint@2.0.0': {} + + '@sinclair/typebox@0.27.10': {} + + '@sindresorhus/is@4.6.0': {} + + '@sindresorhus/is@5.6.0': {} + + '@slorber/react-helmet-async@1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.6 + invariant: 2.2.4 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-fast-compare: 3.2.2 + shallowequal: 1.1.0 + + '@slorber/remark-comment@1.0.0': + dependencies: + micromark-factory-space: 1.1.0 + micromark-util-character: 1.2.0 + micromark-util-symbol: 1.1.0 + + '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + + '@svgr/babel-plugin-remove-jsx-attribute@8.0.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + + '@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + + '@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + + '@svgr/babel-plugin-svg-dynamic-title@8.0.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + + '@svgr/babel-plugin-svg-em-dimensions@8.0.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + + '@svgr/babel-plugin-transform-react-native-svg@8.1.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + + '@svgr/babel-plugin-transform-svg-component@8.0.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + + '@svgr/babel-preset@8.1.0(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@svgr/babel-plugin-add-jsx-attribute': 8.0.0(@babel/core@7.29.0) + '@svgr/babel-plugin-remove-jsx-attribute': 8.0.0(@babel/core@7.29.0) + '@svgr/babel-plugin-remove-jsx-empty-expression': 8.0.0(@babel/core@7.29.0) + '@svgr/babel-plugin-replace-jsx-attribute-value': 8.0.0(@babel/core@7.29.0) + '@svgr/babel-plugin-svg-dynamic-title': 8.0.0(@babel/core@7.29.0) + '@svgr/babel-plugin-svg-em-dimensions': 8.0.0(@babel/core@7.29.0) + '@svgr/babel-plugin-transform-react-native-svg': 8.1.0(@babel/core@7.29.0) + '@svgr/babel-plugin-transform-svg-component': 8.0.0(@babel/core@7.29.0) + + '@svgr/core@8.1.0(typescript@5.9.3)': + dependencies: + '@babel/core': 7.29.0 + '@svgr/babel-preset': 8.1.0(@babel/core@7.29.0) + camelcase: 6.3.0 + cosmiconfig: 8.3.6(typescript@5.9.3) + snake-case: 3.0.4 + transitivePeerDependencies: + - supports-color + - typescript + + '@svgr/hast-util-to-babel-ast@8.0.0': + dependencies: + '@babel/types': 7.29.0 + entities: 4.5.0 + + '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.9.3))': + dependencies: + '@babel/core': 7.29.0 + '@svgr/babel-preset': 8.1.0(@babel/core@7.29.0) + '@svgr/core': 8.1.0(typescript@5.9.3) + '@svgr/hast-util-to-babel-ast': 8.0.0 + svg-parser: 2.0.4 + transitivePeerDependencies: + - supports-color + + '@svgr/plugin-svgo@8.1.0(@svgr/core@8.1.0(typescript@5.9.3))(typescript@5.9.3)': + dependencies: + '@svgr/core': 8.1.0(typescript@5.9.3) + cosmiconfig: 8.3.6(typescript@5.9.3) + deepmerge: 4.3.1 + svgo: 3.3.2 + transitivePeerDependencies: + - typescript + + '@svgr/webpack@8.1.0(typescript@5.9.3)': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-constant-elements': 7.27.1(@babel/core@7.29.0) + '@babel/preset-env': 7.29.0(@babel/core@7.29.0) + '@babel/preset-react': 7.28.5(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@svgr/core': 8.1.0(typescript@5.9.3) + '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) + '@svgr/plugin-svgo': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3))(typescript@5.9.3) + transitivePeerDependencies: + - supports-color + - typescript + + '@szmarczak/http-timer@5.0.1': + dependencies: + defer-to-connect: 2.0.1 + + '@trysound/sax@0.2.0': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 25.2.3 + + '@types/bonjour@3.5.13': + dependencies: + '@types/node': 25.2.3 + + '@types/connect-history-api-fallback@1.5.4': + dependencies: + '@types/express-serve-static-core': 4.19.8 + '@types/node': 25.2.3 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 25.2.3 + + '@types/debug@4.1.12': + dependencies: + '@types/ms': 2.1.0 + + '@types/eslint-scope@3.7.7': + dependencies: + '@types/eslint': 9.6.1 + '@types/estree': 1.0.8 + + '@types/eslint@9.6.1': + dependencies: + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.8 + + '@types/estree@1.0.8': {} + + '@types/express-serve-static-core@4.19.8': + dependencies: + '@types/node': 25.2.3 + '@types/qs': 6.14.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@4.17.25': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 4.19.8 + '@types/qs': 6.14.0 + '@types/serve-static': 1.15.10 + + '@types/gtag.js@0.0.12': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/history@4.7.11': {} + + '@types/html-minifier-terser@6.1.0': {} + + '@types/http-cache-semantics@4.2.0': {} + + '@types/http-errors@2.0.5': {} + + '@types/http-proxy@1.17.17': + dependencies: + '@types/node': 25.2.3 + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/json-schema@7.0.15': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdx@2.0.13': {} + + '@types/mime@1.3.5': {} + + '@types/ms@2.1.0': {} + + '@types/node@17.0.45': {} + + '@types/node@25.2.3': + dependencies: + undici-types: 7.16.0 + + '@types/prismjs@1.26.5': {} + + '@types/qs@6.14.0': {} + + '@types/range-parser@1.2.7': {} + + '@types/react-router-config@5.0.11': + dependencies: + '@types/history': 4.7.11 + '@types/react': 19.2.13 + '@types/react-router': 5.1.20 + + '@types/react-router-dom@5.3.3': + dependencies: + '@types/history': 4.7.11 + '@types/react': 19.2.13 + '@types/react-router': 5.1.20 + + '@types/react-router@5.1.20': + dependencies: + '@types/history': 4.7.11 + '@types/react': 19.2.13 + + '@types/react@19.2.13': + dependencies: + csstype: 3.2.3 + + '@types/retry@0.12.2': {} + + '@types/sax@1.2.7': + dependencies: + '@types/node': 17.0.45 + + '@types/send@0.17.6': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 25.2.3 + + '@types/send@1.2.1': + dependencies: + '@types/node': 25.2.3 + + '@types/serve-index@1.9.4': + dependencies: + '@types/express': 4.17.25 + + '@types/serve-static@1.15.10': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 25.2.3 + '@types/send': 0.17.6 + + '@types/sockjs@0.3.36': + dependencies: + '@types/node': 25.2.3 + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.2.3 + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@ungap/structured-clone@1.3.0': {} + + '@webassemblyjs/ast@1.14.1': + dependencies: + '@webassemblyjs/helper-numbers': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + + '@webassemblyjs/floating-point-hex-parser@1.13.2': {} + + '@webassemblyjs/helper-api-error@1.13.2': {} + + '@webassemblyjs/helper-buffer@1.14.1': {} + + '@webassemblyjs/helper-numbers@1.13.2': + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.13.2 + '@webassemblyjs/helper-api-error': 1.13.2 + '@xtuc/long': 4.2.2 + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} + + '@webassemblyjs/helper-wasm-section@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/wasm-gen': 1.14.1 + + '@webassemblyjs/ieee754@1.13.2': + dependencies: + '@xtuc/ieee754': 1.2.0 + + '@webassemblyjs/leb128@1.13.2': + dependencies: + '@xtuc/long': 4.2.2 + + '@webassemblyjs/utf8@1.13.2': {} + + '@webassemblyjs/wasm-edit@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/helper-wasm-section': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-opt': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + '@webassemblyjs/wast-printer': 1.14.1 + + '@webassemblyjs/wasm-gen@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wasm-opt@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + + '@webassemblyjs/wasm-parser@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-api-error': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wast-printer@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@xtuc/long': 4.2.2 + + '@xtuc/ieee754@1.2.0': {} + + '@xtuc/long@4.2.2': {} + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + acorn-import-phases@1.0.4(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn-walk@8.3.4: + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + address@1.2.2: {} + + aggregate-error@3.1.0: + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + + ajv-formats@2.1.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv-keywords@3.5.2(ajv@6.12.6): + dependencies: + ajv: 6.12.6 + + ajv-keywords@5.1.0(ajv@8.17.1): + dependencies: + ajv: 8.17.1 + fast-deep-equal: 3.1.3 + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + algoliasearch-helper@3.27.1(algoliasearch@5.48.0): + dependencies: + '@algolia/events': 4.0.1 + algoliasearch: 5.48.0 + + algoliasearch@5.48.0: + dependencies: + '@algolia/abtesting': 1.14.0 + '@algolia/client-abtesting': 5.48.0 + '@algolia/client-analytics': 5.48.0 + '@algolia/client-common': 5.48.0 + '@algolia/client-insights': 5.48.0 + '@algolia/client-personalization': 5.48.0 + '@algolia/client-query-suggestions': 5.48.0 + '@algolia/client-search': 5.48.0 + '@algolia/ingestion': 1.48.0 + '@algolia/monitoring': 1.48.0 + '@algolia/recommend': 5.48.0 + '@algolia/requester-browser-xhr': 5.48.0 + '@algolia/requester-fetch': 5.48.0 + '@algolia/requester-node-http': 5.48.0 + + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-html-community@0.0.8: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + arg@5.0.2: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + optional: true + + array-flatten@1.1.1: {} + + array-union@2.1.0: {} + + asn1js@3.0.7: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + + astring@1.9.0: {} + + autoprefixer@10.4.24(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + caniuse-lite: 1.0.30001769 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + babel-loader@9.2.1(@babel/core@7.29.0)(webpack@5.105.1): + dependencies: + '@babel/core': 7.29.0 + find-cache-dir: 4.0.0 + schema-utils: 4.3.3 + webpack: 5.105.1 + + babel-plugin-dynamic-import-node@2.3.3: + dependencies: + object.assign: 4.1.7 + + babel-plugin-polyfill-corejs2@0.4.15(@babel/core@7.29.0): + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.6(@babel/core@7.29.0) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.6(@babel/core@7.29.0) + core-js-compat: 3.48.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.14.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.6(@babel/core@7.29.0) + core-js-compat: 3.48.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-regenerator@0.6.6(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-define-polyfill-provider': 0.6.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + bail@2.0.2: {} + + balanced-match@1.0.2: {} + + baseline-browser-mapping@2.9.19: {} + + batch@0.6.1: {} + + big.js@5.2.2: {} + + binary-extensions@2.3.0: {} + + body-parser@1.20.4: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.14.1 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + bonjour-service@1.3.0: + dependencies: + fast-deep-equal: 3.1.3 + multicast-dns: 7.2.5 + + boolbase@1.0.0: {} + + boxen@6.2.1: + dependencies: + ansi-align: 3.0.1 + camelcase: 6.3.0 + chalk: 4.1.2 + cli-boxes: 3.0.0 + string-width: 5.1.2 + type-fest: 2.19.0 + widest-line: 4.0.1 + wrap-ansi: 8.1.0 + + boxen@7.1.1: + dependencies: + ansi-align: 3.0.1 + camelcase: 7.0.1 + chalk: 5.6.2 + cli-boxes: 3.0.0 + string-width: 5.1.2 + type-fest: 2.19.0 + widest-line: 4.0.1 + wrap-ansi: 8.1.0 + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.19 + caniuse-lite: 1.0.30001769 + electron-to-chromium: 1.5.286 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + buffer-from@1.1.2: {} + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + bytes@3.0.0: {} + + bytes@3.1.2: {} + + bytestreamjs@2.0.1: {} + + cacheable-lookup@7.0.0: {} + + cacheable-request@10.2.14: + dependencies: + '@types/http-cache-semantics': 4.2.0 + get-stream: 6.0.1 + http-cache-semantics: 4.2.0 + keyv: 4.5.4 + mimic-response: 4.0.0 + normalize-url: 8.1.1 + responselike: 3.0.0 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camel-case@4.1.2: + dependencies: + pascal-case: 3.1.2 + tslib: 2.8.1 + + camelcase@6.3.0: {} + + camelcase@7.0.1: {} + + caniuse-api@3.0.0: + dependencies: + browserslist: 4.28.1 + caniuse-lite: 1.0.30001769 + lodash.memoize: 4.1.2 + lodash.uniq: 4.5.0 + + caniuse-lite@1.0.30001769: {} + + ccount@2.0.1: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + char-regex@1.0.2: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.2.2 + css-what: 6.2.2 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + + cheerio@1.0.0-rc.12: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + htmlparser2: 8.0.2 + parse5: 7.3.0 + parse5-htmlparser2-tree-adapter: 7.1.0 + + cheerio@1.2.0: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + encoding-sniffer: 0.2.1 + htmlparser2: 10.1.0 + parse5: 7.3.0 + parse5-htmlparser2-tree-adapter: 7.1.0 + parse5-parser-stream: 7.1.2 + undici: 7.21.0 + whatwg-mimetype: 4.0.0 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chrome-trace-event@1.0.4: {} + + ci-info@3.9.0: {} + + clean-css@5.3.3: + dependencies: + source-map: 0.6.1 + + clean-stack@2.2.0: {} + + cli-boxes@3.0.0: {} + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + + clone-deep@4.0.1: + dependencies: + is-plain-object: 2.0.4 + kind-of: 6.0.3 + shallow-clone: 3.0.1 + + clsx@2.1.1: {} + + collapse-white-space@2.1.0: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colord@2.9.3: {} + + colorette@2.0.20: {} + + combine-promises@1.2.0: {} + + comlink@4.4.2: {} + + comma-separated-tokens@2.0.3: {} + + commander@10.0.1: {} + + commander@2.20.3: {} + + commander@5.1.0: {} + + commander@7.2.0: {} + + commander@8.3.0: {} + + common-path-prefix@3.0.0: {} + + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + concat-map@0.0.1: {} + + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + + configstore@6.0.0: + dependencies: + dot-prop: 6.0.1 + graceful-fs: 4.2.11 + unique-string: 3.0.0 + write-file-atomic: 3.0.3 + xdg-basedir: 5.1.0 + + connect-history-api-fallback@2.0.0: {} + + consola@3.4.2: {} + + content-disposition@0.5.2: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.0.7: {} + + cookie@0.7.2: {} + + copy-webpack-plugin@11.0.0(webpack@5.105.1): + dependencies: + fast-glob: 3.3.3 + glob-parent: 6.0.2 + globby: 13.2.2 + normalize-path: 3.0.0 + schema-utils: 4.3.3 + serialize-javascript: 6.0.2 + webpack: 5.105.1 + + core-js-compat@3.48.0: + dependencies: + browserslist: 4.28.1 + + core-js-pure@3.48.0: {} + + core-js@3.48.0: {} + + core-util-is@1.0.3: {} + + cosmiconfig@8.3.6(typescript@5.9.3): + dependencies: + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + path-type: 4.0.0 + optionalDependencies: + typescript: 5.9.3 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + crypto-random-string@4.0.0: + dependencies: + type-fest: 1.4.0 + + css-blank-pseudo@7.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + + css-declaration-sorter@7.3.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + css-has-pseudo@7.0.3(postcss@8.5.6): + dependencies: + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1) + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + + css-loader@6.11.0(webpack@5.105.1): + dependencies: + icss-utils: 5.1.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-modules-extract-imports: 3.1.0(postcss@8.5.6) + postcss-modules-local-by-default: 4.2.0(postcss@8.5.6) + postcss-modules-scope: 3.2.1(postcss@8.5.6) + postcss-modules-values: 4.0.0(postcss@8.5.6) + postcss-value-parser: 4.2.0 + semver: 7.7.4 + optionalDependencies: + webpack: 5.105.1 + + css-minimizer-webpack-plugin@5.0.1(clean-css@5.3.3)(webpack@5.105.1): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + cssnano: 6.1.2(postcss@8.5.6) + jest-worker: 29.7.0 + postcss: 8.5.6 + schema-utils: 4.3.3 + serialize-javascript: 6.0.2 + webpack: 5.105.1 + optionalDependencies: + clean-css: 5.3.3 + + css-prefers-color-scheme@10.0.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + css-select@4.3.0: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 4.3.1 + domutils: 2.8.0 + nth-check: 2.1.1 + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + + css-tree@2.3.1: + dependencies: + mdn-data: 2.0.30 + source-map-js: 1.2.1 + + css-what@6.2.2: {} + + cssdb@8.7.1: {} + + cssesc@3.0.0: {} + + cssnano-preset-advanced@6.1.2(postcss@8.5.6): + dependencies: + autoprefixer: 10.4.24(postcss@8.5.6) + browserslist: 4.28.1 + cssnano-preset-default: 6.1.2(postcss@8.5.6) + postcss: 8.5.6 + postcss-discard-unused: 6.0.5(postcss@8.5.6) + postcss-merge-idents: 6.0.3(postcss@8.5.6) + postcss-reduce-idents: 6.0.3(postcss@8.5.6) + postcss-zindex: 6.0.2(postcss@8.5.6) + + cssnano-preset-default@6.1.2(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + css-declaration-sorter: 7.3.1(postcss@8.5.6) + cssnano-utils: 4.0.2(postcss@8.5.6) + postcss: 8.5.6 + postcss-calc: 9.0.1(postcss@8.5.6) + postcss-colormin: 6.1.0(postcss@8.5.6) + postcss-convert-values: 6.1.0(postcss@8.5.6) + postcss-discard-comments: 6.0.2(postcss@8.5.6) + postcss-discard-duplicates: 6.0.3(postcss@8.5.6) + postcss-discard-empty: 6.0.3(postcss@8.5.6) + postcss-discard-overridden: 6.0.2(postcss@8.5.6) + postcss-merge-longhand: 6.0.5(postcss@8.5.6) + postcss-merge-rules: 6.1.1(postcss@8.5.6) + postcss-minify-font-values: 6.1.0(postcss@8.5.6) + postcss-minify-gradients: 6.0.3(postcss@8.5.6) + postcss-minify-params: 6.1.0(postcss@8.5.6) + postcss-minify-selectors: 6.0.4(postcss@8.5.6) + postcss-normalize-charset: 6.0.2(postcss@8.5.6) + postcss-normalize-display-values: 6.0.2(postcss@8.5.6) + postcss-normalize-positions: 6.0.2(postcss@8.5.6) + postcss-normalize-repeat-style: 6.0.2(postcss@8.5.6) + postcss-normalize-string: 6.0.2(postcss@8.5.6) + postcss-normalize-timing-functions: 6.0.2(postcss@8.5.6) + postcss-normalize-unicode: 6.1.0(postcss@8.5.6) + postcss-normalize-url: 6.0.2(postcss@8.5.6) + postcss-normalize-whitespace: 6.0.2(postcss@8.5.6) + postcss-ordered-values: 6.0.2(postcss@8.5.6) + postcss-reduce-initial: 6.1.0(postcss@8.5.6) + postcss-reduce-transforms: 6.0.2(postcss@8.5.6) + postcss-svgo: 6.0.3(postcss@8.5.6) + postcss-unique-selectors: 6.0.4(postcss@8.5.6) + + cssnano-utils@4.0.2(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + cssnano@6.1.2(postcss@8.5.6): + dependencies: + cssnano-preset-default: 6.1.2(postcss@8.5.6) + lilconfig: 3.1.3 + postcss: 8.5.6 + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + + csstype@3.2.3: {} + + debounce@1.2.1: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + + deepmerge@4.3.1: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + defer-to-connect@2.0.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-lazy-prop@2.0.0: {} + + define-lazy-prop@3.0.0: {} + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + depd@1.1.2: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + destroy@1.2.0: {} + + detect-node-es@1.1.0: + optional: true + + detect-node@2.1.0: {} + + detect-port@1.6.1: + dependencies: + address: 1.2.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dns-packet@5.6.1: + dependencies: + '@leichtgewicht/ip-codec': 2.0.5 + + dom-converter@0.2.0: + dependencies: + utila: 0.4.0 + + dom-serializer@1.4.1: + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + entities: 2.2.0 + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@4.3.1: + dependencies: + domelementtype: 2.3.0 + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@2.8.0: + dependencies: + dom-serializer: 1.4.1 + domelementtype: 2.3.0 + domhandler: 4.3.1 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dot-case@3.0.4: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + + dot-prop@6.0.1: + dependencies: + is-obj: 2.0.0 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + duplexer@0.1.2: {} + + eastasianwidth@0.2.0: {} + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.286: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + emojilib@2.4.0: {} + + emojis-list@3.0.0: {} + + emoticon@4.1.0: {} + + encodeurl@2.0.0: {} + + encoding-sniffer@0.2.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding: 3.1.1 + + enhanced-resolve@5.19.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + entities@2.2.0: {} + + entities@4.5.0: {} + + entities@6.0.1: {} + + entities@7.0.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.0.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + esast-util-from-estree@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + unist-util-position-from-estree: 2.0.0 + + esast-util-from-js@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + acorn: 8.15.0 + esast-util-from-estree: 2.0.0 + vfile-message: 4.0.3 + + escalade@3.2.0: {} + + escape-goat@4.0.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + eslint-scope@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + + esprima@4.0.1: {} + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@4.3.0: {} + + estraverse@5.3.0: {} + + estree-util-attach-comments@3.0.0: + dependencies: + '@types/estree': 1.0.8 + + estree-util-build-jsx@3.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-walker: 3.0.3 + + estree-util-is-identifier-name@3.0.0: {} + + estree-util-scope@1.0.0: + dependencies: + '@types/estree': 1.0.8 + devlop: 1.1.0 + + estree-util-to-js@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + astring: 1.9.0 + source-map: 0.7.6 + + estree-util-value-to-estree@3.5.0: + dependencies: + '@types/estree': 1.0.8 + + estree-util-visit@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/unist': 3.0.3 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + esutils@2.0.3: {} + + eta@2.2.0: {} + + etag@1.8.1: {} + + eval@0.1.8: + dependencies: + '@types/node': 25.2.3 + require-like: 0.1.2 + + eventemitter3@4.0.7: {} + + events@3.3.0: {} + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + express@4.22.1: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.4 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.12 + proxy-addr: 2.0.7 + qs: 6.14.1 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-uri@3.1.0: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fault@2.0.1: + dependencies: + format: 0.2.2 + + faye-websocket@0.11.4: + dependencies: + websocket-driver: 0.7.4 + + feed@4.2.2: + dependencies: + xml-js: 1.6.11 + + figures@3.2.0: + dependencies: + escape-string-regexp: 1.0.5 + + file-loader@6.2.0(webpack@5.105.1): + dependencies: + loader-utils: 2.0.4 + schema-utils: 3.3.0 + webpack: 5.105.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + find-cache-dir@4.0.0: + dependencies: + common-path-prefix: 3.0.0 + pkg-dir: 7.0.0 + + find-up@6.3.0: + dependencies: + locate-path: 7.2.0 + path-exists: 5.0.0 + + flat@5.0.2: {} + + follow-redirects@1.15.11(debug@4.4.3): + optionalDependencies: + debug: 4.4.3 + + form-data-encoder@2.1.4: {} + + format@0.2.2: {} + + forwarded@0.2.0: {} + + fraction.js@5.3.4: {} + + fresh@0.5.2: {} + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + + fs-extra@11.3.3: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: + optional: true + + get-own-enumerable-property-symbols@3.0.2: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@6.0.1: {} + + github-slugger@1.5.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob-to-regex.js@1.2.0(tslib@2.8.1): + dependencies: + tslib: 2.8.1 + + glob-to-regexp@0.4.1: {} + + global-dirs@3.0.1: + dependencies: + ini: 2.0.0 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + globby@13.2.2: + dependencies: + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 4.0.0 + + gopd@1.2.0: {} + + got@12.6.1: + dependencies: + '@sindresorhus/is': 5.6.0 + '@szmarczak/http-timer': 5.0.1 + cacheable-lookup: 7.0.0 + cacheable-request: 10.2.14 + decompress-response: 6.0.0 + form-data-encoder: 2.1.4 + get-stream: 6.0.1 + http2-wrapper: 2.2.1 + lowercase-keys: 3.0.0 + p-cancelable: 3.0.0 + responselike: 3.0.0 + + graceful-fs@4.2.10: {} + + graceful-fs@4.2.11: {} + + gray-matter@4.0.3: + dependencies: + js-yaml: 3.14.2 + kind-of: 6.0.3 + section-matter: 1.0.0 + strip-bom-string: 1.0.0 + + gzip-size@6.0.0: + dependencies: + duplexer: 0.1.2 + + handle-thing@2.0.1: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + has-yarn@3.0.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.1.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + optional: true + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.0 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-estree@3.1.3: + dependencies: + '@types/estree': 1.0.8 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-attach-comments: 3.0.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + zwitch: 2.0.4 + transitivePeerDependencies: + - supports-color + + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.8 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + optional: true + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + + he@1.2.0: {} + + highlight.js@11.11.1: + optional: true + + history@4.10.1: + dependencies: + '@babel/runtime': 7.28.6 + loose-envify: 1.4.0 + resolve-pathname: 3.0.0 + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + value-equal: 1.0.1 + + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + + hpack.js@2.1.6: + dependencies: + inherits: 2.0.4 + obuf: 1.1.2 + readable-stream: 2.3.8 + wbuf: 1.7.3 + + html-escaper@2.0.2: {} + + html-minifier-terser@6.1.0: + dependencies: + camel-case: 4.1.2 + clean-css: 5.3.3 + commander: 8.3.0 + he: 1.2.0 + param-case: 3.0.4 + relateurl: 0.2.7 + terser: 5.46.0 + + html-minifier-terser@7.2.0: + dependencies: + camel-case: 4.1.2 + clean-css: 5.3.3 + commander: 10.0.1 + entities: 4.5.0 + param-case: 3.0.4 + relateurl: 0.2.7 + terser: 5.46.0 + + html-tags@3.3.1: {} + + html-url-attributes@3.0.1: + optional: true + + html-void-elements@3.0.0: {} + + html-webpack-plugin@5.6.6(webpack@5.105.1): + dependencies: + '@types/html-minifier-terser': 6.1.0 + html-minifier-terser: 6.1.0 + lodash: 4.17.23 + pretty-error: 4.0.0 + tapable: 2.3.0 + optionalDependencies: + webpack: 5.105.1 + + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + + htmlparser2@6.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + domutils: 2.8.0 + entities: 2.2.0 + + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + + http-cache-semantics@4.2.0: {} + + http-deceiver@1.2.7: {} + + http-errors@1.8.1: + dependencies: + depd: 1.1.2 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 1.5.0 + toidentifier: 1.0.1 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-parser-js@0.5.10: {} + + http-proxy-middleware@2.0.9(@types/express@4.17.25)(debug@4.4.3): + dependencies: + '@types/http-proxy': 1.17.17 + http-proxy: 1.18.1(debug@4.4.3) + is-glob: 4.0.3 + is-plain-obj: 3.0.0 + micromatch: 4.0.8 + optionalDependencies: + '@types/express': 4.17.25 + transitivePeerDependencies: + - debug + + http-proxy@1.18.1(debug@4.4.3): + dependencies: + eventemitter3: 4.0.7 + follow-redirects: 1.15.11(debug@4.4.3) + requires-port: 1.0.0 + transitivePeerDependencies: + - debug + + http2-wrapper@2.2.1: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + + human-signals@2.1.0: {} + + hyperdyperid@1.2.0: {} + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + icss-utils@5.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + ignore@5.3.2: {} + + image-size@2.0.2: {} + + immediate@3.3.0: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-lazy@4.0.0: {} + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + infima@0.2.0-alpha.45: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + ini@2.0.0: {} + + inline-style-parser@0.2.7: {} + + invariant@2.2.4: + dependencies: + loose-envify: 1.4.0 + + ipaddr.js@1.9.1: {} + + ipaddr.js@2.3.0: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-arrayish@0.2.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-ci@3.0.1: + dependencies: + ci-info: 3.9.0 + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-decimal@2.0.1: {} + + is-docker@2.2.1: {} + + is-docker@3.0.0: {} + + is-extendable@0.1.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-hexadecimal@2.0.1: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-installed-globally@0.4.0: + dependencies: + global-dirs: 3.0.1 + is-path-inside: 3.0.3 + + is-network-error@1.3.0: {} + + is-npm@6.1.0: {} + + is-number@7.0.0: {} + + is-obj@1.0.1: {} + + is-obj@2.0.0: {} + + is-path-inside@3.0.3: {} + + is-plain-obj@3.0.0: {} + + is-plain-obj@4.1.0: {} + + is-plain-object@2.0.4: + dependencies: + isobject: 3.0.1 + + is-regexp@1.0.0: {} + + is-stream@2.0.1: {} + + is-typedarray@1.0.0: {} + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + is-wsl@3.1.0: + dependencies: + is-inside-container: 1.0.0 + + is-yarn-global@0.4.1: {} + + isarray@0.0.1: {} + + isarray@1.0.0: {} + + isexe@2.0.0: {} + + isobject@3.0.1: {} + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 25.2.3 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.1 + + jest-worker@27.5.1: + dependencies: + '@types/node': 25.2.3 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest-worker@29.7.0: + dependencies: + '@types/node': 25.2.3 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jiti@1.21.7: {} + + joi@17.13.3: + dependencies: + '@hapi/hoek': 9.3.0 + '@hapi/topo': 5.1.0 + '@sideway/address': 4.1.5 + '@sideway/formula': 3.0.1 + '@sideway/pinpoint': 2.0.0 + + js-tokens@4.0.0: {} + + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json5@2.2.3: {} + + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kind-of@6.0.3: {} + + klaw-sync@6.0.0: + dependencies: + graceful-fs: 4.2.11 + + kleur@3.0.3: {} + + latest-version@7.0.0: + dependencies: + package-json: 8.1.1 + + launch-editor@2.12.0: + dependencies: + picocolors: 1.1.1 + shell-quote: 1.8.3 + + leven@3.1.0: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + loader-runner@4.3.1: {} + + loader-utils@2.0.4: + dependencies: + big.js: 5.2.2 + emojis-list: 3.0.0 + json5: 2.2.3 + + locate-path@7.2.0: + dependencies: + p-locate: 6.0.0 + + lodash.debounce@4.0.8: {} + + lodash.memoize@4.1.2: {} + + lodash.uniq@4.5.0: {} + + lodash@4.17.23: {} + + longest-streak@3.1.0: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lower-case@2.0.2: + dependencies: + tslib: 2.8.1 + + lowercase-keys@3.0.0: {} + + lowlight@3.3.0: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + highlight.js: 11.11.1 + optional: true + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.563.0(react@18.3.1): + dependencies: + react: 18.3.1 + optional: true + + lunr-languages@1.14.0: {} + + lunr@2.3.9: {} + + mark.js@8.11.1: {} + + markdown-extensions@2.0.0: {} + + markdown-table@2.0.0: + dependencies: + repeat-string: 1.6.1 + + markdown-table@3.0.4: {} + + math-intrinsics@1.1.0: {} + + mdast-util-directive@3.1.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-visit-parents: 6.0.2 + transitivePeerDependencies: + - supports-color + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-frontmatter@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + escape-string-regexp: 5.0.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + micromark-extension-frontmatter: 2.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.2 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx@3.0.0: + dependencies: + mdast-util-from-markdown: 2.0.2 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdn-data@2.0.28: {} + + mdn-data@2.0.30: {} + + media-typer@0.3.0: {} + + memfs@4.56.10(tslib@2.8.1): + dependencies: + '@jsonjoy.com/fs-core': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-fsa': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-node': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-node-to-fsa': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-print': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/fs-snapshot': 4.56.10(tslib@2.8.1) + '@jsonjoy.com/json-pack': 1.21.0(tslib@2.8.1) + '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) + glob-to-regex.js: 1.2.0(tslib@2.8.1) + thingies: 2.5.0(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + + merge-descriptors@1.0.3: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + methods@1.1.2: {} + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-directive@3.0.2: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + parse-entities: 4.0.2 + + micromark-extension-frontmatter@2.0.0: + dependencies: + fault: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-mdx-expression@3.0.1: + dependencies: + '@types/estree': 1.0.8 + devlop: 1.1.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-mdx-jsx@3.0.2: + dependencies: + '@types/estree': 1.0.8 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + + micromark-extension-mdx-md@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-mdxjs-esm@3.0.0: + dependencies: + '@types/estree': 1.0.8 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-extension-mdxjs@3.0.0: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + micromark-extension-mdx-expression: 3.0.1 + micromark-extension-mdx-jsx: 3.0.2 + micromark-extension-mdx-md: 2.0.0 + micromark-extension-mdxjs-esm: 3.0.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-mdx-expression@2.0.3: + dependencies: + '@types/estree': 1.0.8 + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-factory-space@1.1.0: + dependencies: + micromark-util-character: 1.2.0 + micromark-util-types: 1.1.0 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@1.2.0: + dependencies: + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-events-to-acorn@2.0.3: + dependencies: + '@types/estree': 1.0.8 + '@types/unist': 3.0.3 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.3 + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@1.1.0: {} + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@1.1.0: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.12 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.33.0: {} + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.18: + dependencies: + mime-db: 1.33.0 + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@1.6.0: {} + + mimic-fn@2.1.0: {} + + mimic-response@3.1.0: {} + + mimic-response@4.0.0: {} + + mini-css-extract-plugin@2.10.0(webpack@5.105.1): + dependencies: + schema-utils: 4.3.3 + tapable: 2.3.0 + webpack: 5.105.1 + + minimalistic-assert@1.0.1: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimist@1.2.8: {} + + mrmime@2.0.1: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + multicast-dns@7.2.5: + dependencies: + dns-packet: 5.6.1 + thunky: 1.1.0 + + nanoid@3.3.11: {} + + negotiator@0.6.3: {} + + negotiator@0.6.4: {} + + neo-async@2.6.2: {} + + no-case@3.0.4: + dependencies: + lower-case: 2.0.2 + tslib: 2.8.1 + + node-emoji@2.2.0: + dependencies: + '@sindresorhus/is': 4.6.0 + char-regex: 1.0.2 + emojilib: 2.4.0 + skin-tone: 2.0.0 + + node-releases@2.0.27: {} + + normalize-path@3.0.0: {} + + normalize-url@8.1.1: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + nprogress@0.2.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + null-loader@4.0.1(webpack@5.105.1): + dependencies: + loader-utils: 2.0.4 + schema-utils: 3.3.0 + webpack: 5.105.1 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + obuf@1.1.2: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.1.0: {} + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + open-ask-ai@0.7.3(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@radix-ui/react-dialog': 1.1.15(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-scroll-area': 1.2.10(@types/react@19.2.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.13)(react@18.3.1) + lowlight: 3.3.0 + lucide-react: 0.563.0(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-markdown: 10.1.0(@types/react@19.2.13)(react@18.3.1) + rehype-highlight: 7.0.2 + remark-gfm: 4.0.1 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - supports-color + optional: true + + open@10.2.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + + opener@1.5.2: {} + + p-cancelable@3.0.0: {} + + p-finally@1.0.0: {} + + p-limit@4.0.0: + dependencies: + yocto-queue: 1.2.2 + + p-locate@6.0.0: + dependencies: + p-limit: 4.0.0 + + p-map@4.0.0: + dependencies: + aggregate-error: 3.1.0 + + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + + p-retry@6.2.1: + dependencies: + '@types/retry': 0.12.2 + is-network-error: 1.3.0 + retry: 0.13.1 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + + package-json@8.1.1: + dependencies: + got: 12.6.1 + registry-auth-token: 5.1.1 + registry-url: 6.0.1 + semver: 7.7.4 + + param-case@3.0.4: + dependencies: + dot-case: 3.0.4 + tslib: 2.8.1 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-numeric-range@1.3.0: {} + + parse5-htmlparser2-tree-adapter@7.1.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.3.0 + + parse5-parser-stream@7.1.2: + dependencies: + parse5: 7.3.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parseurl@1.3.3: {} + + pascal-case@3.1.2: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + + path-exists@5.0.0: {} + + path-is-inside@1.0.2: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-to-regexp@0.1.12: {} + + path-to-regexp@1.9.0: + dependencies: + isarray: 0.0.1 + + path-to-regexp@3.3.0: {} + + path-type@4.0.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + pkg-dir@7.0.0: + dependencies: + find-up: 6.3.0 + + pkijs@3.3.3: + dependencies: + '@noble/hashes': 1.4.0 + asn1js: 3.0.7 + bytestreamjs: 2.0.1 + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + + postcss-attribute-case-insensitive@7.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + + postcss-calc@9.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + postcss-value-parser: 4.2.0 + + postcss-clamp@4.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-color-functional-notation@7.0.12(postcss@8.5.6): + dependencies: + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) + '@csstools/utilities': 2.0.0(postcss@8.5.6) + postcss: 8.5.6 + + postcss-color-hex-alpha@10.0.0(postcss@8.5.6): + dependencies: + '@csstools/utilities': 2.0.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-color-rebeccapurple@10.0.0(postcss@8.5.6): + dependencies: + '@csstools/utilities': 2.0.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-colormin@6.1.0(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + caniuse-api: 3.0.0 + colord: 2.9.3 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-convert-values@6.1.0(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-custom-media@11.0.6(postcss@8.5.6): + dependencies: + '@csstools/cascade-layer-name-parser': 2.0.5(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + postcss: 8.5.6 + + postcss-custom-properties@14.0.6(postcss@8.5.6): + dependencies: + '@csstools/cascade-layer-name-parser': 2.0.5(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/utilities': 2.0.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-custom-selectors@8.0.5(postcss@8.5.6): + dependencies: + '@csstools/cascade-layer-name-parser': 2.0.5(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + + postcss-dir-pseudo-class@9.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + + postcss-discard-comments@6.0.2(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-discard-duplicates@6.0.3(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-discard-empty@6.0.3(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-discard-overridden@6.0.2(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-discard-unused@6.0.5(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + postcss-double-position-gradients@6.0.4(postcss@8.5.6): + dependencies: + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) + '@csstools/utilities': 2.0.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-focus-visible@10.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + + postcss-focus-within@9.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + + postcss-font-variant@5.0.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-gap-properties@6.0.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-image-set-function@7.0.0(postcss@8.5.6): + dependencies: + '@csstools/utilities': 2.0.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-lab-function@7.0.12(postcss@8.5.6): + dependencies: + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) + '@csstools/utilities': 2.0.0(postcss@8.5.6) + postcss: 8.5.6 + + postcss-loader@7.3.4(postcss@8.5.6)(typescript@5.9.3)(webpack@5.105.1): + dependencies: + cosmiconfig: 8.3.6(typescript@5.9.3) + jiti: 1.21.7 + postcss: 8.5.6 + semver: 7.7.4 + webpack: 5.105.1 + transitivePeerDependencies: + - typescript + + postcss-logical@8.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-merge-idents@6.0.3(postcss@8.5.6): + dependencies: + cssnano-utils: 4.0.2(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-merge-longhand@6.0.5(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + stylehacks: 6.1.1(postcss@8.5.6) + + postcss-merge-rules@6.1.1(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + caniuse-api: 3.0.0 + cssnano-utils: 4.0.2(postcss@8.5.6) + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + postcss-minify-font-values@6.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-minify-gradients@6.0.3(postcss@8.5.6): + dependencies: + colord: 2.9.3 + cssnano-utils: 4.0.2(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-minify-params@6.1.0(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + cssnano-utils: 4.0.2(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-minify-selectors@6.0.4(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + postcss-modules-extract-imports@3.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-modules-local-by-default@4.2.0(postcss@8.5.6): + dependencies: + icss-utils: 5.1.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + + postcss-modules-scope@3.2.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + + postcss-modules-values@4.0.0(postcss@8.5.6): + dependencies: + icss-utils: 5.1.0(postcss@8.5.6) + postcss: 8.5.6 + + postcss-nesting@13.0.2(postcss@8.5.6): + dependencies: + '@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.1) + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1) + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + + postcss-normalize-charset@6.0.2(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-normalize-display-values@6.0.2(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-positions@6.0.2(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-repeat-style@6.0.2(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-string@6.0.2(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-timing-functions@6.0.2(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-unicode@6.1.0(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-url@6.0.2(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-whitespace@6.0.2(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-opacity-percentage@3.0.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-ordered-values@6.0.2(postcss@8.5.6): + dependencies: + cssnano-utils: 4.0.2(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-overflow-shorthand@6.0.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-page-break@3.0.4(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-place@10.0.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-preset-env@10.6.1(postcss@8.5.6): + dependencies: + '@csstools/postcss-alpha-function': 1.0.1(postcss@8.5.6) + '@csstools/postcss-cascade-layers': 5.0.2(postcss@8.5.6) + '@csstools/postcss-color-function': 4.0.12(postcss@8.5.6) + '@csstools/postcss-color-function-display-p3-linear': 1.0.1(postcss@8.5.6) + '@csstools/postcss-color-mix-function': 3.0.12(postcss@8.5.6) + '@csstools/postcss-color-mix-variadic-function-arguments': 1.0.2(postcss@8.5.6) + '@csstools/postcss-content-alt-text': 2.0.8(postcss@8.5.6) + '@csstools/postcss-contrast-color-function': 2.0.12(postcss@8.5.6) + '@csstools/postcss-exponential-functions': 2.0.9(postcss@8.5.6) + '@csstools/postcss-font-format-keywords': 4.0.0(postcss@8.5.6) + '@csstools/postcss-gamut-mapping': 2.0.11(postcss@8.5.6) + '@csstools/postcss-gradients-interpolation-method': 5.0.12(postcss@8.5.6) + '@csstools/postcss-hwb-function': 4.0.12(postcss@8.5.6) + '@csstools/postcss-ic-unit': 4.0.4(postcss@8.5.6) + '@csstools/postcss-initial': 2.0.1(postcss@8.5.6) + '@csstools/postcss-is-pseudo-class': 5.0.3(postcss@8.5.6) + '@csstools/postcss-light-dark-function': 2.0.11(postcss@8.5.6) + '@csstools/postcss-logical-float-and-clear': 3.0.0(postcss@8.5.6) + '@csstools/postcss-logical-overflow': 2.0.0(postcss@8.5.6) + '@csstools/postcss-logical-overscroll-behavior': 2.0.0(postcss@8.5.6) + '@csstools/postcss-logical-resize': 3.0.0(postcss@8.5.6) + '@csstools/postcss-logical-viewport-units': 3.0.4(postcss@8.5.6) + '@csstools/postcss-media-minmax': 2.0.9(postcss@8.5.6) + '@csstools/postcss-media-queries-aspect-ratio-number-values': 3.0.5(postcss@8.5.6) + '@csstools/postcss-nested-calc': 4.0.0(postcss@8.5.6) + '@csstools/postcss-normalize-display-values': 4.0.1(postcss@8.5.6) + '@csstools/postcss-oklab-function': 4.0.12(postcss@8.5.6) + '@csstools/postcss-position-area-property': 1.0.0(postcss@8.5.6) + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) + '@csstools/postcss-property-rule-prelude-list': 1.0.0(postcss@8.5.6) + '@csstools/postcss-random-function': 2.0.1(postcss@8.5.6) + '@csstools/postcss-relative-color-syntax': 3.0.12(postcss@8.5.6) + '@csstools/postcss-scope-pseudo-class': 4.0.1(postcss@8.5.6) + '@csstools/postcss-sign-functions': 1.1.4(postcss@8.5.6) + '@csstools/postcss-stepped-value-functions': 4.0.9(postcss@8.5.6) + '@csstools/postcss-syntax-descriptor-syntax-production': 1.0.1(postcss@8.5.6) + '@csstools/postcss-system-ui-font-family': 1.0.0(postcss@8.5.6) + '@csstools/postcss-text-decoration-shorthand': 4.0.3(postcss@8.5.6) + '@csstools/postcss-trigonometric-functions': 4.0.9(postcss@8.5.6) + '@csstools/postcss-unset-value': 4.0.0(postcss@8.5.6) + autoprefixer: 10.4.24(postcss@8.5.6) + browserslist: 4.28.1 + css-blank-pseudo: 7.0.1(postcss@8.5.6) + css-has-pseudo: 7.0.3(postcss@8.5.6) + css-prefers-color-scheme: 10.0.0(postcss@8.5.6) + cssdb: 8.7.1 + postcss: 8.5.6 + postcss-attribute-case-insensitive: 7.0.1(postcss@8.5.6) + postcss-clamp: 4.1.0(postcss@8.5.6) + postcss-color-functional-notation: 7.0.12(postcss@8.5.6) + postcss-color-hex-alpha: 10.0.0(postcss@8.5.6) + postcss-color-rebeccapurple: 10.0.0(postcss@8.5.6) + postcss-custom-media: 11.0.6(postcss@8.5.6) + postcss-custom-properties: 14.0.6(postcss@8.5.6) + postcss-custom-selectors: 8.0.5(postcss@8.5.6) + postcss-dir-pseudo-class: 9.0.1(postcss@8.5.6) + postcss-double-position-gradients: 6.0.4(postcss@8.5.6) + postcss-focus-visible: 10.0.1(postcss@8.5.6) + postcss-focus-within: 9.0.1(postcss@8.5.6) + postcss-font-variant: 5.0.0(postcss@8.5.6) + postcss-gap-properties: 6.0.0(postcss@8.5.6) + postcss-image-set-function: 7.0.0(postcss@8.5.6) + postcss-lab-function: 7.0.12(postcss@8.5.6) + postcss-logical: 8.1.0(postcss@8.5.6) + postcss-nesting: 13.0.2(postcss@8.5.6) + postcss-opacity-percentage: 3.0.0(postcss@8.5.6) + postcss-overflow-shorthand: 6.0.0(postcss@8.5.6) + postcss-page-break: 3.0.4(postcss@8.5.6) + postcss-place: 10.0.0(postcss@8.5.6) + postcss-pseudo-class-any-link: 10.0.1(postcss@8.5.6) + postcss-replace-overflow-wrap: 4.0.0(postcss@8.5.6) + postcss-selector-not: 8.0.1(postcss@8.5.6) + + postcss-pseudo-class-any-link@10.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + + postcss-reduce-idents@6.0.3(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-reduce-initial@6.1.0(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + caniuse-api: 3.0.0 + postcss: 8.5.6 + + postcss-reduce-transforms@6.0.2(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-replace-overflow-wrap@4.0.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-selector-not@8.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-sort-media-queries@5.2.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + sort-css-media-queries: 2.2.0 + + postcss-svgo@6.0.3(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + svgo: 3.3.2 + + postcss-unique-selectors@6.0.4(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + postcss-value-parser@4.2.0: {} + + postcss-zindex@6.0.2(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-error@4.0.0: + dependencies: + lodash: 4.17.23 + renderkid: 3.0.0 + + pretty-time@1.1.0: {} + + prism-react-renderer@2.4.1(react@18.3.1): + dependencies: + '@types/prismjs': 1.26.5 + clsx: 2.1.1 + react: 18.3.1 + + prismjs@1.30.0: {} + + process-nextick-args@2.0.1: {} + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + property-information@7.1.0: {} + + proto-list@1.2.4: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + punycode@2.3.1: {} + + pupa@3.3.0: + dependencies: + escape-goat: 4.0.0 + + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.1.5: {} + + qs@6.14.1: + dependencies: + side-channel: 1.1.0 + + queue-microtask@1.2.3: {} + + quick-lru@5.1.1: {} + + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + + range-parser@1.2.0: {} + + range-parser@1.2.1: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-fast-compare@3.2.2: {} + + react-is@16.13.1: {} + + react-json-view-lite@2.5.0(react@18.3.1): + dependencies: + react: 18.3.1 + + react-loadable-ssr-addon-v5-slorber@1.0.1(@docusaurus/react-loadable@6.0.0(react@18.3.1))(webpack@5.105.1): + dependencies: + '@babel/runtime': 7.28.6 + react-loadable: '@docusaurus/react-loadable@6.0.0(react@18.3.1)' + webpack: 5.105.1 + + react-markdown@10.1.0(@types/react@19.2.13)(react@18.3.1): + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/react': 19.2.13 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 18.3.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + optional: true + + react-remove-scroll-bar@2.3.8(@types/react@19.2.13)(react@18.3.1): + dependencies: + react: 18.3.1 + react-style-singleton: 2.2.3(@types/react@19.2.13)(react@18.3.1) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.13 + optional: true + + react-remove-scroll@2.7.2(@types/react@19.2.13)(react@18.3.1): + dependencies: + react: 18.3.1 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.13)(react@18.3.1) + react-style-singleton: 2.2.3(@types/react@19.2.13)(react@18.3.1) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.13)(react@18.3.1) + use-sidecar: 1.1.3(@types/react@19.2.13)(react@18.3.1) + optionalDependencies: + '@types/react': 19.2.13 + optional: true + + react-router-config@5.1.1(react-router@5.3.4(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.28.6 + react: 18.3.1 + react-router: 5.3.4(react@18.3.1) + + react-router-dom@5.3.4(react@18.3.1): + dependencies: + '@babel/runtime': 7.28.6 + history: 4.10.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.3.1 + react-router: 5.3.4(react@18.3.1) + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + + react-router@5.3.4(react@18.3.1): + dependencies: + '@babel/runtime': 7.28.6 + history: 4.10.1 + hoist-non-react-statics: 3.3.2 + loose-envify: 1.4.0 + path-to-regexp: 1.9.0 + prop-types: 15.8.1 + react: 18.3.1 + react-is: 16.13.1 + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + + react-style-singleton@2.2.3(@types/react@19.2.13)(react@18.3.1): + dependencies: + get-nonce: 1.0.1 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.13 + optional: true + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + recma-build-jsx@1.0.0: + dependencies: + '@types/estree': 1.0.8 + estree-util-build-jsx: 3.0.1 + vfile: 6.0.3 + + recma-jsx@1.0.1(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + estree-util-to-js: 2.0.0 + recma-parse: 1.0.0 + recma-stringify: 1.0.0 + unified: 11.0.5 + + recma-parse@1.0.0: + dependencies: + '@types/estree': 1.0.8 + esast-util-from-js: 2.0.1 + unified: 11.0.5 + vfile: 6.0.3 + + recma-stringify@1.0.0: + dependencies: + '@types/estree': 1.0.8 + estree-util-to-js: 2.0.0 + unified: 11.0.5 + vfile: 6.0.3 + + reflect-metadata@0.2.2: {} + + regenerate-unicode-properties@10.2.2: + dependencies: + regenerate: 1.4.2 + + regenerate@1.4.2: {} + + regexpu-core@6.4.0: + dependencies: + regenerate: 1.4.2 + regenerate-unicode-properties: 10.2.2 + regjsgen: 0.8.0 + regjsparser: 0.13.0 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.2.1 + + registry-auth-token@5.1.1: + dependencies: + '@pnpm/npm-conf': 3.0.2 + + registry-url@6.0.1: + dependencies: + rc: 1.2.8 + + regjsgen@0.8.0: {} + + regjsparser@0.13.0: + dependencies: + jsesc: 3.1.0 + + rehype-highlight@7.0.2: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-text: 4.0.2 + lowlight: 3.3.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + optional: true + + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-recma@1.0.0: + dependencies: + '@types/estree': 1.0.8 + '@types/hast': 3.0.4 + hast-util-to-estree: 3.1.3 + transitivePeerDependencies: + - supports-color + + relateurl@0.2.7: {} + + remark-directive@3.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-directive: 3.1.0 + micromark-extension-directive: 3.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-emoji@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + emoticon: 4.1.0 + mdast-util-find-and-replace: 3.0.2 + node-emoji: 2.2.0 + unified: 11.0.5 + + remark-frontmatter@5.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-frontmatter: 2.0.1 + micromark-extension-frontmatter: 2.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-mdx@3.1.1: + dependencies: + mdast-util-mdx: 3.0.0 + micromark-extension-mdxjs: 3.0.0 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.2 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + renderkid@3.0.0: + dependencies: + css-select: 4.3.0 + dom-converter: 0.2.0 + htmlparser2: 6.1.0 + lodash: 4.17.23 + strip-ansi: 6.0.1 + + repeat-string@1.6.1: {} + + require-from-string@2.0.2: {} + + require-like@0.1.2: {} + + requires-port@1.0.0: {} + + resolve-alpn@1.2.1: {} + + resolve-from@4.0.0: {} + + resolve-pathname@3.0.0: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + responselike@3.0.0: + dependencies: + lowercase-keys: 3.0.0 + + retry@0.13.1: {} + + reusify@1.1.0: {} + + rtlcss@4.3.0: + dependencies: + escalade: 3.2.0 + picocolors: 1.1.1 + postcss: 8.5.6 + strip-json-comments: 3.1.1 + + run-applescript@7.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + sax@1.4.4: {} + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + schema-dts@1.1.5: {} + + schema-utils@3.3.0: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 6.12.6 + ajv-keywords: 3.5.2(ajv@6.12.6) + + schema-utils@4.3.3: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.17.1 + ajv-formats: 2.1.1(ajv@8.17.1) + ajv-keywords: 5.1.0(ajv@8.17.1) + + search-insights@2.17.3: {} + + section-matter@1.0.0: + dependencies: + extend-shallow: 2.0.1 + kind-of: 6.0.3 + + select-hose@2.0.0: {} + + selfsigned@5.5.0: + dependencies: + '@peculiar/x509': 1.14.3 + pkijs: 3.3.3 + + semver-diff@4.0.0: + dependencies: + semver: 7.7.4 + + semver@6.3.1: {} + + semver@7.7.4: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serialize-javascript@6.0.2: + dependencies: + randombytes: 2.1.0 + + serve-handler@6.1.6: + dependencies: + bytes: 3.0.0 + content-disposition: 0.5.2 + mime-types: 2.1.18 + minimatch: 3.1.2 + path-is-inside: 1.0.2 + path-to-regexp: 3.3.0 + range-parser: 1.2.0 + + serve-index@1.9.2: + dependencies: + accepts: 1.3.8 + batch: 0.6.1 + debug: 2.6.9 + escape-html: 1.0.3 + http-errors: 1.8.1 + mime-types: 2.1.35 + parseurl: 1.3.3 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + setprototypeof@1.2.0: {} + + shallow-clone@3.0.1: + dependencies: + kind-of: 6.0.3 + + shallowequal@1.1.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.8.3: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + sirv@2.0.4: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + sisteransi@1.0.5: {} + + sitemap@7.1.2: + dependencies: + '@types/node': 17.0.45 + '@types/sax': 1.2.7 + arg: 5.0.2 + sax: 1.4.4 + + skin-tone@2.0.0: + dependencies: + unicode-emoji-modifier-base: 1.0.0 + + slash@3.0.0: {} + + slash@4.0.0: {} + + snake-case@3.0.4: + dependencies: + dot-case: 3.0.4 + tslib: 2.8.1 + + sockjs@0.3.24: + dependencies: + faye-websocket: 0.11.4 + uuid: 8.3.2 + websocket-driver: 0.7.4 + + sort-css-media-queries@2.2.0: {} + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + source-map@0.7.6: {} + + space-separated-tokens@2.0.2: {} + + spdy-transport@3.0.0: + dependencies: + debug: 4.4.3 + detect-node: 2.1.0 + hpack.js: 2.1.6 + obuf: 1.1.2 + readable-stream: 3.6.2 + wbuf: 1.7.3 + transitivePeerDependencies: + - supports-color + + spdy@4.0.2: + dependencies: + debug: 4.4.3 + handle-thing: 2.0.1 + http-deceiver: 1.2.7 + select-hose: 2.0.0 + spdy-transport: 3.0.0 + transitivePeerDependencies: + - supports-color + + sprintf-js@1.0.3: {} + + srcset@4.0.0: {} + + statuses@1.5.0: {} + + statuses@2.0.2: {} + + std-env@3.10.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + stringify-object@3.3.0: + dependencies: + get-own-enumerable-property-symbols: 3.0.2 + is-obj: 1.0.1 + is-regexp: 1.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + + strip-bom-string@1.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-json-comments@2.0.1: {} + + strip-json-comments@3.1.1: {} + + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + + stylehacks@6.1.1(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + svg-parser@2.0.4: {} + + svgo@3.3.2: + dependencies: + '@trysound/sax': 0.2.0 + commander: 7.2.0 + css-select: 5.2.2 + css-tree: 2.3.1 + css-what: 6.2.2 + csso: 5.0.5 + picocolors: 1.1.1 + + tapable@2.3.0: {} + + terser-webpack-plugin@5.3.16(webpack@5.105.1): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + serialize-javascript: 6.0.2 + terser: 5.46.0 + webpack: 5.105.1 + + terser@5.46.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.15.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + thingies@2.5.0(tslib@2.8.1): + dependencies: + tslib: 2.8.1 + + thunky@1.1.0: {} + + tiny-invariant@1.3.3: {} + + tiny-warning@1.0.3: {} + + tinypool@1.1.1: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + totalist@3.0.1: {} + + tree-dump@1.1.0(tslib@2.8.1): + dependencies: + tslib: 2.8.1 + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + tslib@1.14.1: {} + + tslib@2.8.1: {} + + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + + type-fest@0.21.3: {} + + type-fest@1.4.0: {} + + type-fest@2.19.0: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typedarray-to-buffer@3.1.5: + dependencies: + is-typedarray: 1.0.0 + + typescript@5.9.3: {} + + undici-types@7.16.0: {} + + undici@7.21.0: {} + + unicode-canonical-property-names-ecmascript@2.0.1: {} + + unicode-emoji-modifier-base@1.0.0: {} + + unicode-match-property-ecmascript@2.0.0: + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.1 + unicode-property-aliases-ecmascript: 2.2.0 + + unicode-match-property-value-ecmascript@2.2.1: {} + + unicode-property-aliases-ecmascript@2.2.0: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unique-string@3.0.0: + dependencies: + crypto-random-string: 4.0.0 + + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + optional: true + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position-from-estree@2.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + universalify@2.0.1: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + update-notifier@6.0.2: + dependencies: + boxen: 7.1.1 + chalk: 5.6.2 + configstore: 6.0.0 + has-yarn: 3.0.0 + import-lazy: 4.0.0 + is-ci: 3.0.1 + is-installed-globally: 0.4.0 + is-npm: 6.1.0 + is-yarn-global: 0.4.1 + latest-version: 7.0.0 + pupa: 3.3.0 + semver: 7.7.4 + semver-diff: 4.0.0 + xdg-basedir: 5.1.0 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + url-loader@4.1.1(file-loader@6.2.0(webpack@5.105.1))(webpack@5.105.1): + dependencies: + loader-utils: 2.0.4 + mime-types: 2.1.35 + schema-utils: 3.3.0 + webpack: 5.105.1 + optionalDependencies: + file-loader: 6.2.0(webpack@5.105.1) + + use-callback-ref@1.3.3(@types/react@19.2.13)(react@18.3.1): + dependencies: + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.13 + optional: true + + use-sidecar@1.1.3(@types/react@19.2.13)(react@18.3.1): + dependencies: + detect-node-es: 1.1.0 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.13 + optional: true + + util-deprecate@1.0.2: {} + + utila@0.4.0: {} + + utility-types@3.11.0: {} + + utils-merge@1.0.1: {} + + uuid@8.3.2: {} + + value-equal@1.0.1: {} + + vary@1.1.2: {} + + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + watchpack@2.5.1: + dependencies: + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + + wbuf@1.7.3: + dependencies: + minimalistic-assert: 1.0.1 + + web-namespaces@2.0.1: {} + + webpack-bundle-analyzer@4.10.2: + dependencies: + '@discoveryjs/json-ext': 0.5.7 + acorn: 8.15.0 + acorn-walk: 8.3.4 + commander: 7.2.0 + debounce: 1.2.1 + escape-string-regexp: 4.0.0 + gzip-size: 6.0.0 + html-escaper: 2.0.2 + opener: 1.5.2 + picocolors: 1.1.1 + sirv: 2.0.4 + ws: 7.5.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + webpack-dev-middleware@7.4.5(tslib@2.8.1)(webpack@5.105.1): + dependencies: + colorette: 2.0.20 + memfs: 4.56.10(tslib@2.8.1) + mime-types: 3.0.2 + on-finished: 2.4.1 + range-parser: 1.2.1 + schema-utils: 4.3.3 + optionalDependencies: + webpack: 5.105.1 + transitivePeerDependencies: + - tslib + + webpack-dev-server@5.2.3(debug@4.4.3)(tslib@2.8.1)(webpack@5.105.1): + dependencies: + '@types/bonjour': 3.5.13 + '@types/connect-history-api-fallback': 1.5.4 + '@types/express': 4.17.25 + '@types/express-serve-static-core': 4.19.8 + '@types/serve-index': 1.9.4 + '@types/serve-static': 1.15.10 + '@types/sockjs': 0.3.36 + '@types/ws': 8.18.1 + ansi-html-community: 0.0.8 + bonjour-service: 1.3.0 + chokidar: 3.6.0 + colorette: 2.0.20 + compression: 1.8.1 + connect-history-api-fallback: 2.0.0 + express: 4.22.1 + graceful-fs: 4.2.11 + http-proxy-middleware: 2.0.9(@types/express@4.17.25)(debug@4.4.3) + ipaddr.js: 2.3.0 + launch-editor: 2.12.0 + open: 10.2.0 + p-retry: 6.2.1 + schema-utils: 4.3.3 + selfsigned: 5.5.0 + serve-index: 1.9.2 + sockjs: 0.3.24 + spdy: 4.0.2 + webpack-dev-middleware: 7.4.5(tslib@2.8.1)(webpack@5.105.1) + ws: 8.19.0 + optionalDependencies: + webpack: 5.105.1 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - tslib + - utf-8-validate + + webpack-merge@5.10.0: + dependencies: + clone-deep: 4.0.1 + flat: 5.0.2 + wildcard: 2.0.1 + + webpack-merge@6.0.1: + dependencies: + clone-deep: 4.0.1 + flat: 5.0.2 + wildcard: 2.0.1 + + webpack-sources@3.3.3: {} + + webpack@5.105.1: + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.15.0 + acorn-import-phases: 1.0.4(acorn@8.15.0) + browserslist: 4.28.1 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.19.0 + es-module-lexer: 2.0.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.1 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.0 + terser-webpack-plugin: 5.3.16(webpack@5.105.1) + watchpack: 2.5.1 + webpack-sources: 3.3.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + + webpackbar@6.0.1(webpack@5.105.1): + dependencies: + ansi-escapes: 4.3.2 + chalk: 4.1.2 + consola: 3.4.2 + figures: 3.2.0 + markdown-table: 2.0.0 + pretty-time: 1.1.0 + std-env: 3.10.0 + webpack: 5.105.1 + wrap-ansi: 7.0.0 + + websocket-driver@0.7.4: + dependencies: + http-parser-js: 0.5.10 + safe-buffer: 5.2.1 + websocket-extensions: 0.1.4 + + websocket-extensions@0.1.4: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + widest-line@4.0.1: + dependencies: + string-width: 5.1.2 + + wildcard@2.0.1: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + + write-file-atomic@3.0.3: + dependencies: + imurmurhash: 0.1.4 + is-typedarray: 1.0.0 + signal-exit: 3.0.7 + typedarray-to-buffer: 3.1.5 + + ws@7.5.10: {} + + ws@8.19.0: {} + + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.0 + + xdg-basedir@5.1.0: {} + + xml-js@1.6.11: + dependencies: + sax: 1.4.4 + + yallist@3.1.1: {} + + yocto-queue@1.2.2: {} + + zwitch@2.0.4: {} diff --git a/docs-site/sidebars.ts b/docs-site/sidebars.ts new file mode 100644 index 0000000..0ebfa55 --- /dev/null +++ b/docs-site/sidebars.ts @@ -0,0 +1,53 @@ +import type { SidebarsConfig } from '@docusaurus/plugin-content-docs'; + +const sidebars: SidebarsConfig = { + tutorialSidebar: [ + 'intro', + { + type: 'category', + label: 'Start Here', + items: ['getting-started/docker-quick-start', 'getting-started/local-development'], + }, + { + type: 'category', + label: 'Reference', + items: [ + 'guides/environment-variables', + 'reference/stack', + ], + }, + { + type: 'category', + label: 'Configure', + items: [ + 'guides/tts-providers', + { + type: 'doc', + id: 'guides/configuration', + label: 'Auth (Reccomended)', + }, + 'guides/tts-rate-limiting', + 'operations/database-and-migrations', + 'guides/storage-and-blob-behavior', + ], + }, + { + type: 'category', + label: 'Integrations', + items: [ + 'integrations/kokoro-fastapi', + 'integrations/orpheus-fastapi', + 'integrations/deepinfra', + 'integrations/openai', + 'integrations/custom-openai', + ], + }, + { + type: 'category', + label: 'Project', + items: ['community/support', 'community/acknowledgements', 'community/license'], + }, + ], +}; + +export default sidebars; diff --git a/docs-site/static/CNAME b/docs-site/static/CNAME new file mode 100644 index 0000000..f2f08f2 --- /dev/null +++ b/docs-site/static/CNAME @@ -0,0 +1 @@ +docs.openreader.richardr.dev diff --git a/docs-site/static/robots.txt b/docs-site/static/robots.txt new file mode 100644 index 0000000..c2a49f4 --- /dev/null +++ b/docs-site/static/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / diff --git a/docs-site/tsconfig.json b/docs-site/tsconfig.json new file mode 100644 index 0000000..d250afa --- /dev/null +++ b/docs-site/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "@docusaurus/tsconfig", + "compilerOptions": { + "baseUrl": "." + } +} diff --git a/package.json b/package.json index dd95738..d1f8bfc 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,13 @@ "lint": "next lint", "test": "playwright test", "migrate": "node drizzle/scripts/migrate.mjs", - "generate": "node drizzle/scripts/generate.mjs" + "generate": "node drizzle/scripts/generate.mjs", + "docs:init": "pnpm --dir docs-site install", + "docs:dev": "pnpm --dir docs-site start", + "docs:build": "pnpm --dir docs-site build", + "docs:serve": "pnpm --dir docs-site serve", + "docs:version": "pnpm --dir docs-site docusaurus docs:version", + "docs:clear": "pnpm --dir docs-site clear" }, "dependencies": { "@aws-sdk/client-s3": "^3.985.0", diff --git a/src/app/api/rate-limit/status/route.ts b/src/app/api/rate-limit/status/route.ts index 8bd1c04..4274a88 100644 --- a/src/app/api/rate-limit/status/route.ts +++ b/src/app/api/rate-limit/status/route.ts @@ -1,6 +1,6 @@ import { NextResponse, type NextRequest } from 'next/server'; import { auth } from '@/lib/server/auth'; -import { rateLimiter, RATE_LIMITS } from '@/lib/server/rate-limiter'; +import { rateLimiter, RATE_LIMITS, isTtsRateLimitEnabled } from '@/lib/server/rate-limiter'; import { headers } from 'next/headers'; import { isAuthEnabled } from '@/lib/server/auth-config'; import { getClientIp } from '@/lib/server/request-ip'; @@ -18,6 +18,8 @@ function getUtcResetTimeIso(): string { export async function GET(req: NextRequest) { try { + const ttsRateLimitEnabled = isTtsRateLimitEnabled(); + // If auth is not enabled, return unlimited status if (!isAuthEnabled() || !auth) { const resetTime = getUtcResetTimeIso(); @@ -45,8 +47,8 @@ export async function GET(req: NextRequest) { return NextResponse.json({ allowed: true, currentCount: 0, - limit: RATE_LIMITS.ANONYMOUS, - remainingChars: RATE_LIMITS.ANONYMOUS, + limit: ttsRateLimitEnabled ? RATE_LIMITS.ANONYMOUS : Number.MAX_SAFE_INTEGER, + remainingChars: ttsRateLimitEnabled ? RATE_LIMITS.ANONYMOUS : Number.MAX_SAFE_INTEGER, resetTime, userType: 'unauthenticated', authEnabled: true @@ -56,7 +58,7 @@ export async function GET(req: NextRequest) { const isAnonymous = Boolean(session.user.isAnonymous); const ip = getClientIp(req); - const device = isAnonymous ? getOrCreateDeviceId(req) : null; + const device = isAnonymous && ttsRateLimitEnabled ? getOrCreateDeviceId(req) : null; const result = await rateLimiter.getCurrentUsage( { diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index ad64150..8fc1453 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -8,7 +8,7 @@ import type { TTSRequestPayload } from '@/types/client'; import type { TTSError, TTSAudioBuffer } from '@/types/tts'; import { headers } from 'next/headers'; import { auth } from '@/lib/server/auth'; -import { rateLimiter, RATE_LIMITS } from '@/lib/server/rate-limiter'; +import { rateLimiter, RATE_LIMITS, isTtsRateLimitEnabled } from '@/lib/server/rate-limiter'; import { isAuthEnabled } from '@/lib/server/auth-config'; import { getClientIp } from '@/lib/server/request-ip'; import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/device-id'; @@ -142,7 +142,7 @@ export async function POST(req: NextRequest) { return NextResponse.json(errorBody, { status: 400 }); } - // Auth and rate limiting check (only when auth is enabled) + // Auth and TTS char rate limiting check (only when auth is enabled) let didCreateDeviceIdCookie = false; let deviceIdToSet: string | null = null; @@ -159,62 +159,63 @@ export async function POST(req: NextRequest) { } const isAnonymous = Boolean(session.user.isAnonymous); - const charCount = text.length; - - const ip = getClientIp(req); - const device = isAnonymous ? getOrCreateDeviceId(req) : null; - if (device?.didCreate) { - didCreateDeviceIdCookie = true; - deviceIdToSet = device.deviceId; - } - - // Check rate limit - const rateLimitResult = await rateLimiter.checkAndIncrementLimit( - { id: session.user.id, isAnonymous }, - charCount, - { - deviceId: device?.deviceId ?? null, - ip, + if (isTtsRateLimitEnabled()) { + const charCount = text.length; + const ip = getClientIp(req); + const device = isAnonymous ? getOrCreateDeviceId(req) : null; + if (device?.didCreate) { + didCreateDeviceIdCookie = true; + deviceIdToSet = device.deviceId; } - ); - if (!rateLimitResult.allowed) { - const resetTime = rateLimitResult.resetTime.toISOString(); - const retryAfterSeconds = Math.max( - 0, - Math.ceil((rateLimitResult.resetTime.getTime() - Date.now()) / 1000) + // Check rate limit + const rateLimitResult = await rateLimiter.checkAndIncrementLimit( + { id: session.user.id, isAnonymous }, + charCount, + { + deviceId: device?.deviceId ?? null, + ip, + } ); - const problem: ProblemDetails = { - type: PROBLEM_TYPES.dailyQuotaExceeded, - title: 'Daily quota exceeded', - status: 429, - detail: 'Daily character limit exceeded', - code: 'USER_DAILY_QUOTA_EXCEEDED', - currentCount: rateLimitResult.currentCount, - limit: rateLimitResult.limit, - remainingChars: rateLimitResult.remainingChars, - resetTime, - userType: isAnonymous ? 'anonymous' : 'authenticated', - upgradeHint: isAnonymous - ? `Sign up to increase your limit from ${formatLimitForHint(RATE_LIMITS.ANONYMOUS)} to ${formatLimitForHint(RATE_LIMITS.AUTHENTICATED)} characters per day` - : undefined, - instance: req.nextUrl.pathname, - }; + if (!rateLimitResult.allowed) { + const resetTime = rateLimitResult.resetTime.toISOString(); + const retryAfterSeconds = Math.max( + 0, + Math.ceil((rateLimitResult.resetTime.getTime() - Date.now()) / 1000) + ); - const response = new NextResponse(JSON.stringify(problem), { - status: 429, - headers: { - 'Content-Type': 'application/problem+json', - 'Retry-After': String(retryAfterSeconds), - }, - }); + const problem: ProblemDetails = { + type: PROBLEM_TYPES.dailyQuotaExceeded, + title: 'Daily quota exceeded', + status: 429, + detail: 'Daily character limit exceeded', + code: 'USER_DAILY_QUOTA_EXCEEDED', + currentCount: rateLimitResult.currentCount, + limit: rateLimitResult.limit, + remainingChars: rateLimitResult.remainingChars, + resetTime, + userType: isAnonymous ? 'anonymous' : 'authenticated', + upgradeHint: isAnonymous + ? `Sign up to increase your limit from ${formatLimitForHint(RATE_LIMITS.ANONYMOUS)} to ${formatLimitForHint(RATE_LIMITS.AUTHENTICATED)} characters per day` + : undefined, + instance: req.nextUrl.pathname, + }; - if (didCreateDeviceIdCookie && deviceIdToSet) { - setDeviceIdCookie(response, deviceIdToSet); + const response = new NextResponse(JSON.stringify(problem), { + status: 429, + headers: { + 'Content-Type': 'application/problem+json', + 'Retry-After': String(retryAfterSeconds), + }, + }); + + if (didCreateDeviceIdCookie && deviceIdToSet) { + setDeviceIdCookie(response, deviceIdToSet); + } + + return response; } - - return response; } } diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index e483ff1..f7fe9da 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -41,6 +41,15 @@ function getTrustedOrigins(): string[] { return Array.from(origins); } +function envFlagEnabled(name: string, defaultValue: boolean): boolean { + const raw = process.env[name]; + if (!raw || raw.trim() === '') return defaultValue; + const normalized = raw.trim().toLowerCase(); + if (normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on') return true; + if (normalized === '0' || normalized === 'false' || normalized === 'no' || normalized === 'off') return false; + return defaultValue; +} + const createAuth = () => betterAuth({ // eslint-disable-next-line @typescript-eslint/no-explicit-any database: drizzleAdapter(db as any, { @@ -65,9 +74,9 @@ const createAuth = () => betterAuth({ }, }, rateLimit: { - // Disable rate limiting when running tests to support parallel test workers - // In production, better-auth's default rate limiting applies - enabled: process.env.DISABLE_AUTH_RATE_LIMIT !== 'true', + // Better Auth built-in rate limiting is enabled by default. + // Set DISABLE_AUTH_RATE_LIMIT=true to disable it. + enabled: !envFlagEnabled('DISABLE_AUTH_RATE_LIMIT', false), }, socialProviders: { ...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET && { diff --git a/src/lib/server/library.ts b/src/lib/server/library.ts index 25cf35b..cf23bef 100644 --- a/src/lib/server/library.ts +++ b/src/lib/server/library.ts @@ -4,7 +4,7 @@ export const DOCSTORE_DIR = path.join(process.cwd(), 'docstore'); export const DEFAULT_LIBRARY_DIR = path.join(DOCSTORE_DIR, 'library'); export function parseLibraryRoots(): string[] { - const raw = process.env.OPENREADER_LIBRARY_DIRS ?? process.env.OPENREADER_LIBRARY_DIR ?? ''; + const raw = process.env.IMPORT_LIBRARY_DIRS ?? process.env.IMPORT_LIBRARY_DIR ?? ''; const roots = raw .split(/[,:;]/g) diff --git a/src/lib/server/rate-limiter.ts b/src/lib/server/rate-limiter.ts index a041ce4..af51163 100644 --- a/src/lib/server/rate-limiter.ts +++ b/src/lib/server/rate-limiter.ts @@ -3,14 +3,40 @@ import { userTtsChars } from '@/db/schema'; import { isAuthEnabled } from '@/lib/server/auth-config'; import { eq, and, lt, sql } from 'drizzle-orm'; +function readPositiveIntEnv(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw || raw.trim() === '') return fallback; + + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) { + console.warn(`[rate-limiter] Invalid ${name}=${raw}; using default ${fallback}`); + return fallback; + } + + return Math.floor(parsed); +} + +function readBooleanEnv(name: string, fallback: boolean): boolean { + const raw = process.env[name]; + if (!raw || raw.trim() === '') return fallback; + const normalized = raw.trim().toLowerCase(); + if (normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on') return true; + if (normalized === '0' || normalized === 'false' || normalized === 'no' || normalized === 'off') return false; + return fallback; +} + +export function isTtsRateLimitEnabled(): boolean { + return readBooleanEnv('TTS_ENABLE_RATE_LIMIT', false); +} + // Rate limits configuration - character counts per day export const RATE_LIMITS = { - ANONYMOUS: 50_000, // 50K characters per day for anonymous users - AUTHENTICATED: 500_000, // 500K characters per day for authenticated users + ANONYMOUS: readPositiveIntEnv('TTS_DAILY_LIMIT_ANONYMOUS', 50_000), + AUTHENTICATED: readPositiveIntEnv('TTS_DAILY_LIMIT_AUTHENTICATED', 500_000), // IP-based backstop limits to make it harder to reset limits by creating new accounts // or clearing storage/cookies - IP_ANONYMOUS: Number(process.env.TTS_IP_DAILY_LIMIT_ANONYMOUS || 100_000), - IP_AUTHENTICATED: Number(process.env.TTS_IP_DAILY_LIMIT_AUTHENTICATED || 1_000_000), + IP_ANONYMOUS: readPositiveIntEnv('TTS_IP_DAILY_LIMIT_ANONYMOUS', 100_000), + IP_AUTHENTICATED: readPositiveIntEnv('TTS_IP_DAILY_LIMIT_AUTHENTICATED', 1_000_000), } as const; // Helper to ensure DB is strictly typed when we know it exists @@ -126,7 +152,7 @@ export class RateLimiter { * Check if a user can use TTS and increment their char count if allowed */ async checkAndIncrementLimit(user: UserInfo, charCount: number, backstops?: RateLimitBackstops): Promise { - if (!isAuthEnabled()) { + if (!isAuthEnabled() || !isTtsRateLimitEnabled()) { return { allowed: true, currentCount: 0, @@ -227,7 +253,7 @@ export class RateLimiter { * Get current usage for a user without incrementing */ async getCurrentUsage(user: UserInfo, backstops?: RateLimitBackstops): Promise { - if (!isAuthEnabled()) { + if (!isAuthEnabled() || !isTtsRateLimitEnabled()) { return { allowed: true, currentCount: 0, @@ -283,7 +309,7 @@ export class RateLimiter { * Transfer char counts when anonymous user creates an account */ async transferAnonymousUsage(anonymousUserId: string, authenticatedUserId: string): Promise { - if (!isAuthEnabled()) return; + if (!isAuthEnabled() || !isTtsRateLimitEnabled()) return; const today = new Date().toISOString().split('T')[0]; const dateValue = today as unknown as UserTtsCharsDateValue; diff --git a/tsconfig.json b/tsconfig.json index c133409..3dfc4fb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,5 +23,5 @@ } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "docs-site", "docs-site/**"] } From 91bcf232e1a06104920e446d9fd58098f6211144 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 10 Feb 2026 15:15:44 -0700 Subject: [PATCH 18/55] fix(docs): simplify docs versioning workflow --- .github/workflows/docs-version-on-tag.yml | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/.github/workflows/docs-version-on-tag.yml b/.github/workflows/docs-version-on-tag.yml index f00da17..1e3098d 100644 --- a/.github/workflows/docs-version-on-tag.yml +++ b/.github/workflows/docs-version-on-tag.yml @@ -1,4 +1,4 @@ -name: Docs Version on Tag +name: Version Docs on Tag on: push: @@ -46,32 +46,24 @@ jobs: pnpm --dir docs-site docusaurus docs:version "${VERSION}" fi - - name: Set default docs version - env: - VERSION: ${{ github.ref_name }} - run: | - set -euo pipefail - node -e "const fs=require('fs'); const file='docs-site/docusaurus.config.ts'; const source=fs.readFileSync(file,'utf8'); const updated=source.replace(/lastVersion:\s*'[^']*',/, \"lastVersion: '\" + process.env.VERSION + \"',\"); if (source===updated) { throw new Error('Unable to update lastVersion in docusaurus.config.ts'); } fs.writeFileSync(file, updated);" - - name: Build docs run: pnpm --dir docs-site build - name: Create docs version PR uses: peter-evans/create-pull-request@v6 with: - commit-message: docs: snapshot ${{ github.ref_name }} - title: docs: snapshot ${{ github.ref_name }} + commit-message: "docs: snapshot ${{ github.ref_name }}" + title: "docs: snapshot ${{ github.ref_name }}" body: | Automated docs version snapshot for `${{ github.ref_name }}`. - Ran `docusaurus docs:version` - - Updated `lastVersion` in `docs-site/docusaurus.config.ts` + - Added versioned docs/sidebar snapshot files branch: docs/version-${{ github.ref_name }} base: main delete-branch: true labels: docs,automation add-paths: | - docs-site/docusaurus.config.ts docs-site/versions.json docs-site/versioned_docs/** docs-site/versioned_sidebars/** From 9b9206f50d8a30274c86ebaff2a2e799f4921869 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 11 Feb 2026 02:44:34 -0700 Subject: [PATCH 19/55] refactor(audiobooks): migrate audiobook pipeline to object storage This commit restructures the audiobook generation and serving layer to rely exclusively on S3-compatible blob storage, removing the dependency on local filesystem paths. - Bundle `ffmpeg` and `ffprobe` binaries via npm to ensure portability across environments. - Update API routes to stream audio directly from blob storage instead of local disk. - Remove legacy migration endpoints and filesystem-based indexing logic. - Add startup scripts to facilitate the transition from local to remote storage. BREAKING CHANGE: Audiobook functionality is now strictly dependent on S3 configuration. The previous filesystem-based storage method has been removed. --- .env.example | 33 +- Dockerfile | 4 +- README.md | 3 +- .../{guides => configure}/configuration.md | 4 +- .../database-and-migrations.md | 28 +- .../storage-and-blob-behavior.md | 9 +- .../{guides => configure}/tts-providers.md | 2 +- .../tts-rate-limiting.md | 2 +- docs-site/docs/integrations/custom-openai.md | 2 +- docs-site/docs/integrations/deepinfra.md | 2 +- docs-site/docs/integrations/openai.md | 2 +- .../docs/integrations/orpheus-fastapi.md | 2 +- docs-site/docs/intro.md | 14 +- .../acknowledgements.md | 0 .../docs/{community => project}/license.md | 0 .../docs/{community => project}/support.md | 0 .../environment-variables.md | 76 +- .../docker-quick-start.md | 8 +- .../local-development.md | 16 +- .../docs/start-here/vercel-deployment.md | 78 ++ docs-site/docusaurus.config.ts | 3 +- docs-site/sidebars.ts | 16 +- docs-site/static/favicon.ico | Bin 0 -> 15086 bytes next.config.ts | 12 + package.json | 48 +- pnpm-lock.yaml | 1004 ++++++++++------- scripts/migrate-fs-v2.mjs | 919 +++++++++++++++ scripts/openreader-entrypoint.mjs | 45 +- src/app/api/audiobook/chapter/route.ts | 235 ++-- src/app/api/audiobook/route.ts | 759 ++++++------- src/app/api/audiobook/status/route.ts | 143 ++- src/app/api/migrations/v1/route.ts | 144 --- src/app/api/migrations/v2/route.ts | 275 ----- src/app/api/user/claim/route.ts | 41 +- src/app/api/whisper/route.ts | 3 +- src/app/epub/[id]/page.tsx | 6 +- src/app/pdf/[id]/page.tsx | 6 +- src/components/DocumentHeaderMenu.tsx | 8 +- src/components/DocumentSettings.tsx | 14 +- src/contexts/ConfigContext.tsx | 54 +- src/lib/server/audiobook.ts | 3 +- src/lib/server/audiobooks-blobstore.ts | 251 +++++ src/lib/server/claim-data.ts | 220 ++-- src/lib/server/db-indexing.ts | 186 --- src/lib/server/docstore.ts | 648 +---------- src/lib/server/ffmpeg-bin.ts | 47 + tests/global-teardown.ts | 103 +- tests/unit/audiobooks-blobstore.spec.ts | 52 + vercel.json | 11 + 49 files changed, 2968 insertions(+), 2573 deletions(-) rename docs-site/docs/{guides => configure}/configuration.md (82%) rename docs-site/docs/{operations => configure}/database-and-migrations.md (53%) rename docs-site/docs/{guides => configure}/storage-and-blob-behavior.md (75%) rename docs-site/docs/{guides => configure}/tts-providers.md (94%) rename docs-site/docs/{guides => configure}/tts-rate-limiting.md (94%) rename docs-site/docs/{community => project}/acknowledgements.md (100%) rename docs-site/docs/{community => project}/license.md (100%) rename docs-site/docs/{community => project}/support.md (100%) rename docs-site/docs/{guides => reference}/environment-variables.md (79%) rename docs-site/docs/{getting-started => start-here}/docker-quick-start.md (85%) rename docs-site/docs/{getting-started => start-here}/local-development.md (82%) create mode 100644 docs-site/docs/start-here/vercel-deployment.md create mode 100644 docs-site/static/favicon.ico create mode 100644 scripts/migrate-fs-v2.mjs delete mode 100644 src/app/api/migrations/v1/route.ts delete mode 100644 src/app/api/migrations/v2/route.ts create mode 100644 src/lib/server/audiobooks-blobstore.ts delete mode 100644 src/lib/server/db-indexing.ts create mode 100644 src/lib/server/ffmpeg-bin.ts create mode 100644 tests/unit/audiobooks-blobstore.spec.ts create mode 100644 vercel.json diff --git a/.env.example b/.env.example index dba84d8..4e1a1ad 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,7 @@ -# Determins the environment mode, `production` limits many features -NEXT_PUBLIC_NODE_ENV=development # Not availble in Docker containers (due to being build-time only) +# (Optional) Client feature flags (does not work in Docker containers due to being build-time only) +NEXT_PUBLIC_NODE_ENV=development +NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT= +NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT= # Local / OpenAI TTS API Configuration (default) # Suggest using https://github.com/remsky/Kokoro-FastAPI @@ -23,9 +25,6 @@ API_KEY=api_key_optional # TTS_IP_DAILY_LIMIT_ANONYMOUS=100000 # TTS_IP_DAILY_LIMIT_AUTHENTICATED=1000000 -# Path to your local whisper.cpp CLI binary for STT timestamp generation -WHISPER_CPP_BIN=/whisper.cpp/build/bin/whisper-cli - # Auth configuration (recommended for contributors and public instances) # (Optional) Auth is only enabled when AUTH_SECRET and BASE_URL are set BASE_URL=http://localhost:3003 # Externally facing URL for this app (set to LAN IP for access from other devices on the network) @@ -37,28 +36,40 @@ GITHUB_CLIENT_SECRET= # (Optional) Disable Better Auth built-in rate limiting (useful for testing) DISABLE_AUTH_RATE_LIMIT= -# Backend DB used for server-side metadata (documents/audiobooks) and, when auth is enabled, auth tables. +# (Optional) Backend DB used for server-side metadata (documents/audiobooks) and, when auth is enabled, auth tables. # Defaults to SQLite at docstore/sqlite3.db when not set. POSTGRES_URL= -# (Optional) Skip automatic startup migrations when set to `false` (default: `true`) -RUN_DB_MIGRATIONS= # Embedded SeaweedFS weed mini config +# (Optional) Enable embedded weed mini for local S3-compatible storage (default: `true`) USE_EMBEDDED_WEED_MINI= WEED_MINI_DIR= WEED_MINI_WAIT_SEC= # S3 storage config (use with embedded weed mini or external S3-compatible storage) -# For embedded weed mini, set explicit keys if you want stable credentials across restarts. +# (Optional) For embedded weed mini, set explicit keys if you want stable credentials across restarts. S3_ACCESS_KEY_ID= S3_SECRET_ACCESS_KEY= S3_BUCKET= S3_REGION= -# If empty in embedded mode, OpenReader uses BASE_URL host (when set) or detected LAN host. +# (Optional) If empty in embedded mode, OpenReader uses BASE_URL host (when set) or detected LAN host. S3_ENDPOINT= S3_FORCE_PATH_STYLE= S3_PREFIX= -# Server library import roots (optional, uses /docstore/library by default) +# Migrations configuration +# (Optional) Skip automatic startup migrations when set to `false` (default: `true`) +RUN_DRIZZLE_MIGRATIONS= +# (Optional) Skip automatic filesystem->S3/DB migration pass when set to `false` (default: `true`) +RUN_FS_MIGRATIONS= + +# (Optional) Server library import roots (uses /docstore/library by default) IMPORT_LIBRARY_DIR= IMPORT_LIBRARY_DIRS= + +# (Required without Docker) Path to your local whisper.cpp CLI binary for STT timestamp generation +WHISPER_CPP_BIN=/whisper.cpp/build/bin/whisper-cli + +# (Optional) Override ffmpeg/ffprobe binary paths used for audiobook processing/probing +FFMPEG_BIN= +FFPROBE_BIN= diff --git a/Dockerfile b/Dockerfile index 6a37bb2..d985b1a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,9 +44,9 @@ RUN pnpm build FROM node:lts-alpine AS runner # Add runtime OS dependencies: -# - ffmpeg: required for audiobook export and word-by-word alignment (/api/whisper) # - libreoffice-writer: required for DOCX → PDF conversion -RUN apk add --no-cache ca-certificates ffmpeg libreoffice-writer +# ffmpeg/ffprobe are provided by ffmpeg-static/ffprobe-static from node_modules. +RUN apk add --no-cache ca-certificates libreoffice-writer # Install pnpm globally for running the app RUN npm install -g pnpm diff --git a/README.md b/README.md index 2bf393e..dbb6df4 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,10 @@ OpenReader WebUI is an open source, self-host-friendly text-to-speech document r | Goal | Link | | --- | --- | | Run with Docker | [Docker Quick Start](https://docs.openreader.richardr.dev/getting-started/docker-quick-start) | +| Deploy on Vercel | [Vercel Deployment](https://docs.openreader.richardr.dev/getting-started/vercel-deployment) | | Develop locally | [Local Development](https://docs.openreader.richardr.dev/getting-started/local-development) | | Configure auth | [Auth](https://docs.openreader.richardr.dev/guides/configuration) | -| Configure SQL database | [SQL Database](https://docs.openreader.richardr.dev/operations/database-and-migrations) | +| Configure SQL database | [Database and Migrations](https://docs.openreader.richardr.dev/operations/database-and-migrations) | | Configure object storage | [Object / Blob Storage](https://docs.openreader.richardr.dev/guides/storage-and-blob-behavior) | | Configure TTS providers | [TTS Providers](https://docs.openreader.richardr.dev/guides/tts-providers) | | Run Kokoro locally | [Kokoro-FastAPI](https://docs.openreader.richardr.dev/integrations/kokoro-fastapi) | diff --git a/docs-site/docs/guides/configuration.md b/docs-site/docs/configure/configuration.md similarity index 82% rename from docs-site/docs/guides/configuration.md rename to docs-site/docs/configure/configuration.md index 0c9e305..9e84114 100644 --- a/docs-site/docs/guides/configuration.md +++ b/docs-site/docs/configure/configuration.md @@ -12,8 +12,8 @@ This page covers application-level configuration for provider access and authent ## Related docs -- For the complete variable reference: [Environment Variables](./environment-variables) +- For the complete variable reference: [Environment Variables](../reference/environment-variables) - For TTS character limits and quota behavior: [TTS Rate Limiting](./tts-rate-limiting) - For provider-specific guidance: [TTS Providers](./tts-providers) - For storage/S3/SeaweedFS behavior: [Object / Blob Storage](./storage-and-blob-behavior) -- For database mode and migration commands: [SQL Database](../operations/database-and-migrations) +- For database mode and migration commands: [Database and Migrations](./database-and-migrations) diff --git a/docs-site/docs/operations/database-and-migrations.md b/docs-site/docs/configure/database-and-migrations.md similarity index 53% rename from docs-site/docs/operations/database-and-migrations.md rename to docs-site/docs/configure/database-and-migrations.md index e38eba5..50bc434 100644 --- a/docs-site/docs/operations/database-and-migrations.md +++ b/docs-site/docs/configure/database-and-migrations.md @@ -1,5 +1,5 @@ --- -title: SQL Database +title: Database and Migrations --- This page covers database mode selection and migration behavior for OpenReader WebUI. @@ -11,17 +11,23 @@ This page covers database mode selection and migration behavior for OpenReader W ## Startup migration behavior -By default, the shared entrypoint runs DB migrations automatically before app startup in: +By default, the shared entrypoint runs migrations automatically before app startup in: - Docker container startup - `pnpm dev` - `pnpm start` +Startup migration phases: + +- DB schema migrations (`pnpm migrate`) +- Storage/data migration (`pnpm migrate-fs`) for legacy filesystem content into S3 + DB rows + To skip automatic startup migrations: -- Set `RUN_DB_MIGRATIONS=false` +- Set `RUN_DRIZZLE_MIGRATIONS=false` +- Set `RUN_FS_MIGRATIONS=false` -Database variables are documented in [Environment Variables](../guides/environment-variables). +Database variables are documented in [Environment Variables](../reference/environment-variables). ## Common project commands @@ -31,6 +37,12 @@ In most cases, you do not need manual migration commands because startup runs mi # Run pending migrations (uses Postgres config when POSTGRES_URL is set, otherwise SQLite) pnpm migrate +# Run storage migration (filesystem -> S3 + DB) +pnpm migrate-fs + +# Dry-run storage migration without uploading/deleting +pnpm migrate-fs:dry-run + # Generate new migration files for both SQLite and Postgres outputs pnpm generate ``` @@ -39,14 +51,14 @@ pnpm generate ```bash # Migrate SQLite -npx drizzle-kit migrate --config drizzle.config.sqlite.ts +pnpm exec drizzle-kit migrate --config drizzle.config.sqlite.ts # Migrate Postgres -npx drizzle-kit migrate --config drizzle.config.pg.ts +pnpm exec drizzle-kit migrate --config drizzle.config.pg.ts # Generate SQLite migrations -npx drizzle-kit generate --config drizzle.config.sqlite.ts +pnpm exec drizzle-kit generate --config drizzle.config.sqlite.ts # Generate Postgres migrations -npx drizzle-kit generate --config drizzle.config.pg.ts +pnpm exec drizzle-kit generate --config drizzle.config.pg.ts ``` diff --git a/docs-site/docs/guides/storage-and-blob-behavior.md b/docs-site/docs/configure/storage-and-blob-behavior.md similarity index 75% rename from docs-site/docs/guides/storage-and-blob-behavior.md rename to docs-site/docs/configure/storage-and-blob-behavior.md index fba6c6e..f437ce9 100644 --- a/docs-site/docs/guides/storage-and-blob-behavior.md +++ b/docs-site/docs/configure/storage-and-blob-behavior.md @@ -9,7 +9,7 @@ This page documents storage backends, blob upload routing, and Docker mount beha - Default: embedded SQLite metadata + embedded SeaweedFS (`weed mini`) blobs - External option: Postgres + external S3-compatible object storage -Storage variables are documented in [Environment Variables](./environment-variables) under the storage sections. +Storage variables are documented in [Environment Variables](../reference/environment-variables) under the storage sections. ## Ports @@ -26,7 +26,7 @@ Storage variables are documented in [Environment Variables](./environment-variab | Mount | Type | Recommended | Purpose | Example | | --- | --- | --- | --- | --- | -| `/app/docstore` | Docker named volume | Yes (for persistence) | Persists SeaweedFS blob data, SQLite metadata DB, audiobook artifacts, migration/runtime files | `-v openreader_docstore:/app/docstore` | +| `/app/docstore` | Docker named volume | Yes (for persistence) | Persists SeaweedFS blob data, SQLite metadata DB, migrations, and local runtime temp state | `-v openreader_docstore:/app/docstore` | | `/app/docstore/library` | Bind mount | Optional + `:ro` | Read-only source for server library import (files are copied/imported into client storage) | `-v /path/to/your/library:/app/docstore/library:ro` | To import from mounted library: **Settings -> Documents -> Server Library Import**. @@ -42,3 +42,8 @@ If `8333` is not published externally: - Document uploads still work through upload fallback proxy - Reads/snippets continue through app API routes - Direct presigned browser upload/download to embedded endpoint is unavailable + +## Audiobook storage note + +- In current versions, audiobook assets live in object storage (`audiobooks_v1` keyspace), not as durable files under `/app/docstore`. +- Local filesystem usage for audiobook routes is temporary processing only. diff --git a/docs-site/docs/guides/tts-providers.md b/docs-site/docs/configure/tts-providers.md similarity index 94% rename from docs-site/docs/guides/tts-providers.md rename to docs-site/docs/configure/tts-providers.md index 7cf42bf..8351ff2 100644 --- a/docs-site/docs/guides/tts-providers.md +++ b/docs-site/docs/configure/tts-providers.md @@ -42,7 +42,7 @@ If your provider exposes this interface, it can be used as an OpenAI-compatible 3. Set `API_KEY` if required by your provider. 4. Choose model and voice. -For environment variables, see [Environment Variables](./environment-variables). +For environment variables, see [Environment Variables](../reference/environment-variables). For TTS quota behavior, see [TTS Rate Limiting](./tts-rate-limiting). For auth behavior, see [Auth](./configuration). For provider-specific integration guides, see [Kokoro-FastAPI](../integrations/kokoro-fastapi), [Orpheus-FastAPI](../integrations/orpheus-fastapi), [Deepinfra](../integrations/deepinfra), [OpenAI](../integrations/openai), and [Custom OpenAI](../integrations/custom-openai). diff --git a/docs-site/docs/guides/tts-rate-limiting.md b/docs-site/docs/configure/tts-rate-limiting.md similarity index 94% rename from docs-site/docs/guides/tts-rate-limiting.md rename to docs-site/docs/configure/tts-rate-limiting.md index 851c4c8..8bdf336 100644 --- a/docs-site/docs/guides/tts-rate-limiting.md +++ b/docs-site/docs/configure/tts-rate-limiting.md @@ -46,6 +46,6 @@ IP backstop daily limits: ## Related docs -- Full variable list: [Environment Variables](./environment-variables) +- Full variable list: [Environment Variables](../reference/environment-variables) - Auth configuration: [Auth](./configuration) - Provider setup: [TTS Providers](./tts-providers) diff --git a/docs-site/docs/integrations/custom-openai.md b/docs-site/docs/integrations/custom-openai.md index 2a23fe4..bacf6a7 100644 --- a/docs-site/docs/integrations/custom-openai.md +++ b/docs-site/docs/integrations/custom-openai.md @@ -25,4 +25,4 @@ Your provider should expose: - `API_BASE` is required for this provider path because OpenReader cannot infer your custom host. - If voices do not load, verify the `/v1/audio/voices` response format. -- For variable details, see [Environment Variables](../guides/environment-variables). +- For variable details, see [Environment Variables](../reference/environment-variables). diff --git a/docs-site/docs/integrations/deepinfra.md b/docs-site/docs/integrations/deepinfra.md index d080f61..f242031 100644 --- a/docs-site/docs/integrations/deepinfra.md +++ b/docs-site/docs/integrations/deepinfra.md @@ -16,4 +16,4 @@ Use Deepinfra as a hosted OpenAI-compatible TTS provider. - `Deepinfra` is a built-in provider in the dropdown, so `API_BASE` is usually not required. - Deepinfra supports multiple TTS models, including Kokoro-family options. -- For variable details, see [Environment Variables](../guides/environment-variables). +- For variable details, see [Environment Variables](../reference/environment-variables). diff --git a/docs-site/docs/integrations/openai.md b/docs-site/docs/integrations/openai.md index 72e97fd..5636935 100644 --- a/docs-site/docs/integrations/openai.md +++ b/docs-site/docs/integrations/openai.md @@ -15,4 +15,4 @@ Use OpenAI directly as an OpenAI-compatible TTS provider. - `OpenAI` is a built-in provider in the dropdown, so `API_BASE` is usually not required. - OpenReader routes TTS calls through its server API. -- For variable details, see [Environment Variables](../guides/environment-variables). +- For variable details, see [Environment Variables](../reference/environment-variables). diff --git a/docs-site/docs/integrations/orpheus-fastapi.md b/docs-site/docs/integrations/orpheus-fastapi.md index a3f0c24..76dc760 100644 --- a/docs-site/docs/integrations/orpheus-fastapi.md +++ b/docs-site/docs/integrations/orpheus-fastapi.md @@ -20,4 +20,4 @@ Use Orpheus-FastAPI as an OpenAI-compatible TTS backend for OpenReader. - `API_BASE` is needed here because Orpheus is configured through the custom provider path. - OpenReader expects OpenAI-compatible audio endpoints. -- For variable details, see [Environment Variables](../guides/environment-variables). +- For variable details, see [Environment Variables](../reference/environment-variables). diff --git a/docs-site/docs/intro.md b/docs-site/docs/intro.md index 34a3c36..8a7b2e2 100644 --- a/docs-site/docs/intro.md +++ b/docs-site/docs/intro.md @@ -27,13 +27,13 @@ It supports multiple TTS providers including OpenAI, DeepInfra, and custom OpenA ## Start Here -- [Docker Quick Start](./getting-started/docker-quick-start) -- [Local Development](./getting-started/local-development) -- [Environment Variables](./guides/environment-variables) -- [Auth](./guides/configuration) -- [SQL Database](./operations/database-and-migrations) -- [Object / Blob Storage](./guides/storage-and-blob-behavior) -- [TTS Providers](./guides/tts-providers) +- [Docker Quick Start](./start-here/docker-quick-start) +- [Local Development](./start-here/local-development) +- [Environment Variables](./reference/environment-variables) +- [Auth](./configure/configuration) +- [Database and Migrations](./configure/database-and-migrations) +- [Object / Blob Storage](./configure/storage-and-blob-behavior) +- [TTS Providers](./configure/tts-providers) ## Source Repository diff --git a/docs-site/docs/community/acknowledgements.md b/docs-site/docs/project/acknowledgements.md similarity index 100% rename from docs-site/docs/community/acknowledgements.md rename to docs-site/docs/project/acknowledgements.md diff --git a/docs-site/docs/community/license.md b/docs-site/docs/project/license.md similarity index 100% rename from docs-site/docs/community/license.md rename to docs-site/docs/project/license.md diff --git a/docs-site/docs/community/support.md b/docs-site/docs/project/support.md similarity index 100% rename from docs-site/docs/community/support.md rename to docs-site/docs/project/support.md diff --git a/docs-site/docs/guides/environment-variables.md b/docs-site/docs/reference/environment-variables.md similarity index 79% rename from docs-site/docs/guides/environment-variables.md rename to docs-site/docs/reference/environment-variables.md index f4ce7c6..0292cbb 100644 --- a/docs-site/docs/guides/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -10,6 +10,8 @@ This is the single reference page for OpenReader WebUI environment variables. | Variable | Area | Default | When to set | | --- | --- | --- | --- | | `NEXT_PUBLIC_NODE_ENV` | Runtime mode | `development` | Set `production` for production builds | +| `NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT` | Client feature flags | `true` unless set to `false` | Disable audiobook export UI in any environment | +| `NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT` | Client feature flags | `true` in dev, `false` in production | Force-enable word highlight UI in production | | `API_BASE` | TTS provider | none | Point to your OpenAI-compatible TTS base URL | | `API_KEY` | TTS provider | `none` fallback in TTS route | Set when provider requires auth | | `TTS_CACHE_MAX_SIZE_BYTES` | TTS caching | `268435456` (256 MB) | Tune in-memory TTS cache size | @@ -23,7 +25,6 @@ This is the single reference page for OpenReader WebUI environment variables. | `TTS_DAILY_LIMIT_AUTHENTICATED` | Rate limiting | `500000` | Override authenticated per-user daily character limit | | `TTS_IP_DAILY_LIMIT_ANONYMOUS` | Rate limiting | `100000` | Override anonymous IP backstop daily limit | | `TTS_IP_DAILY_LIMIT_AUTHENTICATED` | Rate limiting | `1000000` | Override authenticated IP backstop daily limit | -| `WHISPER_CPP_BIN` | Word timing | unset | Set to enable `whisper.cpp` timestamps | | `BASE_URL` | Auth | unset | Required (with `AUTH_SECRET`) to enable auth | | `AUTH_SECRET` | Auth | unset | Required (with `BASE_URL`) to enable auth | | `AUTH_TRUSTED_ORIGINS` | Auth | empty | Add extra allowed origins | @@ -31,7 +32,6 @@ This is the single reference page for OpenReader WebUI environment variables. | `GITHUB_CLIENT_SECRET` | Auth/OAuth | unset | Set with `GITHUB_CLIENT_ID` to enable GitHub sign-in | | `DISABLE_AUTH_RATE_LIMIT` | Rate limiting | `false` | Set `true` to disable auth-layer rate limiting | | `POSTGRES_URL` | Database | unset (SQLite mode) | Set to switch metadata/auth DB to Postgres | -| `RUN_DB_MIGRATIONS` | Database | `true` | Set `false` to skip startup migrations | | `USE_EMBEDDED_WEED_MINI` | Storage | `true` when unset | Set `false` to use external S3-compatible storage only | | `WEED_MINI_DIR` | Storage | `docstore/seaweedfs` | Override embedded SeaweedFS data directory | | `WEED_MINI_WAIT_SEC` | Storage | `20` | Tune SeaweedFS startup wait timeout | @@ -42,8 +42,13 @@ This is the single reference page for OpenReader WebUI environment variables. | `S3_ENDPOINT` | Storage | derived in embedded mode | Set for S3-compatible providers (MinIO/SeaweedFS/R2/etc.) | | `S3_FORCE_PATH_STYLE` | Storage | `true` in embedded mode | Set per provider requirement | | `S3_PREFIX` | Storage | `openreader` | Customize object key prefix | +| `RUN_DRIZZLE_MIGRATIONS` | Database migrations | `true` | Set `false` to skip startup Drizzle schema migrations | +| `RUN_FS_MIGRATIONS` | Storage migrations | `true` | Set `false` to skip startup filesystem -> S3/DB migration pass | | `IMPORT_LIBRARY_DIR` | Library import | `docstore/library` fallback | Set a single server library root | | `IMPORT_LIBRARY_DIRS` | Library import | unset | Set multiple roots (comma/colon/semicolon separated) | +| `WHISPER_CPP_BIN` | Word timing | unset | Set to enable `whisper.cpp` timestamps | +| `FFMPEG_BIN` | Audio runtime | auto-detected (`ffmpeg-static`) | Override ffmpeg binary path | +| `FFPROBE_BIN` | Audio runtime | auto-detected (`ffprobe-static`) | Override ffprobe binary path | ## Detailed Reference @@ -54,6 +59,21 @@ Controls development vs production behavior in client/server code paths. - Typical values: `development`, `production` - In production builds, set `production` +### NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT + +Controls whether audiobook export UI/actions are shown in the client. + +- Default behavior: enabled unless explicitly set to `false` +- Applies in both development and production + +### NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT + +Controls word-by-word highlighting UI in production builds. + +- Development default: enabled +- Production default: disabled unless set to `true` +- Requires working timestamp generation (for example `WHISPER_CPP_BIN`) + ### API_BASE Base URL for OpenAI-compatible TTS API requests. @@ -110,7 +130,7 @@ Controls TTS character rate limiting in the TTS API. - Default: `false` (TTS char limits disabled) - Set to `true` to enforce `TTS_DAILY_LIMIT_*` and `TTS_IP_DAILY_LIMIT_*` -- For behavior details and examples, see [TTS Rate Limiting](./tts-rate-limiting) +- For behavior details and examples, see [TTS Rate Limiting](../configure/tts-rate-limiting) ### TTS_DAILY_LIMIT_ANONYMOUS @@ -136,13 +156,6 @@ Authenticated IP backstop daily character limit. - Default: `1000000` -### WHISPER_CPP_BIN - -Absolute path to compiled `whisper.cpp` binary for word-level timestamps. - -- Example: `/whisper.cpp/build/bin/whisper-cli` -- Required only for optional word-by-word highlighting - ### BASE_URL External base URL for this OpenReader instance. @@ -191,13 +204,6 @@ Switches metadata/auth storage from SQLite to Postgres. - Unset: SQLite at `docstore/sqlite3.db` - Set: Postgres mode -### RUN_DB_MIGRATIONS - -Controls startup migration execution in shared entrypoint. - -- Default: `true` -- Set `false` to skip automatic startup migrations - ### USE_EMBEDDED_WEED_MINI Controls embedded SeaweedFS startup. @@ -265,6 +271,21 @@ Prefix prepended to stored object keys. - Default: `openreader` +### RUN_DRIZZLE_MIGRATIONS + +Controls startup migration execution in shared entrypoint. + +- Default: `true` +- Set `false` to skip automatic startup Drizzle schema migrations + +### RUN_FS_MIGRATIONS + +Controls startup filesystem-to-object-store migration execution in shared entrypoint. + +- Default: `true` +- Runs `scripts/migrate-fs-v2.mjs` at startup after DB migrations +- Set `false` to skip automatic storage migration pass + ### IMPORT_LIBRARY_DIR Single directory root for server library import. @@ -278,3 +299,24 @@ Multiple library roots for server library import. - Separator: comma, colon, or semicolon - Takes precedence over `IMPORT_LIBRARY_DIR` + +### WHISPER_CPP_BIN + +Absolute path to compiled `whisper.cpp` binary for word-level timestamps. + +- Example: `/whisper.cpp/build/bin/whisper-cli` +- Required only for optional word-by-word highlighting + +### FFMPEG_BIN + +Absolute path or executable name for the ffmpeg binary used by audiobook/processing routes. + +- Resolution order: `FFMPEG_BIN` -> `ffmpeg-static` +- Example: `/var/task/node_modules/ffmpeg-static/ffmpeg` + +### FFPROBE_BIN + +Absolute path or executable name for the ffprobe binary used for audio probing. + +- Resolution order: `FFPROBE_BIN` -> `ffprobe-static` +- Example: `/var/task/node_modules/ffprobe-static/bin/linux/x64/ffprobe` diff --git a/docs-site/docs/getting-started/docker-quick-start.md b/docs-site/docs/start-here/docker-quick-start.md similarity index 85% rename from docs-site/docs/getting-started/docker-quick-start.md rename to docs-site/docs/start-here/docker-quick-start.md index f44f76e..62bafa4 100644 --- a/docs-site/docs/getting-started/docker-quick-start.md +++ b/docs-site/docs/start-here/docker-quick-start.md @@ -50,10 +50,10 @@ Quick notes: - To enable auth, set both `BASE_URL` and `AUTH_SECRET`. - DB migrations run automatically during container startup via the shared entrypoint. -For all environment variables, see [Environment Variables](../guides/environment-variables). -For app/auth behavior, see [Auth](../guides/configuration). -For database startup and migration behavior, see [SQL Database](../operations/database-and-migrations). -For blob behavior and mounts, see [Object / Blob Storage](../guides/storage-and-blob-behavior). +For all environment variables, see [Environment Variables](../reference/environment-variables). +For app/auth behavior, see [Auth](../configure/configuration). +For database startup and migration behavior, see [Database and Migrations](../configure/database-and-migrations). +For blob behavior and mounts, see [Object / Blob Storage](../configure/storage-and-blob-behavior). ## 2. Configure settings in the app UI diff --git a/docs-site/docs/getting-started/local-development.md b/docs-site/docs/start-here/local-development.md similarity index 82% rename from docs-site/docs/getting-started/local-development.md rename to docs-site/docs/start-here/local-development.md index ffdb578..521a9b2 100644 --- a/docs-site/docs/getting-started/local-development.md +++ b/docs-site/docs/start-here/local-development.md @@ -20,12 +20,6 @@ brew install seaweedfs Optional, depending on features: -- [FFmpeg](https://ffmpeg.org) (required for `m4b` audiobook generation) - -```bash -brew install ffmpeg -``` - - [libreoffice](https://www.libreoffice.org) (required for DOCX conversion) ```bash @@ -83,10 +77,10 @@ Optional: - Stable S3 credentials via `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY` - External S3 storage by setting `USE_EMBEDDED_WEED_MINI=false` and related S3 vars -For all environment variables, see [Environment Variables](../guides/environment-variables). -For app/auth behavior, see [Auth](../guides/configuration). -For storage configuration, see [Object / Blob Storage](../guides/storage-and-blob-behavior). -For database mode and migrations, see [SQL Database](../operations/database-and-migrations). +For all environment variables, see [Environment Variables](../reference/environment-variables). +For app/auth behavior, see [Auth](../configure/configuration). +For storage configuration, see [Object / Blob Storage](../configure/storage-and-blob-behavior). +For database mode and migrations, see [Database and Migrations](../configure/database-and-migrations). 4. Run DB migrations. @@ -98,7 +92,7 @@ pnpm migrate ``` :::note -If `POSTGRES_URL` is set, migrations target Postgres; otherwise local SQLite is used. To disable automatic startup migrations, set `RUN_DB_MIGRATIONS=false`. +If `POSTGRES_URL` is set, migrations target Postgres; otherwise local SQLite is used. To disable automatic startup migrations, set `RUN_DRIZZLE_MIGRATIONS=false` and/or `RUN_FS_MIGRATIONS=false`. You can run storage migration manually with `pnpm migrate-fs`. ::: 5. Start the app. diff --git a/docs-site/docs/start-here/vercel-deployment.md b/docs-site/docs/start-here/vercel-deployment.md new file mode 100644 index 0000000..50d06fe --- /dev/null +++ b/docs-site/docs/start-here/vercel-deployment.md @@ -0,0 +1,78 @@ +--- +title: Vercel Deployment +--- + +This guide covers deploying OpenReader WebUI to Vercel with external Postgres and S3-compatible object storage. + +## What works on Vercel + +- Documents (PDF/EPUB/TXT/MD) work with `POSTGRES_URL` + external S3 storage. +- Audiobook routes work on Node.js serverless functions using `ffmpeg-static`/`ffprobe-static`. +- `docx` conversion requires `soffice` (LibreOffice), which is not available in a standard Vercel runtime. + +## 1. Required environment variables + +Set these in your Vercel project: + +```bash +POSTGRES_URL=postgres://... +USE_EMBEDDED_WEED_MINI=false +S3_ACCESS_KEY_ID=... +S3_SECRET_ACCESS_KEY=... +S3_BUCKET=... +S3_REGION=us-east-1 +S3_ENDPOINT=https://... +S3_FORCE_PATH_STYLE=true +S3_PREFIX=openreader +``` + +Optional but common: + +```bash +BASE_URL=https://your-app.vercel.app +AUTH_SECRET=... +NEXT_PUBLIC_NODE_ENV=production +NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT=true +NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT=true +``` + +For all variables and defaults, see [Environment Variables](../reference/environment-variables). + +## 2. FFmpeg/ffprobe packaging in Vercel functions + +`ffmpeg-static` and `ffprobe-static` binaries must be included in function traces. This repo already does that in `next.config.ts` via `outputFileTracingIncludes` for: + +- `/api/audiobook(.*)` +- `/api/whisper` + +If you change route paths or split handlers, update `outputFileTracingIncludes` accordingly. + +## 3. Function memory sizing + +FFmpeg workloads benefit from more memory/CPU. This repo includes: + +```json +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "functions": { + "app/api/audiobook/route.ts": { "memory": 3009 }, + "app/api/whisper/route.ts": { "memory": 3009 } + } +} +``` + +Adjust memory per route if your files are larger or your plan differs. + +## 4. Runtime expectations and caveats + +- Audiobook APIs require S3 configuration; otherwise they return `503`. +- `better-sqlite3` remains in `serverExternalPackages` for mixed/self-host setups, but production Vercel should use `POSTGRES_URL`. +- Filesystem-to-object-store migrations run via server scripts/entrypoint (`scripts/migrate-fs-v2.mjs`), not API routes. +- Vercel deployments do not run `scripts/openreader-entrypoint.mjs`, so run `pnpm migrate-fs` in a controlled environment when migrating legacy filesystem data. + +## 5. Smoke test after deploy + +1. Upload and read a PDF/EPUB document. +2. Confirm sync/blob fetch works across refreshes/devices. +3. Generate at least one audiobook chapter and play/download it. +4. If using word highlighting, verify timestamps are produced and rendered. diff --git a/docs-site/docusaurus.config.ts b/docs-site/docusaurus.config.ts index d1f1813..8cbef16 100644 --- a/docs-site/docusaurus.config.ts +++ b/docs-site/docusaurus.config.ts @@ -5,6 +5,7 @@ import type * as Preset from '@docusaurus/preset-classic'; const config: Config = { title: 'OpenReader WebUI Docs', tagline: 'Docs for OpenReader', + favicon: 'favicon.ico', future: { v4: true, @@ -97,7 +98,7 @@ const config: Config = { { title: 'Community', items: [ - { label: 'Support', to: '/community/support' }, + { label: 'Support', to: '/project/support' }, { label: 'GitHub Discussions', href: 'https://github.com/richardr1126/OpenReader-WebUI/discussions' }, { label: 'Issues', href: 'https://github.com/richardr1126/OpenReader-WebUI/issues' }, ], diff --git a/docs-site/sidebars.ts b/docs-site/sidebars.ts index 0ebfa55..291cf19 100644 --- a/docs-site/sidebars.ts +++ b/docs-site/sidebars.ts @@ -6,13 +6,13 @@ const sidebars: SidebarsConfig = { { type: 'category', label: 'Start Here', - items: ['getting-started/docker-quick-start', 'getting-started/local-development'], + items: ['start-here/docker-quick-start', 'start-here/vercel-deployment', 'start-here/local-development'], }, { type: 'category', label: 'Reference', items: [ - 'guides/environment-variables', + 'reference/environment-variables', 'reference/stack', ], }, @@ -20,15 +20,15 @@ const sidebars: SidebarsConfig = { type: 'category', label: 'Configure', items: [ - 'guides/tts-providers', + 'configure/tts-providers', { type: 'doc', - id: 'guides/configuration', + id: 'configure/configuration', label: 'Auth (Reccomended)', }, - 'guides/tts-rate-limiting', - 'operations/database-and-migrations', - 'guides/storage-and-blob-behavior', + 'configure/tts-rate-limiting', + 'configure/database-and-migrations', + 'configure/storage-and-blob-behavior', ], }, { @@ -45,7 +45,7 @@ const sidebars: SidebarsConfig = { { type: 'category', label: 'Project', - items: ['community/support', 'community/acknowledgements', 'community/license'], + items: ['project/support', 'project/acknowledgements', 'project/license'], }, ], }; diff --git a/docs-site/static/favicon.ico b/docs-site/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..afd0f758428b6961dcb01b38284caa23f1a4e175 GIT binary patch literal 15086 zcmeHNOK)4p6~3fo*-6~Siv~!78c||XPg4&{qNK=@NXf`LyKcMezo-{Qfuj8j-Pb6x zX;%e;#$9&fMT!hvs=fF`6ppx?nzj)d^@bqZHLCfW7KVXJ<84br=eMYVu|&Q1(X-I z!X{EoZS}>yx|BXDYjoN3ZYSd+*JIg|lM6`S!Q7Qz%ISCX@bTH-dmE!+vCG@YM(j^s%sn}+)OT1P zF$lZxcz@$p+X@!n-`&5rF|R(Ibfmf>E!VBdV1AK2n~drg>m{e;cHF|I_VByhZfqZA zN#5svI5E6`Z+?9Dam1hUigYZ7B{bb$&^6{GruBSoVy@>j-^lCLo;fi(KJe@^RwReF zP4J^HW%qgY>GNiyf7w#@b=!MN}yi^kvAhw~($P{Q&8QFDa{? za<#vPe%HbWvtQ-9aGh*o?1*2toHDE;X4;yB`oVLzw$Tr_!w2;}@XY$2HsAke*qqxj z9PRp-pQZTRe~6j*>Hp~(hH|7fN$F+T8-2&_`nUr9xgJw~EbF;gPVslwz00jJHaPH_ z<1h8w2A6G}Kt96T4U^iu3_ssH4fjjV9fM`~Zr`y}7en3gr)wv} z#;(F%F00!{c$v#?MP2mEe=cPM;mzT@=8KieZ^O*kH({gp^dOb#S zfph6&!@XSpTc}n4YxtXspNFZj7ryrgAMq~EOWNP@y^UY^G}kb8j*$C}@XL3Ub6?QX z|9N9jX&~I9zauX}_qDN*+v9o|gFPB^IJ-#jn_f9av0m2vM(iB3>sH6u?mlI0@lN=0tfpfmL{a1dzL_5&`?Q9b9r)Vq3?{TbU zGxTZs&v|7iLD$(k(0?h+;|!_3_}4H1T8p(uVX=DHil6I@i{Quo-#Yw5GC3CLJlAp2 ze}t9ts88xst@wFf{CV6R!a8<=akPxJ4}519KDA!kZ{d$TV{8#7=E9%)PpWg4BMmRR z|K#h>3*xsil+&22evb9ScDT)bHO83ETMVzwPi~7;pTciv1MnX`OaIJciaNNk|8$u5 zZZCfVpStQkZ1~&#R{Bo$X?QVz*_i?GOE%$8+ot931HZ>J?7yh91b@o6SSC)cA2ff( zm~+x^)VCFXsKb8vPn#{ShRyope9Q`i;vb*e%PstQ{5N~#K4W^YvinthPS!vD#9zJ? z?yUH|O&o)qmxw!EGZT;3`})WCkDc~k(s?nrGoLIo^N`h_h{;;Iqje>Ck`riNAz>~o#>8go)Sl#A!L<2XO({zdjl+px}3?q3$8 za0XPy++WIL2lH|gN4k}@SGWYd`S<%i@7AcN)HfJU zlu{jf8)A1{+`khO*U!Wby=1(LVIS7BecJ!DvYm~;q`i$zj?X-uM(^}@u7A!0#L4?< zV&_lycPW0BBR#jDY#8g)HocX#mJ|)e6MN3%40$_$VJ2;sJu&X2_jiT7j~ja<`9FcmkSyCtZn-_%kP_Zh9|D zLf_)}Smz!;!7KX3I`ZqplkIp0!*?n6w7>PkI#_W<@`@fY5X;TC4|@83Bb9~mX&iTy zcF)JVvVvVw9F18&`)jMqajUMI40_L;JeI+04s#RlM-?MeTxt234?oYcL+0;f*$(s9 zv+N=B%UO1x`9PN4Wsb>8IUoQ3iyFAgVxph>e1_g0GKU_eSLs=L=XYAeNDZX3e5ZBe z`oV`&H=jLNn%p^D!}k=7cZ!96#_jVd&o{U}G2h`jh0o?!F+S7R={E}#yN8p*`wvJP za60}b-w0R1_fvga<>SQXW{n~cBd8N9}ue49CGlLvXFAo#p6zO&7FhK0@ScbF%pI_)?%e!wcDgnf_xGX~ z`7<82`On`}77v?ic+Zggf_MrK{iA38@b~HQZ|uyvI*PkL-a%E4Y#&W};;H*S*17&YR+1N! zFsQxr*WK>vW8wDFf9wonqVghK!QF4Q@?C5H?HJm9&F5OhWal3-KrfirdzRnE+*{E5 zt6{1BcN??S(Qh$7__s&W0dpn0C$ae>(NCg-sQ=#NpB=jwN&{J_-;y23|73iVZpq*7 zxpPdck823`i?hf-K8ib2+{54G-EVmyOphM7&QWZ<=VM=Skox!e%5^Utr9N;L{#fXR z0r>r^ul@x39}2Ze-JAPZb`0_{*Nfz*uDkQ^P5&`Ymv9&UBCJ6^o+C+K@|W((%XsGV zPs-nMr20QA|IGZ>&Yq0FWRAJ( z=D%ojmz#Z9m!8{2{hl5NF2fI+%U_!Aov!q*JYQ3R|9Z{8@1||UGnN0W{D+`}&6P)X z?(AoXu4CzdcqA*=A4}EULp?ix>B-v-AddDbH@|n;yq(M4W_QUQZA)HUe@#{PAISGo zxm&)v`~~m{#@H!6r+OC-=3IL}v;7BboB950 z7yB>vI~lK2o~~D?mD-J1#h-c9zt^X-6!%}kPz5HP4Y@q-gXAN6#G>bR{;}Tr+S`QEKfbpTet(euX&tR+^acaGRr z=TyvBr?+vQk@}|mNdCfSUysS>R;Ra)jqm90L)H13)%TlL<~)af?sw^W*7uyVBfAe* zW}X7Gx3k>7jraI(djPCc_zk`7$N0TK3+HyAEXBJ}sH;m%Vm-cjP5gr8P0%NP(e1Ur z>D93G)}P-_@|#KV@t6mn1%F{8&5r!@Z|Ab?y0xVb$C&B;y{=n-$d2#5_{|jCiJz@= zd*c6!eLKT%X1p(Ln$zi}f6FFUt$*|v(vbX~ca%TxZ+GSe*;l4_k3`S(hxw)x4c?vl zooV!A8w>E?GzRjnjdyI8aF52jG~S`P+^Ma%@Exy@qubLv$@tMYiazD{%hJ2kSVf<7 c4Ep%pIigV!Z literal 0 HcmV?d00001 diff --git a/next.config.ts b/next.config.ts index c6b910e..c10b3b8 100644 --- a/next.config.ts +++ b/next.config.ts @@ -7,6 +7,18 @@ const nextConfig: NextConfig = { }, }, serverExternalPackages: ["better-sqlite3"], + outputFileTracingIncludes: { + '/api/audiobook(.*)': [ + 'node_modules/ffmpeg-static/ffmpeg', + 'node_modules/ffprobe-static/bin/linux/*/ffprobe', + 'node_modules/.pnpm/ffmpeg-static@*/node_modules/ffmpeg-static/ffmpeg', + 'node_modules/.pnpm/ffprobe-static@*/node_modules/ffprobe-static/bin/linux/*/ffprobe', + ], + '/api/whisper': [ + 'node_modules/ffmpeg-static/ffmpeg', + 'node_modules/.pnpm/ffmpeg-static@*/node_modules/ffmpeg-static/ffmpeg', + ], + }, }; export default nextConfig; diff --git a/package.json b/package.json index d1f8bfc..52e1678 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "lint": "next lint", "test": "playwright test", "migrate": "node drizzle/scripts/migrate.mjs", + "migrate-fs": "node scripts/migrate-fs-v2.mjs", + "migrate-fs:dry-run": "node scripts/migrate-fs-v2.mjs --dry-run true", "generate": "node drizzle/scripts/generate.mjs", "docs:init": "pnpm --dir docs-site install", "docs:dev": "pnpm --dir docs-site start", @@ -20,34 +22,34 @@ "docs:clear": "pnpm --dir docs-site clear" }, "dependencies": { - "@aws-sdk/client-s3": "^3.985.0", - "@aws-sdk/s3-request-presigner": "^3.985.0", + "@aws-sdk/client-s3": "^3.987.0", + "@aws-sdk/s3-request-presigner": "^3.987.0", "@headlessui/react": "^2.2.9", - "@types/howler": "^2.2.12", - "@types/uuid": "^10.0.0", "@vercel/analytics": "^1.6.1", - "better-auth": "^1.4.17", + "better-auth": "^1.4.18", "better-sqlite3": "^12.6.2", - "cmpstr": "^3.2.0", + "cmpstr": "^3.2.1", "compromise": "^14.14.5", "core-js": "^3.48.0", - "dexie": "^4.2.1", + "dexie": "^4.3.0", "dexie-react-hooks": "^4.2.0", - "dotenv": "^17.2.3", - "drizzle-kit": "^0.31.8", + "dotenv": "^17.2.4", + "drizzle-kit": "^0.31.9", "drizzle-orm": "^0.45.1", "epubjs": "^0.3.93", + "ffmpeg-static": "^5.3.0", + "ffprobe-static": "^3.1.0", "howler": "^2.2.4", - "lru-cache": "^11.2.4", - "next": "^15.5.9", - "openai": "^6.16.0", + "lru-cache": "^11.2.6", + "next": "^15.5.12", + "openai": "^6.21.0", "pdfjs-dist": "4.8.69", - "pg": "^8.17.2", - "react": "^19.2.3", + "pg": "^8.18.0", + "react": "^19.2.4", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", - "react-dom": "^19.2.3", - "react-dropzone": "^14.3.8", + "react-dom": "^19.2.4", + "react-dropzone": "^14.4.1", "react-hot-toast": "^2.6.0", "react-markdown": "^10.1.0", "react-pdf": "^9.2.1", @@ -57,22 +59,26 @@ }, "devDependencies": { "@eslint/eslintrc": "^3.3.3", - "@playwright/test": "^1.58.0", + "@playwright/test": "^1.58.2", "@tailwindcss/typography": "^0.5.19", "@types/better-sqlite3": "^7.6.13", - "@types/node": "^20.19.30", + "@types/ffprobe-static": "^2.0.3", + "@types/node": "^20.19.33", "@types/pg": "^8.16.0", - "@types/react": "^19.2.9", + "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", + "@types/howler": "^2.2.12", + "@types/uuid": "^10.0.0", "eslint": "^9.39.2", - "eslint-config-next": "^15.5.9", + "eslint-config-next": "^15.5.12", "postcss": "^8.5.6", "tailwindcss": "^3.4.19", "typescript": "^5.9.3" }, "pnpm": { "onlyBuiltDependencies": [ - "better-sqlite3" + "better-sqlite3", + "ffmpeg-static" ], "overrides": { "lodash": "^4.17.23", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f41538b..6e69f8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,14 +14,14 @@ importers: .: dependencies: '@aws-sdk/client-s3': - specifier: ^3.985.0 - version: 3.985.0 + specifier: ^3.987.0 + version: 3.987.0 '@aws-sdk/s3-request-presigner': - specifier: ^3.985.0 - version: 3.985.0 + specifier: ^3.987.0 + version: 3.987.0 '@headlessui/react': specifier: ^2.2.9 - version: 2.2.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/howler': specifier: ^2.2.12 version: 2.2.12 @@ -30,16 +30,16 @@ importers: version: 10.0.0 '@vercel/analytics': specifier: ^1.6.1 - version: 1.6.1(next@15.5.9(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 1.6.1(next@15.5.12(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) better-auth: - specifier: ^1.4.17 - version: 1.4.17(@prisma/client@5.22.0)(better-sqlite3@12.6.2)(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2))(next@15.5.9(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.17.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.4.18 + version: 1.4.18(@prisma/client@5.22.0)(better-sqlite3@12.6.2)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0))(next@15.5.12(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) better-sqlite3: specifier: ^12.6.2 version: 12.6.2 cmpstr: - specifier: ^3.2.0 - version: 3.2.0 + specifier: ^3.2.1 + version: 3.2.1 compromise: specifier: ^14.14.5 version: 14.14.5 @@ -47,68 +47,74 @@ importers: specifier: ^3.48.0 version: 3.48.0 dexie: - specifier: ^4.2.1 - version: 4.2.1 + specifier: ^4.3.0 + version: 4.3.0 dexie-react-hooks: specifier: ^4.2.0 - version: 4.2.0(@types/react@19.2.9)(dexie@4.2.1)(react@19.2.3) + version: 4.2.0(@types/react@19.2.13)(dexie@4.3.0)(react@19.2.4) dotenv: - specifier: ^17.2.3 - version: 17.2.3 + specifier: ^17.2.4 + version: 17.2.4 drizzle-kit: - specifier: ^0.31.8 - version: 0.31.8 + specifier: ^0.31.9 + version: 0.31.9 drizzle-orm: specifier: ^0.45.1 - version: 0.45.1(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2) + version: 0.45.1(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0) epubjs: specifier: ^0.3.93 version: 0.3.93 + ffmpeg-static: + specifier: ^5.3.0 + version: 5.3.0 + ffprobe-static: + specifier: ^3.1.0 + version: 3.1.0 howler: specifier: ^2.2.4 version: 2.2.4 lru-cache: - specifier: ^11.2.4 - version: 11.2.4 + specifier: ^11.2.6 + version: 11.2.6 next: - specifier: ^15.5.9 - version: 15.5.9(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^15.5.12 + version: 15.5.12(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) openai: - specifier: ^6.16.0 - version: 6.16.0(zod@4.3.6) + specifier: ^6.21.0 + version: 6.21.0(zod@4.3.6) pdfjs-dist: specifier: 4.8.69 version: 4.8.69 pg: - specifier: ^8.17.2 - version: 8.17.2 + specifier: ^8.18.0 + version: 8.18.0 react: - specifier: ^19.2.3 - version: 19.2.3 + specifier: ^19.2.4 + version: 19.2.4 react-dnd: specifier: ^16.0.1 - version: 16.0.1(@types/node@20.19.30)(@types/react@19.2.9)(react@19.2.3) + version: 16.0.1(@types/node@20.19.33)(@types/react@19.2.13)(react@19.2.4) react-dnd-html5-backend: specifier: ^16.0.1 version: 16.0.1 react-dom: - specifier: ^19.2.3 - version: 19.2.3(react@19.2.3) + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) react-dropzone: - specifier: ^14.3.8 - version: 14.3.8(react@19.2.3) + specifier: ^14.4.1 + version: 14.4.1(react@19.2.4) react-hot-toast: specifier: ^2.6.0 - version: 2.6.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 2.6.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-markdown: specifier: ^10.1.0 - version: 10.1.0(@types/react@19.2.9)(react@19.2.3) + version: 10.1.0(@types/react@19.2.13)(react@19.2.4) react-pdf: specifier: ^9.2.1 - version: 9.2.1(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 9.2.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react-reader: specifier: ^2.0.15 - version: 2.0.15(react@19.2.3) + version: 2.0.15(react@19.2.4) remark-gfm: specifier: ^4.0.1 version: 4.0.1 @@ -120,32 +126,35 @@ importers: specifier: ^3.3.3 version: 3.3.3 '@playwright/test': - specifier: ^1.58.0 - version: 1.58.0 + specifier: ^1.58.2 + version: 1.58.2 '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@3.4.19) '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 + '@types/ffprobe-static': + specifier: ^2.0.3 + version: 2.0.3 '@types/node': - specifier: ^20.19.30 - version: 20.19.30 + specifier: ^20.19.33 + version: 20.19.33 '@types/pg': specifier: ^8.16.0 version: 8.16.0 '@types/react': - specifier: ^19.2.9 - version: 19.2.9 + specifier: ^19.2.13 + version: 19.2.13 '@types/react-dom': specifier: ^19.2.3 - version: 19.2.3(@types/react@19.2.9) + version: 19.2.3(@types/react@19.2.13) eslint: specifier: ^9.39.2 version: 9.39.2(jiti@1.21.7) eslint-config-next: - specifier: ^15.5.9 - version: 15.5.9(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + specifier: ^15.5.12 + version: 15.5.12(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) postcss: specifier: ^8.5.6 version: 8.5.6 @@ -185,8 +194,8 @@ packages: '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - '@aws-sdk/client-s3@3.985.0': - resolution: {integrity: sha512-S9TqjzzZEEIKBnC7yFpvqM7CG9ALpY5qhQ5BnDBJtdG20NoGpjKLGUUfD2wmZItuhbrcM4Z8c6m6Fg0XYIOVvw==} + '@aws-sdk/client-s3@3.987.0': + resolution: {integrity: sha512-9nLbDIjqdiDkJk8hrAW8jP51bRXjD0+2J3lnCAy+N2G4BDoQuN09+iQF2chF/9BJ/hTk5Ldm2beaO8G2PM1cyw==} engines: {node: '>=20.0.0'} '@aws-sdk/client-sso@3.985.0': @@ -281,12 +290,12 @@ packages: resolution: {integrity: sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==} engines: {node: '>=20.0.0'} - '@aws-sdk/s3-request-presigner@3.985.0': - resolution: {integrity: sha512-lPnf977GFM4cMLJ7X+ThktKMe/0CXIfX+wz1z+sUT7yagPL2IRyiNUPFZ0VTEGBo1gRhHEDPWy6yzk8WWRFsvg==} + '@aws-sdk/s3-request-presigner@3.987.0': + resolution: {integrity: sha512-XHf9ZQOgsdzBhfFhMM+wFITnd1M3OqMVUEdIrciSS8aFOg+WtQEcR2GcMs+Sj5whmO4XOrUMFuDdCgAwvdq0tg==} engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.985.0': - resolution: {integrity: sha512-W6hTSOPiSbh4IdTYVxN7xHjpCh0qvfQU1GKGBzGQm0ZEIOaMmWqiDEvFfyGYKmfBvumT8vHKxQRTX0av9omtIg==} + '@aws-sdk/signature-v4-multi-region@3.987.0': + resolution: {integrity: sha512-5kVC6x6+2NO+/NIXWJwN68+8cvqREsoE+tFOMyZWj2fg3EWzCnTGVIFd7hSJZJT2WiP5LqcrdEoFyXtfDta1hg==} engines: {node: '>=20.0.0'} '@aws-sdk/token-providers@3.985.0': @@ -305,6 +314,10 @@ packages: resolution: {integrity: sha512-vth7UfGSUR3ljvaq8V4Rc62FsM7GUTH/myxPWkaEgOrprz1/Pc72EgTXxj+cPPPDAfHFIpjhkB7T7Td0RJx+BA==} engines: {node: '>=20.0.0'} + '@aws-sdk/util-endpoints@3.987.0': + resolution: {integrity: sha512-rZnZwDq7Pn+TnL0nyS6ryAhpqTZtLtHbJaqfxuHlDX3v/bq0M7Ch/V3qF9dZWaGgsJ2H9xn7/vFOxlnL4fBMcQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/util-format-url@3.972.3': resolution: {integrity: sha512-n7F2ycckcKFXa01vAsT/SJdjFHfKH9s96QHcs5gn8AaaigASICeME8WdUL9uBp8XV/OVwEt8+6gzn6KFUgQa8g==} engines: {node: '>=20.0.0'} @@ -337,8 +350,8 @@ packages: resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} - '@better-auth/core@1.4.17': - resolution: {integrity: sha512-WSaEQDdUO6B1CzAmissN6j0lx9fM9lcslEYzlApB5UzFaBeAOHNUONTdglSyUs6/idiZBoRvt0t/qMXCgIU8ug==} + '@better-auth/core@1.4.18': + resolution: {integrity: sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg==} peerDependencies: '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 @@ -347,10 +360,10 @@ packages: kysely: ^0.28.5 nanostores: ^1.0.1 - '@better-auth/telemetry@1.4.17': - resolution: {integrity: sha512-R1BC4e/bNjQbXu7lG6ubpgmsPj7IMqky5DvMlzAtnAJWJhh99pMh/n6w5gOHa0cqDZgEAuj75IPTxv+q3YiInA==} + '@better-auth/telemetry@1.4.18': + resolution: {integrity: sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ==} peerDependencies: - '@better-auth/core': 1.4.17 + '@better-auth/core': 1.4.18 '@better-auth/utils@0.3.0': resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==} @@ -358,6 +371,10 @@ packages: '@better-fetch/fetch@1.1.21': resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@derhuerst/http-basic@8.2.4': + resolution: {integrity: sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==} + engines: {node: '>=6.0.0'} + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -704,14 +721,14 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@floating-ui/core@1.7.3': - resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + '@floating-ui/core@1.7.4': + resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} - '@floating-ui/dom@1.7.4': - resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + '@floating-ui/dom@1.7.5': + resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} - '@floating-ui/react-dom@2.1.6': - resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==} + '@floating-ui/react-dom@2.1.7': + resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' @@ -901,56 +918,56 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} - '@next/env@15.5.9': - resolution: {integrity: sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==} + '@next/env@15.5.12': + resolution: {integrity: sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg==} - '@next/eslint-plugin-next@15.5.9': - resolution: {integrity: sha512-kUzXx0iFiXw27cQAViE1yKWnz/nF8JzRmwgMRTMh8qMY90crNsdXJRh2e+R0vBpFR3kk1yvAR7wev7+fCCb79Q==} + '@next/eslint-plugin-next@15.5.12': + resolution: {integrity: sha512-+ZRSDFTv4aC96aMb5E41rMjysx8ApkryevnvEYZvPZO52KvkqP5rNExLUXJFr9P4s0f3oqNQR6vopCZsPWKDcQ==} - '@next/swc-darwin-arm64@15.5.7': - resolution: {integrity: sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==} + '@next/swc-darwin-arm64@15.5.12': + resolution: {integrity: sha512-RnRjBtH8S8eXCpUNkQ+543DUc7ys8y15VxmFU9HRqlo9BG3CcBUiwNtF8SNoi2xvGCVJq1vl2yYq+3oISBS0Zg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.5.7': - resolution: {integrity: sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==} + '@next/swc-darwin-x64@15.5.12': + resolution: {integrity: sha512-nqa9/7iQlboF1EFtNhWxQA0rQstmYRSBGxSM6g3GxvxHxcoeqVXfGNr9stJOme674m2V7r4E3+jEhhGvSQhJRA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.5.7': - resolution: {integrity: sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==} + '@next/swc-linux-arm64-gnu@15.5.12': + resolution: {integrity: sha512-dCzAjqhDHwmoB2M4eYfVKqXs99QdQxNQVpftvP1eGVppamXh/OkDAwV737Zr0KPXEqRUMN4uCjh6mjO+XtF3Mw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@15.5.7': - resolution: {integrity: sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==} + '@next/swc-linux-arm64-musl@15.5.12': + resolution: {integrity: sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@15.5.7': - resolution: {integrity: sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==} + '@next/swc-linux-x64-gnu@15.5.12': + resolution: {integrity: sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@15.5.7': - resolution: {integrity: sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==} + '@next/swc-linux-x64-musl@15.5.12': + resolution: {integrity: sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@15.5.7': - resolution: {integrity: sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==} + '@next/swc-win32-arm64-msvc@15.5.12': + resolution: {integrity: sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.5.7': - resolution: {integrity: sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==} + '@next/swc-win32-x64-msvc@15.5.12': + resolution: {integrity: sha512-Z1Dh6lhFkxvBDH1FoW6OU/L6prYwPSlwjLiZkExIAh8fbP6iI/M7iGTQAJPYJ9YFlWobCZ1PHbchFhFYb2ADkw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -979,8 +996,8 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} - '@playwright/test@1.58.0': - resolution: {integrity: sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} engines: {node: '>=18'} hasBin: true @@ -993,14 +1010,14 @@ packages: prisma: optional: true - '@react-aria/focus@3.21.3': - resolution: {integrity: sha512-FsquWvjSCwC2/sBk4b+OqJyONETUIXQ2vM0YdPAuC+QFQh2DT6TIBo6dOZVSezlhudDla69xFBd6JvCFq1AbUw==} + '@react-aria/focus@3.21.4': + resolution: {integrity: sha512-6gz+j9ip0/vFRTKJMl3R30MHopn4i19HqqLfSQfElxJD+r9hBnYG1Q6Wd/kl/WRR1+CALn2F+rn06jUnf5sT8Q==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/interactions@3.26.0': - resolution: {integrity: sha512-AAEcHiltjfbmP1i9iaVw34Mb7kbkiHpYdqieWufldh4aplWgsF11YQZOfaCJW4QoR2ML4Zzoa9nfFwLXA52R7Q==} + '@react-aria/interactions@3.27.0': + resolution: {integrity: sha512-D27pOy+0jIfHK60BB26AgqjjRFOYdvVSkwC31b2LicIzRCSPOSP06V4gMHuGmkhNTF4+YWDi1HHYjxIvMeiSlA==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 @@ -1011,8 +1028,8 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-aria/utils@3.32.0': - resolution: {integrity: sha512-/7Rud06+HVBIlTwmwmJa2W8xVtgxgzm0+kLbuFooZRzKDON6hhozS1dOMR/YLMxyJOaYOTpImcP4vRR9gL1hEg==} + '@react-aria/utils@3.33.0': + resolution: {integrity: sha512-yvz7CMH8d2VjwbSa5nGXqjU031tYhD8ddax95VzJsHSPyqHDEGfxul8RkhGV6oO7bVqZxVs6xY66NIgae+FHjw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 @@ -1034,8 +1051,8 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@react-types/shared@3.32.1': - resolution: {integrity: sha512-famxyD5emrGGpFuUlgOP6fVW2h/ZaF405G5KDi3zPHzyjAWys/8W6NAVJtNbkCkhedmvL0xOhvt8feGXyXaw5w==} + '@react-types/shared@3.33.0': + resolution: {integrity: sha512-xuUpP6MyuPmJtzNOqF5pzFUIHH2YogyOQfUQHag54PRmWB7AbjuGWBUv0l1UDmz6+AbzAYGmDVAzcRDOu2PFpw==} peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 @@ -1061,8 +1078,8 @@ packages: resolution: {integrity: sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==} engines: {node: '>=18.0.0'} - '@smithy/core@3.22.1': - resolution: {integrity: sha512-x3ie6Crr58MWrm4viHqqy2Du2rHYZjwu8BekasrQx4ca+Y24dzVAwq3yErdqIbc2G3I0kLQA13PQ+/rde+u65g==} + '@smithy/core@3.23.0': + resolution: {integrity: sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg==} engines: {node: '>=18.0.0'} '@smithy/credential-provider-imds@4.2.8': @@ -1125,12 +1142,12 @@ packages: resolution: {integrity: sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==} engines: {node: '>=18.0.0'} - '@smithy/middleware-endpoint@4.4.13': - resolution: {integrity: sha512-x6vn0PjYmGdNuKh/juUJJewZh7MoQ46jYaJ2mvekF4EesMuFfrl4LaW/k97Zjf8PTCPQmPgMvwewg7eNoH9n5w==} + '@smithy/middleware-endpoint@4.4.14': + resolution: {integrity: sha512-FUFNE5KVeaY6U/GL0nzAAHkaCHzXLZcY1EhtQnsAqhD8Du13oPKtMB9/0WK4/LK6a/T5OZ24wPoSShff5iI6Ag==} engines: {node: '>=18.0.0'} - '@smithy/middleware-retry@4.4.30': - resolution: {integrity: sha512-CBGyFvN0f8hlnqKH/jckRDz78Snrp345+PVk8Ux7pnkUCW97Iinse59lY78hBt04h1GZ6hjBN94BRwZy1xC8Bg==} + '@smithy/middleware-retry@4.4.31': + resolution: {integrity: sha512-RXBzLpMkIrxBPe4C8OmEOHvS8aH9RUuCOH++Acb5jZDEblxDjyg6un72X9IcbrGTJoiUwmI7hLypNfuDACypbg==} engines: {node: '>=18.0.0'} '@smithy/middleware-serde@4.2.9': @@ -1145,8 +1162,8 @@ packages: resolution: {integrity: sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==} engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.4.9': - resolution: {integrity: sha512-KX5Wml5mF+luxm1szW4QDz32e3NObgJ4Fyw+irhph4I/2geXwUy4jkIMUs5ZPGflRBeR6BUkC2wqIab4Llgm3w==} + '@smithy/node-http-handler@4.4.10': + resolution: {integrity: sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA==} engines: {node: '>=18.0.0'} '@smithy/property-provider@4.2.8': @@ -1177,8 +1194,8 @@ packages: resolution: {integrity: sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==} engines: {node: '>=18.0.0'} - '@smithy/smithy-client@4.11.2': - resolution: {integrity: sha512-SCkGmFak/xC1n7hKRsUr6wOnBTJ3L22Qd4e8H1fQIuKTAjntwgU8lrdMe7uHdiT2mJAOWA/60qaW9tiMu69n1A==} + '@smithy/smithy-client@4.11.3': + resolution: {integrity: sha512-Q7kY5sDau8OoE6Y9zJoRGgje8P4/UY0WzH8R2ok0PDh+iJ+ZnEKowhjEqYafVcubkbYxQVaqwm3iufktzhprGg==} engines: {node: '>=18.0.0'} '@smithy/types@4.12.0': @@ -1213,12 +1230,12 @@ packages: resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-browser@4.3.29': - resolution: {integrity: sha512-nIGy3DNRmOjaYaaKcQDzmWsro9uxlaqUOhZDHQed9MW/GmkBZPtnU70Pu1+GT9IBmUXwRdDuiyaeiy9Xtpn3+Q==} + '@smithy/util-defaults-mode-browser@4.3.30': + resolution: {integrity: sha512-cMni0uVU27zxOiU8TuC8pQLC1pYeZ/xEMxvchSK/ILwleRd1ugobOcIRr5vXtcRqKd4aBLWlpeBoDPJJ91LQng==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-node@4.2.32': - resolution: {integrity: sha512-7dtFff6pu5fsjqrVve0YMhrnzJtccCWDacNKOkiZjJ++fmjGExmmSu341x+WU6Oc1IccL7lDuaUj7SfrHpWc5Q==} + '@smithy/util-defaults-mode-node@4.2.33': + resolution: {integrity: sha512-LEb2aq5F4oZUSzWBG7S53d4UytZSkOEJPXcBq/xbG2/TmK9EW5naUZ8lKu1BEyWMzdHIzEVN16M3k8oxDq+DJA==} engines: {node: '>=18.0.0'} '@smithy/util-endpoints@3.2.8': @@ -1237,8 +1254,8 @@ packages: resolution: {integrity: sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==} engines: {node: '>=18.0.0'} - '@smithy/util-stream@4.5.11': - resolution: {integrity: sha512-lKmZ0S/3Qj2OF5H1+VzvDLb6kRxGzZHq6f3rAsoSu5cTLGsn3v3VQBA8czkNNXlLjoFEtVu3OQT2jEeOtOE2CA==} + '@smithy/util-stream@4.5.12': + resolution: {integrity: sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg==} engines: {node: '>=18.0.0'} '@smithy/util-uri-escape@4.2.0': @@ -1299,6 +1316,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/ffprobe-static@2.0.3': + resolution: {integrity: sha512-86Q8dIzhjknxA6935ZcVLkVU3t6bP+F08mdriWZk2+3UqjxIbT3QEADjbO5Yq8826cVs/aZBREVZ7jUoSTgHiw==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -1317,8 +1337,11 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@20.19.30': - resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==} + '@types/node@10.17.60': + resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==} + + '@types/node@20.19.33': + resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==} '@types/pg@8.16.0': resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} @@ -1328,8 +1351,8 @@ packages: peerDependencies: '@types/react': ^19.2.0 - '@types/react@19.2.9': - resolution: {integrity: sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==} + '@types/react@19.2.13': + resolution: {integrity: sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==} '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -1340,63 +1363,63 @@ packages: '@types/uuid@10.0.0': resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} - '@typescript-eslint/eslint-plugin@8.53.1': - resolution: {integrity: sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag==} + '@typescript-eslint/eslint-plugin@8.55.0': + resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.53.1 + '@typescript-eslint/parser': ^8.55.0 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.53.1': - resolution: {integrity: sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==} + '@typescript-eslint/parser@8.55.0': + resolution: {integrity: sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.53.1': - resolution: {integrity: sha512-WYC4FB5Ra0xidsmlPb+1SsnaSKPmS3gsjIARwbEkHkoWloQmuzcfypljaJcR78uyLA1h8sHdWWPHSLDI+MtNog==} + '@typescript-eslint/project-service@8.55.0': + resolution: {integrity: sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.53.1': - resolution: {integrity: sha512-Lu23yw1uJMFY8cUeq7JlrizAgeQvWugNQzJp8C3x8Eo5Jw5Q2ykMdiiTB9vBVOOUBysMzmRRmUfwFrZuI2C4SQ==} + '@typescript-eslint/scope-manager@8.55.0': + resolution: {integrity: sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.53.1': - resolution: {integrity: sha512-qfvLXS6F6b1y43pnf0pPbXJ+YoXIC7HKg0UGZ27uMIemKMKA6XH2DTxsEDdpdN29D+vHV07x/pnlPNVLhdhWiA==} + '@typescript-eslint/tsconfig-utils@8.55.0': + resolution: {integrity: sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.53.1': - resolution: {integrity: sha512-MOrdtNvyhy0rHyv0ENzub1d4wQYKb2NmIqG7qEqPWFW7Mpy2jzFC3pQ2yKDvirZB7jypm5uGjF2Qqs6OIqu47w==} + '@typescript-eslint/type-utils@8.55.0': + resolution: {integrity: sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.53.1': - resolution: {integrity: sha512-jr/swrr2aRmUAUjW5/zQHbMaui//vQlsZcJKijZf3M26bnmLj8LyZUpj8/Rd6uzaek06OWsqdofN/Thenm5O8A==} + '@typescript-eslint/types@8.55.0': + resolution: {integrity: sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.53.1': - resolution: {integrity: sha512-RGlVipGhQAG4GxV1s34O91cxQ/vWiHJTDHbXRr0li2q/BGg3RR/7NM8QDWgkEgrwQYCvmJV9ichIwyoKCQ+DTg==} + '@typescript-eslint/typescript-estree@8.55.0': + resolution: {integrity: sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.53.1': - resolution: {integrity: sha512-c4bMvGVWW4hv6JmDUEG7fSYlWOl3II2I4ylt0NM+seinYQlZMQIaKaXIIVJWt9Ofh6whrpM+EdDQXKXjNovvrg==} + '@typescript-eslint/utils@8.55.0': + resolution: {integrity: sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.53.1': - resolution: {integrity: sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg==} + '@typescript-eslint/visitor-keys@8.55.0': + resolution: {integrity: sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': @@ -1537,6 +1560,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -1625,8 +1652,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - better-auth@1.4.17: - resolution: {integrity: sha512-VmHGQyKsEahkEs37qguROKg/6ypYpNF13D7v/lkbO7w7Aivz0Bv2h+VyUkH4NzrGY0QBKXi1577mGhDCVwp0ew==} + better-auth@1.4.18: + resolution: {integrity: sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg==} peerDependencies: '@lynx-js/react': '*' '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -1709,8 +1736,8 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - bowser@2.13.1: - resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -1748,13 +1775,16 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} - caniuse-lite@1.0.30001766: - resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==} + caniuse-lite@1.0.30001769: + resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} canvas@3.2.1: resolution: {integrity: sha512-ej1sPFR5+0YWtaVp6S1N1FVz69TQCqmrkGeRvQxZeAB1nAIcjNTHVwrZtYtWFFBmQsF40/uDLehsW5KuYC99mg==} engines: {node: ^18.12.0 || >= 20.9.0} + caseless@0.12.0: + resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -1788,8 +1818,8 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} - cmpstr@3.2.0: - resolution: {integrity: sha512-ZNAEZBm+dfAA103DrGWg87benkl64PHAqRV0UL8xsHVMPqYcEq9WoO5hghQyVRKYiK3aS4T7/kH5TVz+ID5Fiw==} + cmpstr@3.2.1: + resolution: {integrity: sha512-BgOb4IfBTxZ8/VdAGlhmhTv3Um9YJTXrqbkGzkhEJiZ55+W6SbMJL+HiCBZh4TMdzJ45sYrXlyf9KU47QGpeqA==} engines: {node: '>=18.0.0'} color-convert@2.0.1: @@ -1813,6 +1843,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + core-js@3.48.0: resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} @@ -1910,8 +1944,8 @@ packages: dexie: '>=4.2.0-alpha.1 <5.0.0' react: '>=16' - dexie@4.2.1: - resolution: {integrity: sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg==} + dexie@4.3.0: + resolution: {integrity: sha512-5EeoQpJvMKHe6zWt/FSIIuRa3CWlZeIl6zKXt+Lz7BU6RoRRLgX9dZEynRfXrkLcldKYCBiz7xekTEylnie1Ug==} didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -1926,12 +1960,12 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} - dotenv@17.2.3: - resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + dotenv@17.2.4: + resolution: {integrity: sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==} engines: {node: '>=12'} - drizzle-kit@0.31.8: - resolution: {integrity: sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg==} + drizzle-kit@0.31.9: + resolution: {integrity: sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==} hasBin: true drizzle-orm@0.45.1: @@ -2043,6 +2077,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + epubjs@0.3.93: resolution: {integrity: sha512-c06pNSdBxcXv3dZSbXAVLE1/pmleRhOT6mXNZo6INKmvuKpYB65MwU/lO7830czCtjIiK9i+KR+3S+p0wtljrw==} @@ -2112,8 +2150,8 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - eslint-config-next@15.5.9: - resolution: {integrity: sha512-852JYI3NkFNzW8CqsMhI0K2CDRxTObdZ2jQJj5CtpEaOkYHn13107tHpNuD/h0WRpU4FAbCdUaxQsrfBtNK9Kw==} + eslint-config-next@15.5.12: + resolution: {integrity: sha512-ktW3XLfd+ztEltY5scJNjxjHwtKWk6vU2iwzZqSN09UsbBmMeE/cVlJ1yESg6Yx5LW7p/Z8WzUAgYXGLEmGIpg==} peerDependencies: eslint: ^7.23.0 || ^8.0.0 || ^9.0.0 typescript: '>=3.3.1' @@ -2281,6 +2319,13 @@ packages: picomatch: optional: true + ffmpeg-static@5.3.0: + resolution: {integrity: sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==} + engines: {node: '>=16'} + + ffprobe-static@3.1.0: + resolution: {integrity: sha512-Dvpa9uhVMOYivhHKWLGDoa512J751qN1WZAIO+Xw4L/mrUSPxS4DApzSUDbCFE/LUq2+xYnznEahTd63AqBSpA==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -2350,8 +2395,8 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} - get-tsconfig@4.13.0: - resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} @@ -2427,6 +2472,13 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + http-response-object@3.0.2: + resolution: {integrity: sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -2635,8 +2687,8 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - kysely@0.28.10: - resolution: {integrity: sha512-ksNxfzIW77OcZ+QWSAPC7yDqUSaIVwkTWnTPNiIy//vifNbwsSgQ57OkkncHxxpcBHM3LRfLAZVEh7kjq5twVA==} + kysely@0.28.11: + resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==} engines: {node: '>=20.0.0'} language-subtag-registry@0.3.23: @@ -2683,8 +2735,8 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - lru-cache@11.2.4: - resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} + lru-cache@11.2.6: + resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} engines: {node: 20 || >=22} make-cancellable-promise@1.3.2: @@ -2894,8 +2946,8 @@ packages: next-tick@1.1.0: resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} - next@15.5.9: - resolution: {integrity: sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==} + next@15.5.12: + resolution: {integrity: sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: @@ -2965,8 +3017,8 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - openai@6.16.0: - resolution: {integrity: sha512-fZ1uBqjFUjXzbGc35fFtYKEOxd20kd9fDpFeqWtsOZWiubY8CZ1NAlXHW3iathaFvqmNtCWMIsosCuyeI7Joxg==} + openai@6.21.0: + resolution: {integrity: sha512-26dQFi76dB8IiN/WKGQOV+yKKTTlRCxQjoi2WLt0kMcH8pvxVyvfdBDkld5GTl7W1qvBpwVOtFcsqktj3fBRpA==} hasBin: true peerDependencies: ws: ^8.18.0 @@ -3000,6 +3052,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-cache-control@1.0.1: + resolution: {integrity: sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -3028,8 +3083,8 @@ packages: pg-cloudflare@1.3.0: resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} - pg-connection-string@2.10.1: - resolution: {integrity: sha512-iNzslsoeSH2/gmDDKiyMqF64DATUCWj3YJ0wP14kqcsf2TUklwimd+66yYojKwZCA7h2yRNLGug71hCBA2a4sw==} + pg-connection-string@2.11.0: + resolution: {integrity: sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==} pg-int8@1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} @@ -3047,8 +3102,8 @@ packages: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} - pg@8.17.2: - resolution: {integrity: sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw==} + pg@8.18.0: + resolution: {integrity: sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==} engines: {node: '>= 16.0.0'} peerDependencies: pg-native: '>=3.0.1' @@ -3078,13 +3133,13 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} - playwright-core@1.58.0: - resolution: {integrity: sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} engines: {node: '>=18'} hasBin: true - playwright@1.58.0: - resolution: {integrity: sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==} + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} engines: {node: '>=18'} hasBin: true @@ -3175,6 +3230,10 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -3213,13 +3272,13 @@ packages: '@types/react': optional: true - react-dom@19.2.3: - resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: - react: ^19.2.3 + react: ^19.2.4 - react-dropzone@14.3.8: - resolution: {integrity: sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==} + react-dropzone@14.4.1: + resolution: {integrity: sha512-QDuV76v3uKbHiH34SpwifZ+gOLi1+RdsCO1kl5vxMT4wW8R82+sthjvBw4th3NHF/XX6FBsqDYZVNN+pnhaw0g==} engines: {node: '>= 10.13'} peerDependencies: react: '>= 16.8 || 18.0.0' @@ -3258,8 +3317,8 @@ packages: peerDependencies: react: ^16.8.3 || ^17 || ^18 || ^19.0.0 || ^19.0.0-rc - react@19.2.3: - resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} read-cache@1.0.0: @@ -3350,8 +3409,8 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true @@ -3594,6 +3653,9 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -3744,7 +3806,7 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-sdk/client-s3@3.985.0': + '@aws-sdk/client-s3@3.987.0': dependencies: '@aws-crypto/sha1-browser': 5.2.0 '@aws-crypto/sha256-browser': 5.2.0 @@ -3762,13 +3824,13 @@ snapshots: '@aws-sdk/middleware-ssec': 3.972.3 '@aws-sdk/middleware-user-agent': 3.972.7 '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/signature-v4-multi-region': 3.985.0 + '@aws-sdk/signature-v4-multi-region': 3.987.0 '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.985.0 + '@aws-sdk/util-endpoints': 3.987.0 '@aws-sdk/util-user-agent-browser': 3.972.3 '@aws-sdk/util-user-agent-node': 3.972.5 '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.22.1 + '@smithy/core': 3.23.0 '@smithy/eventstream-serde-browser': 4.2.8 '@smithy/eventstream-serde-config-resolver': 4.3.8 '@smithy/eventstream-serde-node': 4.2.8 @@ -3779,25 +3841,25 @@ snapshots: '@smithy/invalid-dependency': 4.2.8 '@smithy/md5-js': 4.2.8 '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.13 - '@smithy/middleware-retry': 4.4.30 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-retry': 4.4.31 '@smithy/middleware-serde': 4.2.9 '@smithy/middleware-stack': 4.2.8 '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.9 + '@smithy/node-http-handler': 4.4.10 '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.2 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 '@smithy/url-parser': 4.2.8 '@smithy/util-base64': 4.3.0 '@smithy/util-body-length-browser': 4.2.0 '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.29 - '@smithy/util-defaults-mode-node': 4.2.32 + '@smithy/util-defaults-mode-browser': 4.3.30 + '@smithy/util-defaults-mode-node': 4.2.33 '@smithy/util-endpoints': 3.2.8 '@smithy/util-middleware': 4.2.8 '@smithy/util-retry': 4.2.8 - '@smithy/util-stream': 4.5.11 + '@smithy/util-stream': 4.5.12 '@smithy/util-utf8': 4.2.0 '@smithy/util-waiter': 4.2.8 tslib: 2.8.1 @@ -3819,26 +3881,26 @@ snapshots: '@aws-sdk/util-user-agent-browser': 3.972.3 '@aws-sdk/util-user-agent-node': 3.972.5 '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.22.1 + '@smithy/core': 3.23.0 '@smithy/fetch-http-handler': 5.3.9 '@smithy/hash-node': 4.2.8 '@smithy/invalid-dependency': 4.2.8 '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.13 - '@smithy/middleware-retry': 4.4.30 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-retry': 4.4.31 '@smithy/middleware-serde': 4.2.9 '@smithy/middleware-stack': 4.2.8 '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.9 + '@smithy/node-http-handler': 4.4.10 '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.2 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 '@smithy/url-parser': 4.2.8 '@smithy/util-base64': 4.3.0 '@smithy/util-body-length-browser': 4.2.0 '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.29 - '@smithy/util-defaults-mode-node': 4.2.32 + '@smithy/util-defaults-mode-browser': 4.3.30 + '@smithy/util-defaults-mode-node': 4.2.33 '@smithy/util-endpoints': 3.2.8 '@smithy/util-middleware': 4.2.8 '@smithy/util-retry': 4.2.8 @@ -3851,12 +3913,12 @@ snapshots: dependencies: '@aws-sdk/types': 3.973.1 '@aws-sdk/xml-builder': 3.972.4 - '@smithy/core': 3.22.1 + '@smithy/core': 3.23.0 '@smithy/node-config-provider': 4.3.8 '@smithy/property-provider': 4.2.8 '@smithy/protocol-http': 5.3.8 '@smithy/signature-v4': 5.3.8 - '@smithy/smithy-client': 4.11.2 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 '@smithy/util-base64': 4.3.0 '@smithy/util-middleware': 4.2.8 @@ -3881,12 +3943,12 @@ snapshots: '@aws-sdk/core': 3.973.7 '@aws-sdk/types': 3.973.1 '@smithy/fetch-http-handler': 5.3.9 - '@smithy/node-http-handler': 4.4.9 + '@smithy/node-http-handler': 4.4.10 '@smithy/property-provider': 4.2.8 '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.2 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 - '@smithy/util-stream': 4.5.11 + '@smithy/util-stream': 4.5.12 tslib: 2.8.1 '@aws-sdk/credential-provider-ini@3.972.5': @@ -4002,7 +4064,7 @@ snapshots: '@smithy/protocol-http': 5.3.8 '@smithy/types': 4.12.0 '@smithy/util-middleware': 4.2.8 - '@smithy/util-stream': 4.5.11 + '@smithy/util-stream': 4.5.12 '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 @@ -4038,15 +4100,15 @@ snapshots: '@aws-sdk/core': 3.973.7 '@aws-sdk/types': 3.973.1 '@aws-sdk/util-arn-parser': 3.972.2 - '@smithy/core': 3.22.1 + '@smithy/core': 3.23.0 '@smithy/node-config-provider': 4.3.8 '@smithy/protocol-http': 5.3.8 '@smithy/signature-v4': 5.3.8 - '@smithy/smithy-client': 4.11.2 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 '@smithy/util-config-provider': 4.2.0 '@smithy/util-middleware': 4.2.8 - '@smithy/util-stream': 4.5.11 + '@smithy/util-stream': 4.5.12 '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 @@ -4061,7 +4123,7 @@ snapshots: '@aws-sdk/core': 3.973.7 '@aws-sdk/types': 3.973.1 '@aws-sdk/util-endpoints': 3.985.0 - '@smithy/core': 3.22.1 + '@smithy/core': 3.23.0 '@smithy/protocol-http': 5.3.8 '@smithy/types': 4.12.0 tslib: 2.8.1 @@ -4081,26 +4143,26 @@ snapshots: '@aws-sdk/util-user-agent-browser': 3.972.3 '@aws-sdk/util-user-agent-node': 3.972.5 '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.22.1 + '@smithy/core': 3.23.0 '@smithy/fetch-http-handler': 5.3.9 '@smithy/hash-node': 4.2.8 '@smithy/invalid-dependency': 4.2.8 '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.13 - '@smithy/middleware-retry': 4.4.30 + '@smithy/middleware-endpoint': 4.4.14 + '@smithy/middleware-retry': 4.4.31 '@smithy/middleware-serde': 4.2.9 '@smithy/middleware-stack': 4.2.8 '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.9 + '@smithy/node-http-handler': 4.4.10 '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.2 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 '@smithy/url-parser': 4.2.8 '@smithy/util-base64': 4.3.0 '@smithy/util-body-length-browser': 4.2.0 '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.29 - '@smithy/util-defaults-mode-node': 4.2.32 + '@smithy/util-defaults-mode-browser': 4.3.30 + '@smithy/util-defaults-mode-node': 4.2.33 '@smithy/util-endpoints': 3.2.8 '@smithy/util-middleware': 4.2.8 '@smithy/util-retry': 4.2.8 @@ -4117,18 +4179,18 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/s3-request-presigner@3.985.0': + '@aws-sdk/s3-request-presigner@3.987.0': dependencies: - '@aws-sdk/signature-v4-multi-region': 3.985.0 + '@aws-sdk/signature-v4-multi-region': 3.987.0 '@aws-sdk/types': 3.973.1 '@aws-sdk/util-format-url': 3.972.3 - '@smithy/middleware-endpoint': 4.4.13 + '@smithy/middleware-endpoint': 4.4.14 '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.2 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.985.0': + '@aws-sdk/signature-v4-multi-region@3.987.0': dependencies: '@aws-sdk/middleware-sdk-s3': 3.972.7 '@aws-sdk/types': 3.973.1 @@ -4166,6 +4228,14 @@ snapshots: '@smithy/util-endpoints': 3.2.8 tslib: 2.8.1 + '@aws-sdk/util-endpoints@3.987.0': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-endpoints': 3.2.8 + tslib: 2.8.1 + '@aws-sdk/util-format-url@3.972.3': dependencies: '@aws-sdk/types': 3.973.1 @@ -4181,7 +4251,7 @@ snapshots: dependencies: '@aws-sdk/types': 3.973.1 '@smithy/types': 4.12.0 - bowser: 2.13.1 + bowser: 2.14.1 tslib: 2.8.1 '@aws-sdk/util-user-agent-node@3.972.5': @@ -4202,20 +4272,20 @@ snapshots: '@babel/runtime@7.28.6': {} - '@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0)': + '@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)': dependencies: '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 '@standard-schema/spec': 1.1.0 better-call: 1.1.8(zod@4.3.6) jose: 6.1.3 - kysely: 0.28.10 + kysely: 0.28.11 nanostores: 1.1.0 zod: 4.3.6 - '@better-auth/telemetry@1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0))': + '@better-auth/telemetry@1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))': dependencies: - '@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0) + '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 @@ -4223,6 +4293,13 @@ snapshots: '@better-fetch/fetch@1.1.21': {} + '@derhuerst/http-basic@8.2.4': + dependencies: + caseless: 0.12.0 + concat-stream: 2.0.0 + http-response-object: 3.0.2 + parse-cache-control: 1.0.1 + '@drizzle-team/brocli@0.10.2': {} '@emnapi/core@1.8.1': @@ -4249,7 +4326,7 @@ snapshots: '@esbuild-kit/esm-loader@2.6.5': dependencies: '@esbuild-kit/core-utils': 3.3.2 - get-tsconfig: 4.13.0 + get-tsconfig: 4.13.6 '@esbuild/aix-ppc64@0.25.12': optional: true @@ -4441,40 +4518,40 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@floating-ui/core@1.7.3': + '@floating-ui/core@1.7.4': dependencies: '@floating-ui/utils': 0.2.10 - '@floating-ui/dom@1.7.4': + '@floating-ui/dom@1.7.5': dependencies: - '@floating-ui/core': 1.7.3 + '@floating-ui/core': 1.7.4 '@floating-ui/utils': 0.2.10 - '@floating-ui/react-dom@2.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@floating-ui/react-dom@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@floating-ui/dom': 1.7.4 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + '@floating-ui/dom': 1.7.5 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) - '@floating-ui/react@0.26.28(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@floating-ui/react@0.26.28(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@floating-ui/react-dom': 2.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@floating-ui/utils': 0.2.10 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) tabbable: 6.4.0 '@floating-ui/utils@0.2.10': {} - '@headlessui/react@2.2.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@headlessui/react@2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@floating-ui/react': 0.26.28(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@react-aria/focus': 3.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@react-aria/interactions': 3.26.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/react-virtual': 3.13.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - use-sync-external-store: 1.6.0(react@19.2.3) + '@floating-ui/react': 0.26.28(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/focus': 3.21.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-virtual': 3.13.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.4) '@humanfs/core@0.19.1': {} @@ -4605,34 +4682,34 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@next/env@15.5.9': {} + '@next/env@15.5.12': {} - '@next/eslint-plugin-next@15.5.9': + '@next/eslint-plugin-next@15.5.12': dependencies: fast-glob: 3.3.1 - '@next/swc-darwin-arm64@15.5.7': + '@next/swc-darwin-arm64@15.5.12': optional: true - '@next/swc-darwin-x64@15.5.7': + '@next/swc-darwin-x64@15.5.12': optional: true - '@next/swc-linux-arm64-gnu@15.5.7': + '@next/swc-linux-arm64-gnu@15.5.12': optional: true - '@next/swc-linux-arm64-musl@15.5.7': + '@next/swc-linux-arm64-musl@15.5.12': optional: true - '@next/swc-linux-x64-gnu@15.5.7': + '@next/swc-linux-x64-gnu@15.5.12': optional: true - '@next/swc-linux-x64-musl@15.5.7': + '@next/swc-linux-x64-musl@15.5.12': optional: true - '@next/swc-win32-arm64-msvc@15.5.7': + '@next/swc-win32-arm64-msvc@15.5.12': optional: true - '@next/swc-win32-x64-msvc@15.5.7': + '@next/swc-win32-x64-msvc@15.5.12': optional: true '@noble/ciphers@2.1.1': {} @@ -4653,48 +4730,48 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} - '@playwright/test@1.58.0': + '@playwright/test@1.58.2': dependencies: - playwright: 1.58.0 + playwright: 1.58.2 '@prisma/client@5.22.0': optional: true - '@react-aria/focus@3.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@react-aria/focus@3.21.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/interactions': 3.26.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@react-aria/utils': 3.32.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@react-types/shared': 3.32.1(react@19.2.3) + '@react-aria/interactions': 3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@react-types/shared': 3.33.0(react@19.2.4) '@swc/helpers': 0.5.18 clsx: 2.1.1 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) - '@react-aria/interactions@3.26.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@react-aria/interactions@3.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/ssr': 3.9.10(react@19.2.3) - '@react-aria/utils': 3.32.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@react-aria/ssr': 3.9.10(react@19.2.4) + '@react-aria/utils': 3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@react-stately/flags': 3.1.2 - '@react-types/shared': 3.32.1(react@19.2.3) + '@react-types/shared': 3.33.0(react@19.2.4) '@swc/helpers': 0.5.18 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) - '@react-aria/ssr@3.9.10(react@19.2.3)': + '@react-aria/ssr@3.9.10(react@19.2.4)': dependencies: '@swc/helpers': 0.5.18 - react: 19.2.3 + react: 19.2.4 - '@react-aria/utils@3.32.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@react-aria/utils@3.33.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@react-aria/ssr': 3.9.10(react@19.2.3) + '@react-aria/ssr': 3.9.10(react@19.2.4) '@react-stately/flags': 3.1.2 - '@react-stately/utils': 3.11.0(react@19.2.3) - '@react-types/shared': 3.32.1(react@19.2.3) + '@react-stately/utils': 3.11.0(react@19.2.4) + '@react-types/shared': 3.33.0(react@19.2.4) '@swc/helpers': 0.5.18 clsx: 2.1.1 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) '@react-dnd/asap@5.0.2': {} @@ -4706,14 +4783,14 @@ snapshots: dependencies: '@swc/helpers': 0.5.18 - '@react-stately/utils@3.11.0(react@19.2.3)': + '@react-stately/utils@3.11.0(react@19.2.4)': dependencies: '@swc/helpers': 0.5.18 - react: 19.2.3 + react: 19.2.4 - '@react-types/shared@3.32.1(react@19.2.3)': + '@react-types/shared@3.33.0(react@19.2.4)': dependencies: - react: 19.2.3 + react: 19.2.4 '@rtsao/scc@1.1.0': {} @@ -4742,7 +4819,7 @@ snapshots: '@smithy/util-middleware': 4.2.8 tslib: 2.8.1 - '@smithy/core@3.22.1': + '@smithy/core@3.23.0': dependencies: '@smithy/middleware-serde': 4.2.9 '@smithy/protocol-http': 5.3.8 @@ -4750,7 +4827,7 @@ snapshots: '@smithy/util-base64': 4.3.0 '@smithy/util-body-length-browser': 4.2.0 '@smithy/util-middleware': 4.2.8 - '@smithy/util-stream': 4.5.11 + '@smithy/util-stream': 4.5.12 '@smithy/util-utf8': 4.2.0 '@smithy/uuid': 1.1.0 tslib: 2.8.1 @@ -4846,9 +4923,9 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 - '@smithy/middleware-endpoint@4.4.13': + '@smithy/middleware-endpoint@4.4.14': dependencies: - '@smithy/core': 3.22.1 + '@smithy/core': 3.23.0 '@smithy/middleware-serde': 4.2.9 '@smithy/node-config-provider': 4.3.8 '@smithy/shared-ini-file-loader': 4.4.3 @@ -4857,12 +4934,12 @@ snapshots: '@smithy/util-middleware': 4.2.8 tslib: 2.8.1 - '@smithy/middleware-retry@4.4.30': + '@smithy/middleware-retry@4.4.31': dependencies: '@smithy/node-config-provider': 4.3.8 '@smithy/protocol-http': 5.3.8 '@smithy/service-error-classification': 4.2.8 - '@smithy/smithy-client': 4.11.2 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 '@smithy/util-middleware': 4.2.8 '@smithy/util-retry': 4.2.8 @@ -4887,7 +4964,7 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 - '@smithy/node-http-handler@4.4.9': + '@smithy/node-http-handler@4.4.10': dependencies: '@smithy/abort-controller': 4.2.8 '@smithy/protocol-http': 5.3.8 @@ -4936,14 +5013,14 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 - '@smithy/smithy-client@4.11.2': + '@smithy/smithy-client@4.11.3': dependencies: - '@smithy/core': 3.22.1 - '@smithy/middleware-endpoint': 4.4.13 + '@smithy/core': 3.23.0 + '@smithy/middleware-endpoint': 4.4.14 '@smithy/middleware-stack': 4.2.8 '@smithy/protocol-http': 5.3.8 '@smithy/types': 4.12.0 - '@smithy/util-stream': 4.5.11 + '@smithy/util-stream': 4.5.12 tslib: 2.8.1 '@smithy/types@4.12.0': @@ -4984,20 +5061,20 @@ snapshots: dependencies: tslib: 2.8.1 - '@smithy/util-defaults-mode-browser@4.3.29': + '@smithy/util-defaults-mode-browser@4.3.30': dependencies: '@smithy/property-provider': 4.2.8 - '@smithy/smithy-client': 4.11.2 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 tslib: 2.8.1 - '@smithy/util-defaults-mode-node@4.2.32': + '@smithy/util-defaults-mode-node@4.2.33': dependencies: '@smithy/config-resolver': 4.4.6 '@smithy/credential-provider-imds': 4.2.8 '@smithy/node-config-provider': 4.3.8 '@smithy/property-provider': 4.2.8 - '@smithy/smithy-client': 4.11.2 + '@smithy/smithy-client': 4.11.3 '@smithy/types': 4.12.0 tslib: 2.8.1 @@ -5022,10 +5099,10 @@ snapshots: '@smithy/types': 4.12.0 tslib: 2.8.1 - '@smithy/util-stream@4.5.11': + '@smithy/util-stream@4.5.12': dependencies: '@smithy/fetch-http-handler': 5.3.9 - '@smithy/node-http-handler': 4.4.9 + '@smithy/node-http-handler': 4.4.10 '@smithy/types': 4.12.0 '@smithy/util-base64': 4.3.0 '@smithy/util-buffer-from': 4.2.0 @@ -5072,11 +5149,11 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 3.4.19 - '@tanstack/react-virtual@3.13.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-virtual@3.13.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@tanstack/virtual-core': 3.13.18 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) '@tanstack/virtual-core@3.13.18': {} @@ -5087,7 +5164,7 @@ snapshots: '@types/better-sqlite3@7.6.13': dependencies: - '@types/node': 20.19.30 + '@types/node': 20.19.33 '@types/debug@4.1.12': dependencies: @@ -5099,6 +5176,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/ffprobe-static@2.0.3': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -5115,21 +5194,23 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@20.19.30': + '@types/node@10.17.60': {} + + '@types/node@20.19.33': dependencies: undici-types: 6.21.0 '@types/pg@8.16.0': dependencies: - '@types/node': 20.19.30 + '@types/node': 20.19.33 pg-protocol: 1.11.0 pg-types: 2.2.0 - '@types/react-dom@19.2.3(@types/react@19.2.9)': + '@types/react-dom@19.2.3(@types/react@19.2.13)': dependencies: - '@types/react': 19.2.9 + '@types/react': 19.2.13 - '@types/react@19.2.9': + '@types/react@19.2.13': dependencies: csstype: 3.2.3 @@ -5139,14 +5220,14 @@ snapshots: '@types/uuid@10.0.0': {} - '@typescript-eslint/eslint-plugin@8.53.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.53.1 - '@typescript-eslint/type-utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.53.1 + '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/type-utils': 8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.55.0 eslint: 9.39.2(jiti@1.21.7) ignore: 7.0.5 natural-compare: 1.4.0 @@ -5155,41 +5236,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.53.1 - '@typescript-eslint/types': 8.53.1 - '@typescript-eslint/typescript-estree': 8.53.1(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.53.1 + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.55.0 debug: 4.4.3 eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.53.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.55.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.53.1(typescript@5.9.3) - '@typescript-eslint/types': 8.53.1 + '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.53.1': + '@typescript-eslint/scope-manager@8.55.0': dependencies: - '@typescript-eslint/types': 8.53.1 - '@typescript-eslint/visitor-keys': 8.53.1 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/visitor-keys': 8.55.0 - '@typescript-eslint/tsconfig-utils@8.53.1(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.55.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.53.1 - '@typescript-eslint/typescript-estree': 8.53.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.2(jiti@1.21.7) ts-api-utils: 2.4.0(typescript@5.9.3) @@ -5197,37 +5278,37 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.53.1': {} + '@typescript-eslint/types@8.55.0': {} - '@typescript-eslint/typescript-estree@8.53.1(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.55.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.53.1(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.53.1(typescript@5.9.3) - '@typescript-eslint/types': 8.53.1 - '@typescript-eslint/visitor-keys': 8.53.1 + '@typescript-eslint/project-service': 8.55.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/visitor-keys': 8.55.0 debug: 4.4.3 minimatch: 9.0.5 - semver: 7.7.3 + semver: 7.7.4 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/utils@8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) - '@typescript-eslint/scope-manager': 8.53.1 - '@typescript-eslint/types': 8.53.1 - '@typescript-eslint/typescript-estree': 8.53.1(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.53.1': + '@typescript-eslint/visitor-keys@8.55.0': dependencies: - '@typescript-eslint/types': 8.53.1 + '@typescript-eslint/types': 8.55.0 eslint-visitor-keys: 4.2.1 '@ungap/structured-clone@1.3.0': {} @@ -5291,10 +5372,10 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vercel/analytics@1.6.1(next@15.5.9(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': + '@vercel/analytics@1.6.1(next@15.5.12(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': optionalDependencies: - next: 15.5.9(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react: 19.2.3 + next: 15.5.12(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 '@xmldom/xmldom@0.9.8': {} @@ -5304,6 +5385,12 @@ snapshots: acorn@8.15.0: {} + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -5415,10 +5502,10 @@ snapshots: base64-js@1.5.1: {} - better-auth@1.4.17(@prisma/client@5.22.0)(better-sqlite3@12.6.2)(drizzle-kit@0.31.8)(drizzle-orm@0.45.1(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2))(next@15.5.9(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(pg@8.17.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + better-auth@1.4.18(@prisma/client@5.22.0)(better-sqlite3@12.6.2)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0))(next@15.5.12(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - '@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0) - '@better-auth/telemetry': 1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0)) + '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) + '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.1.1 @@ -5426,18 +5513,18 @@ snapshots: better-call: 1.1.8(zod@4.3.6) defu: 6.1.4 jose: 6.1.3 - kysely: 0.28.10 + kysely: 0.28.11 nanostores: 1.1.0 zod: 4.3.6 optionalDependencies: '@prisma/client': 5.22.0 better-sqlite3: 12.6.2 - drizzle-kit: 0.31.8 - drizzle-orm: 0.45.1(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2) - next: 15.5.9(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - pg: 8.17.2 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + drizzle-kit: 0.31.9 + drizzle-orm: 0.45.1(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0) + next: 15.5.12(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + pg: 8.18.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) better-call@1.1.8(zod@4.3.6): dependencies: @@ -5465,7 +5552,7 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - bowser@2.13.1: {} + bowser@2.14.1: {} brace-expansion@1.1.12: dependencies: @@ -5508,7 +5595,7 @@ snapshots: camelcase-css@2.0.1: {} - caniuse-lite@1.0.30001766: {} + caniuse-lite@1.0.30001769: {} canvas@3.2.1: dependencies: @@ -5516,6 +5603,8 @@ snapshots: prebuild-install: 7.1.3 optional: true + caseless@0.12.0: {} + ccount@2.0.1: {} chalk@4.1.2: @@ -5549,7 +5638,7 @@ snapshots: clsx@2.1.1: {} - cmpstr@3.2.0: {} + cmpstr@3.2.1: {} color-convert@2.0.1: dependencies: @@ -5569,6 +5658,13 @@ snapshots: concat-map@0.0.1: {} + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + core-js@3.48.0: {} core-util-is@1.0.3: {} @@ -5650,13 +5746,13 @@ snapshots: dependencies: dequal: 2.0.3 - dexie-react-hooks@4.2.0(@types/react@19.2.9)(dexie@4.2.1)(react@19.2.3): + dexie-react-hooks@4.2.0(@types/react@19.2.13)(dexie@4.3.0)(react@19.2.4): dependencies: - '@types/react': 19.2.9 - dexie: 4.2.1 - react: 19.2.3 + '@types/react': 19.2.13 + dexie: 4.3.0 + react: 19.2.4 - dexie@4.2.1: {} + dexie@4.3.0: {} didyoumean@1.2.2: {} @@ -5672,9 +5768,9 @@ snapshots: dependencies: esutils: 2.0.3 - dotenv@17.2.3: {} + dotenv@17.2.4: {} - drizzle-kit@0.31.8: + drizzle-kit@0.31.9: dependencies: '@drizzle-team/brocli': 0.10.2 '@esbuild-kit/esm-loader': 2.6.5 @@ -5683,14 +5779,14 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.45.1(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2): + drizzle-orm@0.45.1(@prisma/client@5.22.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(pg@8.18.0): optionalDependencies: '@prisma/client': 5.22.0 '@types/better-sqlite3': 7.6.13 '@types/pg': 8.16.0 better-sqlite3: 12.6.2 - kysely: 0.28.10 - pg: 8.17.2 + kysely: 0.28.11 + pg: 8.18.0 dunder-proto@1.0.1: dependencies: @@ -5708,6 +5804,8 @@ snapshots: dependencies: once: 1.4.0 + env-paths@2.2.1: {} + epubjs@0.3.93: dependencies: '@types/localforage': empty-module@0.0.2 @@ -5904,16 +6002,16 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-next@15.5.9(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): + eslint-config-next@15.5.12(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): dependencies: - '@next/eslint-plugin-next': 15.5.9 + '@next/eslint-plugin-next': 15.5.12 '@rushstack/eslint-patch': 1.15.0 - '@typescript-eslint/eslint-plugin': 8.53.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/parser': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@1.21.7)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@1.21.7)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.2(jiti@1.21.7)) @@ -5937,28 +6035,28 @@ snapshots: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 eslint: 9.39.2(jiti@1.21.7) - get-tsconfig: 4.13.0 + get-tsconfig: 4.13.6 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -5969,7 +6067,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -5981,7 +6079,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -6156,6 +6254,17 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + ffmpeg-static@5.3.0: + dependencies: + '@derhuerst/http-basic': 8.2.4 + env-paths: 2.2.1 + https-proxy-agent: 5.0.1 + progress: 2.0.3 + transitivePeerDependencies: + - supports-color + + ffprobe-static@3.1.0: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -6233,7 +6342,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 - get-tsconfig@4.13.0: + get-tsconfig@4.13.6: dependencies: resolve-pkg-maps: 1.0.0 @@ -6316,6 +6425,17 @@ snapshots: html-url-attributes@3.0.1: {} + http-response-object@3.0.2: + dependencies: + '@types/node': 10.17.60 + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -6379,7 +6499,7 @@ snapshots: is-bun-module@2.0.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 is-callable@1.2.7: {} @@ -6525,7 +6645,7 @@ snapshots: dependencies: json-buffer: 3.0.1 - kysely@0.28.10: {} + kysely@0.28.11: {} language-subtag-registry@0.3.23: {} @@ -6568,7 +6688,7 @@ snapshots: dependencies: js-tokens: 4.0.0 - lru-cache@11.2.4: {} + lru-cache@11.2.6: {} make-cancellable-promise@1.3.2: {} @@ -6733,9 +6853,9 @@ snapshots: dependencies: '@types/mdast': 4.0.4 - merge-refs@1.3.0(@types/react@19.2.9): + merge-refs@1.3.0(@types/react@19.2.13): optionalDependencies: - '@types/react': 19.2.9 + '@types/react': 19.2.13 merge2@1.4.1: {} @@ -6969,25 +7089,25 @@ snapshots: next-tick@1.1.0: {} - next@15.5.9(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@15.5.12(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - '@next/env': 15.5.9 + '@next/env': 15.5.12 '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001766 + caniuse-lite: 1.0.30001769 postcss: 8.4.31 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(react@19.2.3) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + styled-jsx: 5.1.6(react@19.2.4) optionalDependencies: - '@next/swc-darwin-arm64': 15.5.7 - '@next/swc-darwin-x64': 15.5.7 - '@next/swc-linux-arm64-gnu': 15.5.7 - '@next/swc-linux-arm64-musl': 15.5.7 - '@next/swc-linux-x64-gnu': 15.5.7 - '@next/swc-linux-x64-musl': 15.5.7 - '@next/swc-win32-arm64-msvc': 15.5.7 - '@next/swc-win32-x64-msvc': 15.5.7 - '@playwright/test': 1.58.0 + '@next/swc-darwin-arm64': 15.5.12 + '@next/swc-darwin-x64': 15.5.12 + '@next/swc-linux-arm64-gnu': 15.5.12 + '@next/swc-linux-arm64-musl': 15.5.12 + '@next/swc-linux-x64-gnu': 15.5.12 + '@next/swc-linux-x64-musl': 15.5.12 + '@next/swc-win32-arm64-msvc': 15.5.12 + '@next/swc-win32-x64-msvc': 15.5.12 + '@playwright/test': 1.58.2 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -6995,7 +7115,7 @@ snapshots: node-abi@3.87.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 node-addon-api@7.1.1: optional: true @@ -7050,7 +7170,7 @@ snapshots: dependencies: wrappy: 1.0.2 - openai@6.16.0(zod@4.3.6): + openai@6.21.0(zod@4.3.6): optionalDependencies: zod: 4.3.6 @@ -7083,6 +7203,8 @@ snapshots: dependencies: callsites: 3.1.0 + parse-cache-control@1.0.1: {} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -7112,13 +7234,13 @@ snapshots: pg-cloudflare@1.3.0: optional: true - pg-connection-string@2.10.1: {} + pg-connection-string@2.11.0: {} pg-int8@1.0.1: {} - pg-pool@3.11.0(pg@8.17.2): + pg-pool@3.11.0(pg@8.18.0): dependencies: - pg: 8.17.2 + pg: 8.18.0 pg-protocol@1.11.0: {} @@ -7130,10 +7252,10 @@ snapshots: postgres-date: 1.0.7 postgres-interval: 1.2.0 - pg@8.17.2: + pg@8.18.0: dependencies: - pg-connection-string: 2.10.1 - pg-pool: 3.11.0(pg@8.17.2) + pg-connection-string: 2.11.0 + pg-pool: 3.11.0(pg@8.18.0) pg-protocol: 1.11.0 pg-types: 2.2.0 pgpass: 1.0.5 @@ -7154,11 +7276,11 @@ snapshots: pirates@4.0.7: {} - playwright-core@1.58.0: {} + playwright-core@1.58.2: {} - playwright@1.58.0: + playwright@1.58.2: dependencies: - playwright-core: 1.58.0 + playwright-core: 1.58.2 optionalDependencies: fsevents: 2.3.2 @@ -7241,6 +7363,8 @@ snapshots: process-nextick-args@2.0.1: {} + progress@2.0.3: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -7269,49 +7393,49 @@ snapshots: dependencies: dnd-core: 16.0.1 - react-dnd@16.0.1(@types/node@20.19.30)(@types/react@19.2.9)(react@19.2.3): + react-dnd@16.0.1(@types/node@20.19.33)(@types/react@19.2.13)(react@19.2.4): dependencies: '@react-dnd/invariant': 4.0.2 '@react-dnd/shallowequal': 4.0.2 dnd-core: 16.0.1 fast-deep-equal: 3.1.3 hoist-non-react-statics: 3.3.2 - react: 19.2.3 + react: 19.2.4 optionalDependencies: - '@types/node': 20.19.30 - '@types/react': 19.2.9 + '@types/node': 20.19.33 + '@types/react': 19.2.13 - react-dom@19.2.3(react@19.2.3): + react-dom@19.2.4(react@19.2.4): dependencies: - react: 19.2.3 + react: 19.2.4 scheduler: 0.27.0 - react-dropzone@14.3.8(react@19.2.3): + react-dropzone@14.4.1(react@19.2.4): dependencies: attr-accept: 2.2.5 file-selector: 2.1.2 prop-types: 15.8.1 - react: 19.2.3 + react: 19.2.4 - react-hot-toast@2.6.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + react-hot-toast@2.6.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: csstype: 3.2.3 goober: 2.1.18(csstype@3.2.3) - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) react-is@16.13.1: {} - react-markdown@10.1.0(@types/react@19.2.9)(react@19.2.3): + react-markdown@10.1.0(@types/react@19.2.13)(react@19.2.4): dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 - '@types/react': 19.2.9 + '@types/react': 19.2.13 devlop: 1.1.0 hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 mdast-util-to-hast: 13.2.1 - react: 19.2.3 + react: 19.2.4 remark-parse: 11.0.0 remark-rehype: 11.1.2 unified: 11.0.5 @@ -7320,33 +7444,33 @@ snapshots: transitivePeerDependencies: - supports-color - react-pdf@9.2.1(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + react-pdf@9.2.1(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: clsx: 2.1.1 dequal: 2.0.3 make-cancellable-promise: 1.3.2 make-event-props: 1.6.2 - merge-refs: 1.3.0(@types/react@19.2.9) + merge-refs: 1.3.0(@types/react@19.2.13) pdfjs-dist: 4.8.69 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) tiny-invariant: 1.3.3 warning: 4.0.3 optionalDependencies: - '@types/react': 19.2.9 + '@types/react': 19.2.13 - react-reader@2.0.15(react@19.2.3): + react-reader@2.0.15(react@19.2.4): dependencies: epubjs: 0.3.93 - react-swipeable: 7.0.2(react@19.2.3) + react-swipeable: 7.0.2(react@19.2.4) transitivePeerDependencies: - react - react-swipeable@7.0.2(react@19.2.3): + react-swipeable@7.0.2(react@19.2.4): dependencies: - react: 19.2.3 + react: 19.2.4 - react@19.2.3: {} + react@19.2.4: {} read-cache@1.0.0: dependencies: @@ -7481,7 +7605,7 @@ snapshots: semver@6.3.1: {} - semver@7.7.3: {} + semver@7.7.4: {} set-cookie-parser@2.7.2: {} @@ -7513,7 +7637,7 @@ snapshots: dependencies: '@img/colour': 1.0.0 detect-libc: 2.1.2 - semver: 7.7.3 + semver: 7.7.4 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 @@ -7682,10 +7806,10 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(react@19.2.3): + styled-jsx@5.1.6(react@19.2.4): dependencies: client-only: 0.0.1 - react: 19.2.3 + react: 19.2.4 sucrase@3.35.1: dependencies: @@ -7831,6 +7955,8 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 + typedarray@0.0.6: {} + typescript@5.9.3: {} unbox-primitive@1.1.0: @@ -7903,9 +8029,9 @@ snapshots: dependencies: punycode: 2.3.1 - use-sync-external-store@1.6.0(react@19.2.3): + use-sync-external-store@1.6.0(react@19.2.4): dependencies: - react: 19.2.3 + react: 19.2.4 util-deprecate@1.0.2: {} diff --git a/scripts/migrate-fs-v2.mjs b/scripts/migrate-fs-v2.mjs new file mode 100644 index 0000000..97cb3d9 --- /dev/null +++ b/scripts/migrate-fs-v2.mjs @@ -0,0 +1,919 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +import * as dotenv from 'dotenv'; +import { + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; + +const require = createRequire(import.meta.url); +const { Pool } = require('pg'); +const BetterSqlite3 = require('better-sqlite3'); +const ffprobeStatic = require('ffprobe-static'); + +const DOCSTORE_DIR = path.join(process.cwd(), 'docstore'); +const DOCUMENTS_V1_DIR = path.join(DOCSTORE_DIR, 'documents_v1'); +const AUDIOBOOKS_V1_DIR = path.join(DOCSTORE_DIR, 'audiobooks_v1'); +const UNCLAIMED_USER_ID = 'unclaimed'; + +const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; +const SAFE_AUDIOBOOK_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; +const DOCUMENT_ID_REGEX = /^[a-f0-9]{64}$/i; + +function loadEnvFiles() { + const envPath = path.join(process.cwd(), '.env'); + const envLocalPath = path.join(process.cwd(), '.env.local'); + if (fs.existsSync(envPath)) dotenv.config({ path: envPath }); + if (fs.existsSync(envLocalPath)) dotenv.config({ path: envLocalPath, override: true }); +} + +function parseBool(value, fallback = false) { + if (value == null || String(value).trim() === '') return fallback; + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return fallback; +} + +function parseArgs(argv) { + const args = { + dryRun: false, + deleteLocal: false, + namespace: null, + }; + + for (let i = 0; i < argv.length; i += 1) { + const raw = argv[i]; + if (raw === '--dry-run' && argv[i + 1]) { + args.dryRun = parseBool(argv[i + 1], true); + i += 1; + continue; + } + if (raw.startsWith('--dry-run=')) { + args.dryRun = parseBool(raw.slice('--dry-run='.length), true); + continue; + } + if (raw === '--delete-local' && argv[i + 1]) { + args.deleteLocal = parseBool(argv[i + 1], true); + i += 1; + continue; + } + if (raw.startsWith('--delete-local=')) { + args.deleteLocal = parseBool(raw.slice('--delete-local='.length), true); + continue; + } + if (raw === '--namespace' && argv[i + 1]) { + args.namespace = sanitizeNamespace(argv[i + 1]); + i += 1; + continue; + } + if (raw.startsWith('--namespace=')) { + args.namespace = sanitizeNamespace(raw.slice('--namespace='.length)); + } + } + + return args; +} + +function sanitizeNamespace(namespace) { + if (!namespace) return null; + const safe = String(namespace).trim(); + if (!safe || !SAFE_NAMESPACE_REGEX.test(safe)) return null; + return safe; +} + +function applyNamespacePath(baseDir, namespace) { + if (!namespace) return baseDir; + const resolved = path.resolve(baseDir, namespace); + if (!resolved.startsWith(path.resolve(baseDir) + path.sep)) return baseDir; + return resolved; +} + +function getUnclaimedUserIdForNamespace(namespace) { + if (!namespace) return UNCLAIMED_USER_ID; + return `${UNCLAIMED_USER_ID}::${namespace}`; +} + +function normalizePrefix(prefix) { + const base = String(prefix || 'openreader').trim(); + if (!base) return 'openreader'; + return base.replace(/^\/+|\/+$/g, ''); +} + +function parseS3ConfigFromEnv() { + const bucket = process.env.S3_BUCKET?.trim(); + const region = process.env.S3_REGION?.trim(); + const accessKeyId = process.env.S3_ACCESS_KEY_ID?.trim(); + const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY?.trim(); + const endpoint = process.env.S3_ENDPOINT?.trim(); + + if (!bucket || !region || !accessKeyId || !secretAccessKey) { + throw new Error('S3 is not configured. Required env vars: S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY.'); + } + + return { + bucket, + region, + endpoint: endpoint || undefined, + accessKeyId, + secretAccessKey, + forcePathStyle: parseBool(process.env.S3_FORCE_PATH_STYLE, false), + prefix: normalizePrefix(process.env.S3_PREFIX), + }; +} + +function createS3Client(config) { + return new S3Client({ + region: config.region, + endpoint: config.endpoint, + forcePathStyle: config.forcePathStyle, + credentials: { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + }, + }); +} + +function isPreconditionFailed(error) { + if (!error || typeof error !== 'object') return false; + return error?.$metadata?.httpStatusCode === 412 || error?.name === 'PreconditionFailed'; +} + +function documentKey(s3Config, id, namespace) { + if (!DOCUMENT_ID_REGEX.test(id)) { + throw new Error(`Invalid document id: ${id}`); + } + const nsSegment = namespace ? `ns/${namespace}/` : ''; + return `${s3Config.prefix}/documents_v1/${nsSegment}${id}`; +} + +function audiobookKey(s3Config, bookId, userId, fileName, namespace) { + if (!SAFE_AUDIOBOOK_ID_REGEX.test(bookId)) throw new Error(`Invalid audiobook id: ${bookId}`); + if (!userId) throw new Error('Missing user id for audiobook key'); + if (!fileName || fileName.includes('/') || fileName.includes('\\')) throw new Error(`Invalid audiobook file name: ${fileName}`); + const nsSegment = namespace ? `ns/${namespace}/` : ''; + return `${s3Config.prefix}/audiobooks_v1/${nsSegment}users/${encodeURIComponent(String(userId))}/${bookId}-audiobook/${fileName}`; +} + +async function putObjectIfMissing(s3Client, s3Config, key, body, contentType) { + await s3Client.send(new PutObjectCommand({ + Bucket: s3Config.bucket, + Key: key, + Body: body, + ContentType: contentType, + IfNoneMatch: '*', + })); +} + +function isLegacyDocumentMetadata(value) { + if (!value || typeof value !== 'object') return false; + const v = value; + return typeof v.id === 'string' + && typeof v.name === 'string' + && typeof v.size === 'number' + && typeof v.lastModified === 'number' + && typeof v.type === 'string'; +} + +function isValidDocumentId(id) { + return DOCUMENT_ID_REGEX.test(id); +} + +function extractIdFromFileName(fileName) { + const match = /^([a-f0-9]{64})__/i.exec(fileName); + if (!match) return null; + const id = match[1].toLowerCase(); + return isValidDocumentId(id) ? id : null; +} + +function decodeNameFromFileName(fileName, id) { + const prefix = `${id}__`; + if (!fileName.startsWith(prefix)) return fileName; + const encoded = fileName.slice(prefix.length); + try { + return decodeURIComponent(encoded); + } catch { + return fileName; + } +} + +function sniffBinaryDocumentType(bytes) { + if (bytes.length >= 5 && bytes.subarray(0, 5).toString('ascii') === '%PDF-') { + return 'pdf'; + } + + const isZip = bytes.length >= 4 + && bytes[0] === 0x50 + && bytes[1] === 0x4b + && (bytes[2] === 0x03 || bytes[2] === 0x05 || bytes[2] === 0x07) + && (bytes[3] === 0x04 || bytes[3] === 0x06 || bytes[3] === 0x08); + if (!isZip) return null; + + const probe = bytes.subarray(0, Math.min(bytes.length, 1024 * 1024)).toString('latin1'); + if (probe.includes('application/epub+zip') || probe.includes('META-INF/container.xml')) return 'epub'; + if (probe.includes('[Content_Types].xml') && probe.includes('word/')) return 'docx'; + return null; +} + +function toDocumentTypeFromName(name) { + const ext = path.extname(name).toLowerCase(); + if (ext === '.pdf') return 'pdf'; + if (ext === '.epub') return 'epub'; + if (ext === '.docx') return 'docx'; + return 'html'; +} + +function safeDocumentName(rawName, fallback) { + const baseName = path.basename(rawName || fallback); + return baseName.replaceAll('\u0000', '').slice(0, 240) || fallback; +} + +function normalizeNameForType(name, id, type) { + if (type === 'html') return name; + const expectedExt = type === 'pdf' ? '.pdf' : type === 'epub' ? '.epub' : '.docx'; + if (name.toLowerCase().endsWith(expectedExt)) return name; + const base = name.replace(/\.bin$/i, ''); + return `${base || id}${expectedExt}`; +} + +function contentTypeForName(name) { + const ext = path.extname(name).toLowerCase(); + if (ext === '.pdf') return 'application/pdf'; + if (ext === '.epub') return 'application/epub+zip'; + if (ext === '.docx') return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + if (ext === '.md' || ext === '.mdown' || ext === '.markdown') return 'text/markdown; charset=utf-8'; + if (ext === '.html' || ext === '.htm') return 'text/html; charset=utf-8'; + return 'text/plain; charset=utf-8'; +} + +function contentTypeForDocument(type, name) { + if (type === 'pdf') return 'application/pdf'; + if (type === 'epub') return 'application/epub+zip'; + if (type === 'docx') return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + return contentTypeForName(name); +} + +function sanitizeTagValue(value) { + return String(value || '').replaceAll('\u0000', '').replaceAll(/\r?\n/g, ' ').trim(); +} + +function sanitizeFileStem(value) { + return sanitizeTagValue(value) + .replaceAll(/[\\/]/g, ' ') + .replaceAll(/[<>:"|?*\u0000]/g, '') + .replaceAll(/\s+/g, ' ') + .trim() + .slice(0, 180); +} + +function encodeChapterFileName(index, title, format) { + const oneBased = String(index + 1).padStart(4, '0'); + const safeTitle = sanitizeFileStem(title) || `Chapter ${index + 1}`; + return `${oneBased}__${encodeURIComponent(safeTitle)}.${format}`; +} + +function decodeChapterFileName(fileName) { + const match = /^(\d{1,6})__(.+)\.(mp3|m4b)$/i.exec(fileName); + if (!match) return null; + const oneBased = Number(match[1]); + if (!Number.isInteger(oneBased) || oneBased <= 0) return null; + const format = match[3].toLowerCase(); + try { + const title = decodeURIComponent(match[2]); + return { index: oneBased - 1, title: title || `Chapter ${oneBased}`, format }; + } catch { + return { index: oneBased - 1, title: match[2], format }; + } +} + +function decodeChapterTitleTag(tag) { + const raw = sanitizeTagValue(tag); + if (!raw) return null; + const match = /^(\d{1,6})\s*[-.:]\s*(.+)$/.exec(raw); + if (!match) return null; + const oneBased = Number(match[1]); + if (!Number.isFinite(oneBased) || !Number.isInteger(oneBased) || oneBased <= 0) return null; + return { index: oneBased - 1, title: match[2].trim() || `Chapter ${oneBased}` }; +} + +function chooseBinary(preferred, bundled, envVarName, packageName) { + const envValue = preferred ? String(preferred).trim() : ''; + if (envValue) { + if ((envValue.includes('/') || envValue.includes('\\')) && !fs.existsSync(envValue)) { + throw new Error(`${envVarName} points to a missing binary: ${envValue}`); + } + return envValue; + } + + const bundledValue = bundled ? String(bundled).trim() : ''; + if (!bundledValue) { + throw new Error(`${packageName} binary is unavailable on this platform. Set ${envVarName} to an installed binary path.`); + } + if ((bundledValue.includes('/') || bundledValue.includes('\\')) && !fs.existsSync(bundledValue)) { + throw new Error(`${packageName} resolved to a missing binary path: ${bundledValue}`); + } + return bundledValue; +} + +function getFFprobePath() { + return chooseBinary(process.env.FFPROBE_BIN || null, ffprobeStatic?.path || null, 'FFPROBE_BIN', 'ffprobe-static'); +} + +async function ffprobeTitleTag(filePath) { + return new Promise((resolve) => { + const child = spawn(getFFprobePath(), [ + '-v', 'quiet', + '-print_format', 'json', + '-show_entries', 'format_tags=title', + filePath, + ]); + + let output = ''; + child.stdout.on('data', (data) => { output += data.toString(); }); + child.on('error', () => resolve(null)); + child.on('close', (code) => { + if (code !== 0) { + resolve(null); + return; + } + try { + const parsed = JSON.parse(output); + const title = parsed?.format?.tags?.title; + resolve(typeof title === 'string' ? title : null); + } catch { + resolve(null); + } + }); + }); +} + +function isPersistedAudiobookFileName(fileName) { + if (fileName === 'audiobook.meta.json') return true; + if (fileName === 'complete.mp3' || fileName === 'complete.m4b') return true; + if (/^complete\.(mp3|m4b)\.manifest\.json$/i.test(fileName)) return true; + return decodeChapterFileName(fileName) !== null; +} + +function isTransientAudiobookFileName(fileName) { + if (/^-?\d+-input\.mp3$/i.test(fileName)) return true; + if (/\.tmp\./i.test(fileName)) return true; + return false; +} + +function contentTypeForAudiobookFileName(fileName) { + if (fileName.endsWith('.mp3')) return 'audio/mpeg'; + if (fileName.endsWith('.m4b')) return 'audio/mp4'; + if (fileName.endsWith('.json')) return 'application/json; charset=utf-8'; + return 'application/octet-stream'; +} + +async function collectDocumentCandidates(docsDir, docstoreDir) { + const byId = new Map(); + let filesScanned = 0; + let skippedInvalid = 0; + + if (fs.existsSync(docsDir)) { + const entries = await fsp.readdir(docsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile()) continue; + filesScanned += 1; + const fullPath = path.join(docsDir, entry.name); + const bytes = await fsp.readFile(fullPath); + const st = await fsp.stat(fullPath); + + const extractedId = extractIdFromFileName(entry.name); + const id = extractedId ?? createHash('sha256').update(bytes).digest('hex'); + if (!isValidDocumentId(id)) { + skippedInvalid += 1; + continue; + } + + const inferredName = decodeNameFromFileName(entry.name, id); + const inferredType = toDocumentTypeFromName(inferredName); + const type = inferredType === 'html' ? (sniffBinaryDocumentType(bytes) ?? inferredType) : inferredType; + const normalizedName = normalizeNameForType(inferredName, id, type); + const contentType = contentTypeForDocument(type, normalizedName); + const lastModified = Number.isFinite(st.mtimeMs) ? Math.floor(st.mtimeMs) : Date.now(); + + if (!byId.has(id)) { + byId.set(id, { + id, + name: normalizedName, + type, + size: bytes.length, + lastModified, + contentType, + bytes, + localPaths: new Set([fullPath]), + }); + } else { + byId.get(id).localPaths.add(fullPath); + } + } + } + + if (fs.existsSync(docstoreDir)) { + const entries = await fsp.readdir(docstoreDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.json')) continue; + const metadataPath = path.join(docstoreDir, entry.name); + let parsed; + try { + parsed = JSON.parse(await fsp.readFile(metadataPath, 'utf8')); + } catch { + continue; + } + if (!isLegacyDocumentMetadata(parsed)) continue; + + const contentPath = path.join(docstoreDir, `${parsed.id}.${parsed.type}`); + if (!fs.existsSync(contentPath)) continue; + + filesScanned += 1; + const bytes = await fsp.readFile(contentPath); + const st = await fsp.stat(contentPath); + const id = createHash('sha256').update(bytes).digest('hex'); + if (!isValidDocumentId(id)) { + skippedInvalid += 1; + continue; + } + + const fallbackName = `${id}.${parsed.type}`; + const normalizedInputName = safeDocumentName(parsed.name, fallbackName); + const inferredType = toDocumentTypeFromName(normalizedInputName); + const type = inferredType === 'html' ? (sniffBinaryDocumentType(bytes) ?? inferredType) : inferredType; + const normalizedName = normalizeNameForType(normalizedInputName, id, type); + const contentType = contentTypeForDocument(type, normalizedName); + const lastModified = Number.isFinite(st.mtimeMs) ? Math.floor(st.mtimeMs) : Date.now(); + + if (!byId.has(id)) { + byId.set(id, { + id, + name: normalizedName, + type, + size: bytes.length, + lastModified, + contentType, + bytes, + localPaths: new Set([contentPath, metadataPath]), + }); + } else { + byId.get(id).localPaths.add(contentPath); + byId.get(id).localPaths.add(metadataPath); + } + } + } + + return { + candidates: Array.from(byId.values()).map((item) => ({ + ...item, + localPaths: Array.from(item.localPaths), + })), + filesScanned, + skippedInvalid, + }; +} + +async function collectAudiobookCandidates(audiobooksDir, docstoreDir) { + const stats = { + booksScanned: 0, + filesScanned: 0, + skippedTransient: 0, + }; + + const sourceDirs = new Map(); + + if (fs.existsSync(audiobooksDir)) { + const entries = await fsp.readdir(audiobooksDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.endsWith('-audiobook')) continue; + const bookId = entry.name.slice(0, -'-audiobook'.length); + if (!SAFE_AUDIOBOOK_ID_REGEX.test(bookId)) continue; + sourceDirs.set(`${bookId}::${path.join(audiobooksDir, entry.name)}`, { + bookId, + dirPath: path.join(audiobooksDir, entry.name), + }); + } + } + + if (fs.existsSync(docstoreDir)) { + const entries = await fsp.readdir(docstoreDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.endsWith('-audiobook')) continue; + const bookId = entry.name.slice(0, -'-audiobook'.length); + if (!SAFE_AUDIOBOOK_ID_REGEX.test(bookId)) continue; + sourceDirs.set(`${bookId}::${path.join(docstoreDir, entry.name)}`, { + bookId, + dirPath: path.join(docstoreDir, entry.name), + }); + } + } + + const grouped = new Map(); + for (const source of sourceDirs.values()) { + if (!grouped.has(source.bookId)) grouped.set(source.bookId, []); + grouped.get(source.bookId).push(source.dirPath); + } + + const books = []; + for (const [bookId, dirPaths] of grouped.entries()) { + stats.booksScanned += 1; + + const uploads = []; + const dedupedChapterByIndex = new Map(); + let title = 'Unknown Title'; + + for (const dirPath of dirPaths) { + const entries = await fsp.readdir(dirPath, { withFileTypes: true }).catch(() => []); + const chapterMetaByIndex = new Map(); + + for (const entry of entries) { + if (!entry.isFile()) continue; + if (entry.name === 'audiobook.meta.json') continue; + if (!entry.name.endsWith('.meta.json')) continue; + const metaPath = path.join(dirPath, entry.name); + try { + const raw = JSON.parse(await fsp.readFile(metaPath, 'utf8')); + const idx = Number(raw?.index ?? entry.name.replace(/\.meta\.json$/i, '')); + const chapterTitle = typeof raw?.title === 'string' ? raw.title : null; + if (Number.isInteger(idx) && idx >= 0 && chapterTitle) { + chapterMetaByIndex.set(idx, chapterTitle); + } + } catch {} + } + + const unresolvedSha = []; + const usedIndices = new Set(); + const chapterUploads = []; + + for (const entry of entries) { + if (!entry.isFile()) continue; + const fileName = entry.name; + stats.filesScanned += 1; + + if (isTransientAudiobookFileName(fileName)) { + stats.skippedTransient += 1; + continue; + } + + const fullPath = path.join(dirPath, fileName); + const st = await fsp.stat(fullPath).catch(() => null); + const mtimeMs = Number(st?.mtimeMs ?? 0); + + const canonical = decodeChapterFileName(fileName); + if (canonical) { + const targetFileName = encodeChapterFileName(canonical.index, canonical.title, canonical.format); + chapterUploads.push({ + index: canonical.index, + title: canonical.title, + format: canonical.format, + targetFileName, + sourcePath: fullPath, + mtimeMs, + }); + usedIndices.add(canonical.index); + continue; + } + + if (isPersistedAudiobookFileName(fileName)) { + uploads.push({ + sourcePath: fullPath, + targetFileName: fileName, + contentType: contentTypeForAudiobookFileName(fileName), + }); + continue; + } + + const legacy = /^(\d+)-chapter\.(mp3|m4b)$/i.exec(fileName); + if (legacy) { + const index = Number(legacy[1]); + const format = legacy[2].toLowerCase(); + const chapterTitle = chapterMetaByIndex.get(index) ?? `Chapter ${index + 1}`; + const targetFileName = encodeChapterFileName(index, chapterTitle, format); + chapterUploads.push({ + index, + title: chapterTitle, + format, + targetFileName, + sourcePath: fullPath, + mtimeMs, + }); + usedIndices.add(index); + continue; + } + + const sha = /^[a-f0-9]{64}\.(mp3|m4b)$/i.test(fileName); + if (sha) { + unresolvedSha.push({ + sourcePath: fullPath, + format: fileName.toLowerCase().endsWith('.mp3') ? 'mp3' : 'm4b', + mtimeMs, + }); + continue; + } + } + + unresolvedSha.sort((a, b) => a.sourcePath.localeCompare(b.sourcePath)); + const nextIndex = () => { + let idx = 0; + while (usedIndices.has(idx)) idx += 1; + usedIndices.add(idx); + return idx; + }; + + for (const item of unresolvedSha) { + let decoded = null; + const titleTag = await ffprobeTitleTag(item.sourcePath); + if (titleTag) decoded = decodeChapterTitleTag(titleTag); + const index = decoded?.index ?? nextIndex(); + const chapterTitle = decoded?.title ?? `Chapter ${index + 1}`; + const targetFileName = encodeChapterFileName(index, chapterTitle, item.format); + chapterUploads.push({ + index, + title: chapterTitle, + format: item.format, + targetFileName, + sourcePath: item.sourcePath, + mtimeMs: item.mtimeMs, + }); + } + + for (const chapter of chapterUploads) { + uploads.push({ + sourcePath: chapter.sourcePath, + targetFileName: chapter.targetFileName, + contentType: contentTypeForAudiobookFileName(chapter.targetFileName), + }); + + const current = dedupedChapterByIndex.get(chapter.index); + if (!current || chapter.targetFileName > current.fileName || chapter.mtimeMs > current.mtimeMs) { + dedupedChapterByIndex.set(chapter.index, { + index: chapter.index, + title: chapter.title, + format: chapter.format, + fileName: chapter.targetFileName, + mtimeMs: chapter.mtimeMs, + }); + } + } + } + + const chapters = Array.from(dedupedChapterByIndex.values()) + .sort((a, b) => a.index - b.index) + .map((chapter) => ({ + index: chapter.index, + title: chapter.title, + format: chapter.format, + fileName: chapter.fileName, + })); + + if (chapters.length > 0) title = chapters[0].title || title; + + books.push({ + id: bookId, + title, + chapters, + uploads, + sourceDirs: dirPaths, + }); + } + + return { books, stats }; +} + +function openDatabase() { + if (process.env.POSTGRES_URL) { + const pool = new Pool({ + connectionString: process.env.POSTGRES_URL, + ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false, + }); + return { + mode: 'pg', + async query(sql, values = []) { + return pool.query(sql, values); + }, + async close() { + await pool.end(); + }, + }; + } + + const dbPath = path.join(process.cwd(), 'docstore', 'sqlite3.db'); + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + const sqlite = new BetterSqlite3(dbPath); + return { + mode: 'sqlite', + async query(sql, values = []) { + if (/^\s*select/i.test(sql)) { + const rows = sqlite.prepare(sql).all(...values); + return { rows, rowCount: rows.length }; + } + const info = sqlite.prepare(sql).run(...values); + return { rows: [], rowCount: Number(info.changes ?? 0) }; + }, + async close() { + sqlite.close(); + }, + }; +} + +async function migrateDatabaseRows(database, userId, docCandidates, audiobookBooks, dryRun) { + const mismatched = await database.query('SELECT COUNT(*) AS count FROM documents WHERE file_path <> id'); + const rowsUpdated = Number(mismatched.rows[0]?.count ?? mismatched.rows[0]?.COUNT ?? 0); + if (!dryRun && rowsUpdated > 0) { + await database.query('UPDATE documents SET file_path = id WHERE file_path <> id'); + } + + const existingDocs = database.mode === 'sqlite' + ? await database.query('SELECT id FROM documents WHERE user_id = ?', [userId]) + : await database.query('SELECT id FROM documents WHERE user_id = $1', [userId]); + const existingDocIds = new Set(existingDocs.rows.map((row) => row.id)); + const toInsertDocs = docCandidates.filter((candidate) => !existingDocIds.has(candidate.id)); + + if (!dryRun) { + for (const candidate of toInsertDocs) { + if (database.mode === 'sqlite') { + await database.query( + 'INSERT OR IGNORE INTO documents (id, user_id, name, type, size, last_modified, file_path) VALUES (?, ?, ?, ?, ?, ?, ?)', + [candidate.id, userId, candidate.name, candidate.type, candidate.size, candidate.lastModified, candidate.id], + ); + } else { + await database.query( + 'INSERT INTO documents (id, user_id, name, type, size, last_modified, file_path) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT DO NOTHING', + [candidate.id, userId, candidate.name, candidate.type, candidate.size, candidate.lastModified, candidate.id], + ); + } + } + + for (const book of audiobookBooks) { + if (database.mode === 'sqlite') { + await database.query( + 'INSERT OR IGNORE INTO audiobooks (id, user_id, title, duration) VALUES (?, ?, ?, ?)', + [book.id, userId, book.title || 'Unknown Title', 0], + ); + } else { + await database.query( + 'INSERT INTO audiobooks (id, user_id, title, duration) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING', + [book.id, userId, book.title || 'Unknown Title', 0], + ); + } + + for (const chapter of book.chapters) { + const chapterId = `${book.id}-${chapter.index}`; + if (database.mode === 'sqlite') { + await database.query( + 'INSERT OR IGNORE INTO audiobook_chapters (id, book_id, user_id, chapter_index, title, duration, file_path, format) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [chapterId, book.id, userId, chapter.index, chapter.title, 0, chapter.fileName, chapter.format], + ); + } else { + await database.query( + 'INSERT INTO audiobook_chapters (id, book_id, user_id, chapter_index, title, duration, file_path, format) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT DO NOTHING', + [chapterId, book.id, userId, chapter.index, chapter.title, 0, chapter.fileName, chapter.format], + ); + } + } + } + } + + return { + dbRowsUpdated: rowsUpdated, + dbRowsSeeded: toInsertDocs.length, + audiobookDbBooksSeeded: audiobookBooks.length, + audiobookDbChaptersSeeded: audiobookBooks.reduce((sum, book) => sum + book.chapters.length, 0), + }; +} + +async function main() { + loadEnvFiles(); + const { dryRun, deleteLocal, namespace } = parseArgs(process.argv.slice(2)); + const unclaimedUserId = getUnclaimedUserIdForNamespace(namespace); + const docsDir = applyNamespacePath(DOCUMENTS_V1_DIR, namespace); + const audiobooksDir = applyNamespacePath(AUDIOBOOKS_V1_DIR, namespace); + const docstoreDir = applyNamespacePath(DOCSTORE_DIR, namespace); + + const s3Config = parseS3ConfigFromEnv(); + const s3Client = createS3Client(s3Config); + + const { candidates: documentCandidates, filesScanned, skippedInvalid } = await collectDocumentCandidates(docsDir, docstoreDir); + const { books: audiobookBooks, stats: audiobookStats } = await collectAudiobookCandidates(audiobooksDir, docstoreDir); + + let uploaded = 0; + let alreadyPresent = 0; + let deletedLocal = 0; + + for (const candidate of documentCandidates) { + if (!dryRun) { + try { + await putObjectIfMissing( + s3Client, + s3Config, + documentKey(s3Config, candidate.id, namespace), + candidate.bytes, + candidate.contentType, + ); + uploaded += 1; + } catch (error) { + if (isPreconditionFailed(error)) { + alreadyPresent += 1; + } else { + throw error; + } + } + } + + if (deleteLocal && !dryRun) { + for (const localPath of candidate.localPaths) { + const removed = await fsp.unlink(localPath).then(() => true).catch(() => false); + if (removed) deletedLocal += 1; + } + } + } + + let audiobookUploaded = 0; + let audiobookAlreadyPresent = 0; + let audiobookDeletedLocal = 0; + + for (const book of audiobookBooks) { + for (const upload of book.uploads) { + const bytes = await fsp.readFile(upload.sourcePath); + if (!dryRun) { + try { + await putObjectIfMissing( + s3Client, + s3Config, + audiobookKey(s3Config, book.id, unclaimedUserId, upload.targetFileName, namespace), + bytes, + upload.contentType, + ); + audiobookUploaded += 1; + } catch (error) { + if (isPreconditionFailed(error)) { + audiobookAlreadyPresent += 1; + } else { + throw error; + } + } + } + + if (deleteLocal && !dryRun) { + const removed = await fsp.unlink(upload.sourcePath).then(() => true).catch(() => false); + if (removed) audiobookDeletedLocal += 1; + } + } + + if (deleteLocal && !dryRun) { + for (const dirPath of book.sourceDirs) { + await fsp.rm(dirPath, { recursive: true, force: true }).catch(() => {}); + } + } + } + + const database = openDatabase(); + try { + const dbStats = await migrateDatabaseRows( + database, + unclaimedUserId, + documentCandidates, + audiobookBooks, + dryRun, + ); + + const result = { + success: true, + dryRun, + deleteLocal, + docsDir, + audiobooksDir, + namespace, + filesScanned, + uploaded, + alreadyPresent, + skippedInvalid, + deletedLocal, + dbRowsUpdated: dbStats.dbRowsUpdated, + dbRowsSeeded: dbStats.dbRowsSeeded, + audiobookBooksScanned: audiobookStats.booksScanned, + audiobookFilesScanned: audiobookStats.filesScanned, + audiobookUploaded, + audiobookAlreadyPresent, + audiobookDeletedLocal, + audiobookSkippedTransient: audiobookStats.skippedTransient, + audiobookDbBooksSeeded: dbStats.audiobookDbBooksSeeded, + audiobookDbChaptersSeeded: dbStats.audiobookDbChaptersSeeded, + }; + + console.log(JSON.stringify(result, null, 2)); + } finally { + await database.close(); + } +} + +main().catch((error) => { + console.error('Error running v2 migration script:', error); + process.exit(1); +}); diff --git a/scripts/openreader-entrypoint.mjs b/scripts/openreader-entrypoint.mjs index a39df05..0c0cae5 100644 --- a/scripts/openreader-entrypoint.mjs +++ b/scripts/openreader-entrypoint.mjs @@ -28,6 +28,11 @@ function isTrue(value, defaultValue) { return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; } +function resolveBooleanEnv(env, key, defaultValue) { + const value = env[key]; + return isTrue(value, defaultValue); +} + function withDefault(value, fallback) { return value && value.trim() ? value.trim() : fallback; } @@ -193,6 +198,35 @@ function runDbMigrations(env) { } } +function runStorageMigrations(env) { + const migrateScript = path.join(process.cwd(), 'scripts', 'migrate-fs-v2.mjs'); + if (!fs.existsSync(migrateScript)) { + throw new Error(`Could not find storage migration script at ${migrateScript}`); + } + + console.log('Running storage migrations (v2)...'); + const migration = spawnSync(process.execPath, [migrateScript, '--dry-run', 'false', '--delete-local', 'false'], { + env, + stdio: 'inherit', + }); + + if (migration.error) { + throw migration.error; + } + if (typeof migration.status === 'number' && migration.status !== 0) { + throw new Error(`Storage migrations failed with exit code ${migration.status}.`); + } +} + +function hasS3Config(env) { + return Boolean( + env.S3_BUCKET?.trim() + && env.S3_REGION?.trim() + && env.S3_ACCESS_KEY_ID?.trim() + && env.S3_SECRET_ACCESS_KEY?.trim() + ); +} + function sendSignal(child, signal, useProcessGroup) { if (!child) return false; if (useProcessGroup && process.platform !== 'win32' && typeof child.pid === 'number' && child.pid > 0) { @@ -291,7 +325,7 @@ async function main() { }); try { - const shouldRunDbMigrations = isTrue(runtimeEnv.RUN_DB_MIGRATIONS, true); + const shouldRunDbMigrations = resolveBooleanEnv(runtimeEnv, 'RUN_DRIZZLE_MIGRATIONS', true); if (shouldRunDbMigrations) { runDbMigrations(runtimeEnv); } @@ -351,6 +385,15 @@ async function main() { console.log(`Embedded SeaweedFS is ready at ${endpoint}`); } + const shouldRunStorageMigrations = resolveBooleanEnv(runtimeEnv, 'RUN_FS_MIGRATIONS', true); + if (shouldRunStorageMigrations) { + if (hasS3Config(runtimeEnv)) { + runStorageMigrations(runtimeEnv); + } else { + console.warn('Skipping storage migrations: S3 configuration is incomplete.'); + } + } + const { child, exitPromise } = spawnMainCommand(command, runtimeEnv); appProc = child; const exitCode = await exitPromise; diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index f52924d..300199e 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -1,211 +1,196 @@ import { NextRequest, NextResponse } from 'next/server'; -import { createReadStream, existsSync } from 'fs'; -import { readdir, unlink } from 'fs/promises'; -import { join } from 'path'; -import { getAudiobooksRootDir, ensureAudiobooksV1Ready, isAudiobooksV1Ready } from '@/lib/server/docstore'; -import { findStoredChapterByIndex } from '@/lib/server/audiobook'; +import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; import { audiobooks, audiobookChapters } from '@/db/schema'; -import { and, eq, inArray } from 'drizzle-orm'; import { requireAuthContext } from '@/lib/server/auth'; -import { ensureDbIndexed } from '@/lib/server/db-indexing'; +import { + deleteAudiobookObject, + getAudiobookObjectBuffer, + isMissingBlobError, + listAudiobookObjects, +} from '@/lib/server/audiobooks-blobstore'; +import { decodeChapterFileName } from '@/lib/server/audiobook'; +import { isS3Configured } from '@/lib/server/s3'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; -import { pruneAudiobookChapterIfMissingFile, pruneAudiobookIfMissingDir } from '@/lib/server/audiobook-prune'; export const dynamic = 'force-dynamic'; +function s3NotConfiguredResponse(): NextResponse { + return NextResponse.json( + { error: 'Audiobooks storage is not configured. Set S3_* environment variables.' }, + { status: 503 }, + ); +} + +function streamBuffer(buffer: Buffer): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(buffer)); + controller.close(); + }, + }); +} + +function findChapterFileNameByIndex(fileNames: string[], index: number): { fileName: string; title: string; format: 'mp3' | 'm4b' } | null { + const matches = fileNames + .map((fileName) => { + const decoded = decodeChapterFileName(fileName); + if (!decoded) return null; + if (decoded.index !== index) return null; + return { fileName, title: decoded.title, format: decoded.format }; + }) + .filter((value): value is { fileName: string; title: string; format: 'mp3' | 'm4b' } => Boolean(value)) + .sort((a, b) => a.fileName.localeCompare(b.fileName)); + + return matches.at(-1) ?? null; +} + export async function GET(request: NextRequest) { try { + if (!isS3Configured()) return s3NotConfiguredResponse(); + const bookId = request.nextUrl.searchParams.get('bookId'); const chapterIndexStr = request.nextUrl.searchParams.get('chapterIndex'); if (!bookId || !chapterIndexStr) { - return NextResponse.json( - { error: 'Missing bookId or chapterIndex parameter' }, - { status: 400 } - ); + return NextResponse.json({ error: 'Missing bookId or chapterIndex parameter' }, { status: 400 }); } - const chapterIndex = parseInt(chapterIndexStr); - if (isNaN(chapterIndex)) { - return NextResponse.json( - { error: 'Invalid chapterIndex parameter' }, - { status: 400 } - ); - } - - await ensureAudiobooksV1Ready(); - if (!(await isAudiobooksV1Ready())) { - return NextResponse.json( - { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, - { status: 409 }, - ); + const chapterIndex = Number.parseInt(chapterIndexStr, 10); + if (!Number.isInteger(chapterIndex) || chapterIndex < 0) { + return NextResponse.json({ error: 'Invalid chapterIndex parameter' }, { status: 400 }); } const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; + const { userId, authEnabled } = ctxOrRes; const testNamespace = getOpenReaderTestNamespace(request.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const storageUserId = userId ?? unclaimedUserId; const allowedUserIds = authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; - await ensureDbIndexed(); - - // Verify ownership with composite PK - allow access to user's own OR unclaimed audiobooks const [existingBook] = await db .select({ userId: audiobooks.userId }) .from(audiobooks) .where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds))); + if (!existingBook) { return NextResponse.json({ error: 'Book not found' }, { status: 404 }); } - const intermediateDir = join( - getAudiobooksRootDir({ - userId: existingBook.userId, - authEnabled, - namespace: testNamespace, - }), - `${bookId}-audiobook`, + const objects = await listAudiobookObjects(bookId, existingBook.userId, testNamespace); + const chapter = findChapterFileNameByIndex( + objects.map((object) => object.fileName), + chapterIndex, ); - const dirExists = existsSync(intermediateDir); - if (!dirExists) { - await pruneAudiobookIfMissingDir(bookId, existingBook.userId, false); + + if (!chapter) { + await db + .delete(audiobookChapters) + .where( + and( + eq(audiobookChapters.bookId, bookId), + eq(audiobookChapters.userId, existingBook.userId), + eq(audiobookChapters.chapterIndex, chapterIndex), + ), + ); return NextResponse.json({ error: 'Chapter not found' }, { status: 404 }); } - const chapter = await findStoredChapterByIndex(intermediateDir, chapterIndex, request.signal); - const chapterFileExists = !!chapter?.filePath && existsSync(chapter.filePath); - if (!chapterFileExists) { - await pruneAudiobookChapterIfMissingFile(bookId, existingBook.userId, chapterIndex, false); - return NextResponse.json({ error: 'Chapter not found' }, { status: 404 }); - } - - // Stream the chapter file - const stream = createReadStream(chapter.filePath); - - const readableWebStream = new ReadableStream({ - start(controller) { - stream.on('data', (chunk) => { - controller.enqueue(chunk); - }); - stream.on('end', () => { - controller.close(); - }); - stream.on('error', (err) => { - controller.error(err); - }); - }, - cancel() { - stream.destroy(); + let buffer: Buffer; + try { + buffer = await getAudiobookObjectBuffer(bookId, existingBook.userId, chapter.fileName, testNamespace); + } catch (error) { + if (isMissingBlobError(error)) { + await db + .delete(audiobookChapters) + .where( + and( + eq(audiobookChapters.bookId, bookId), + eq(audiobookChapters.userId, existingBook.userId), + eq(audiobookChapters.chapterIndex, chapterIndex), + ), + ); + return NextResponse.json({ error: 'Chapter not found' }, { status: 404 }); } - }); + throw error; + } const mimeType = chapter.format === 'mp3' ? 'audio/mpeg' : 'audio/mp4'; const sanitizedTitle = chapter.title.replace(/[^a-z0-9]/gi, '_').toLowerCase(); - return new NextResponse(readableWebStream, { + return new NextResponse(streamBuffer(buffer), { headers: { 'Content-Type': mimeType, 'Content-Disposition': `attachment; filename="${sanitizedTitle}.${chapter.format}"`, 'Cache-Control': 'no-cache', }, }); - } catch (error) { console.error('Error downloading chapter:', error); - return NextResponse.json( - { error: 'Failed to download chapter' }, - { status: 500 } - ); + return NextResponse.json({ error: 'Failed to download chapter' }, { status: 500 }); } } export async function DELETE(request: NextRequest) { try { + if (!isS3Configured()) return s3NotConfiguredResponse(); + const bookId = request.nextUrl.searchParams.get('bookId'); const chapterIndexStr = request.nextUrl.searchParams.get('chapterIndex'); if (!bookId || !chapterIndexStr) { - return NextResponse.json( - { error: 'Missing bookId or chapterIndex parameter' }, - { status: 400 } - ); + return NextResponse.json({ error: 'Missing bookId or chapterIndex parameter' }, { status: 400 }); } - const chapterIndex = parseInt(chapterIndexStr, 10); - if (isNaN(chapterIndex)) { - return NextResponse.json( - { error: 'Invalid chapterIndex parameter' }, - { status: 400 } - ); - } - - await ensureAudiobooksV1Ready(); - if (!(await isAudiobooksV1Ready())) { - return NextResponse.json( - { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, - { status: 409 }, - ); + const chapterIndex = Number.parseInt(chapterIndexStr, 10); + if (!Number.isInteger(chapterIndex) || chapterIndex < 0) { + return NextResponse.json({ error: 'Invalid chapterIndex parameter' }, { status: 400 }); } const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; - const { userId, authEnabled } = ctxOrRes; + const testNamespace = getOpenReaderTestNamespace(request.headers); - const storageUserId = userId ?? getUnclaimedUserIdForNamespace(testNamespace); + const storageUserId = ctxOrRes.userId ?? getUnclaimedUserIdForNamespace(testNamespace); - await ensureDbIndexed(); - - // Verify ownership and delete from DB with composite PK const [existingBook] = await db .select({ userId: audiobooks.userId }) .from(audiobooks) .where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId))); + if (!existingBook) { return NextResponse.json({ error: 'Book not found' }, { status: 404 }); } - // Delete from DB - await db.delete(audiobookChapters).where( - and( - eq(audiobookChapters.bookId, bookId), - eq(audiobookChapters.userId, storageUserId), - eq(audiobookChapters.chapterIndex, chapterIndex), - ), - ); + await db + .delete(audiobookChapters) + .where( + and( + eq(audiobookChapters.bookId, bookId), + eq(audiobookChapters.userId, storageUserId), + eq(audiobookChapters.chapterIndex, chapterIndex), + ), + ); - const intermediateDir = join( - getAudiobooksRootDir({ - userId: storageUserId, - authEnabled, - namespace: testNamespace, - }), - `${bookId}-audiobook`, - ); + const objectNames = (await listAudiobookObjects(bookId, storageUserId, testNamespace)).map((object) => object.fileName); const chapterPrefix = `${String(chapterIndex + 1).padStart(4, '0')}__`; - const files = await readdir(intermediateDir).catch(() => []); - for (const file of files) { - if (!file.startsWith(chapterPrefix)) continue; - if (!file.endsWith('.mp3') && !file.endsWith('.m4b')) continue; - await unlink(join(intermediateDir, file)).catch(() => { }); + + for (const fileName of objectNames) { + if (!fileName.startsWith(chapterPrefix)) continue; + if (!fileName.endsWith('.mp3') && !fileName.endsWith('.m4b')) continue; + await deleteAudiobookObject(bookId, storageUserId, fileName, testNamespace).catch(() => {}); } - // Invalidate any combined "complete" files - const completeM4b = join(intermediateDir, `complete.m4b`); - const completeMp3 = join(intermediateDir, `complete.mp3`); - if (existsSync(completeM4b)) await unlink(completeM4b).catch(() => { }); - if (existsSync(completeMp3)) await unlink(completeMp3).catch(() => { }); - await unlink(join(intermediateDir, 'complete.mp3.manifest.json')).catch(() => { }); - await unlink(join(intermediateDir, 'complete.m4b.manifest.json')).catch(() => { }); + await deleteAudiobookObject(bookId, storageUserId, 'complete.mp3', testNamespace).catch(() => {}); + await deleteAudiobookObject(bookId, storageUserId, 'complete.m4b', testNamespace).catch(() => {}); + await deleteAudiobookObject(bookId, storageUserId, 'complete.mp3.manifest.json', testNamespace).catch(() => {}); + await deleteAudiobookObject(bookId, storageUserId, 'complete.m4b.manifest.json', testNamespace).catch(() => {}); return NextResponse.json({ success: true }); } catch (error) { console.error('Error deleting chapter:', error); - return NextResponse.json( - { error: 'Failed to delete chapter' }, - { status: 500 } - ); + return NextResponse.json({ error: 'Failed to delete chapter' }, { status: 500 }); } } diff --git a/src/app/api/audiobook/route.ts b/src/app/api/audiobook/route.ts index 7ed21d1..ec09115 100644 --- a/src/app/api/audiobook/route.ts +++ b/src/app/api/audiobook/route.ts @@ -1,20 +1,34 @@ import { NextRequest, NextResponse } from 'next/server'; import { spawn } from 'child_process'; -import { readFile, writeFile, mkdir, unlink, rm, rename, readdir } from 'fs/promises'; -import { existsSync, createReadStream } from 'fs'; -import { basename, join } from 'path'; +import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { randomUUID } from 'crypto'; -import { ensureAudiobooksV1Ready, isAudiobooksV1Ready, getAudiobooksRootDir } from '@/lib/server/docstore'; -import { encodeChapterFileName, encodeChapterTitleTag, listStoredChapters, ffprobeAudio, escapeFFMetadata } from '@/lib/server/audiobook'; -import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts'; -import type { AudiobookGenerationSettings } from '@/types/client'; +import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; import { audiobooks, audiobookChapters } from '@/db/schema'; -import { eq, and, inArray } from 'drizzle-orm'; import { requireAuthContext } from '@/lib/server/auth'; -import { ensureDbIndexed } from '@/lib/server/db-indexing'; +import { + audiobookPrefix, + deleteAudiobookObject, + deleteAudiobookPrefix, + getAudiobookObjectBuffer, + isMissingBlobError, + listAudiobookObjects, + putAudiobookObject, +} from '@/lib/server/audiobooks-blobstore'; +import { + decodeChapterFileName, + encodeChapterFileName, + encodeChapterTitleTag, + escapeFFMetadata, + ffprobeAudio, +} from '@/lib/server/audiobook'; +import { isS3Configured } from '@/lib/server/s3'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; -import { pruneAudiobookIfMissingDir } from '@/lib/server/audiobook-prune'; +import { getFFmpegPath, getFFprobePath } from '@/lib/server/ffmpeg-bin'; +import type { AudiobookGenerationSettings } from '@/types/client'; +import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts'; export const dynamic = 'force-dynamic'; @@ -27,13 +41,134 @@ interface ConversionRequest { settings?: AudiobookGenerationSettings; } +type ChapterObject = { + index: number; + title: string; + format: TTSAudiobookFormat; + fileName: string; +}; + +const SAFE_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; + +function isSafeId(value: string): boolean { + return SAFE_ID_REGEX.test(value); +} + +function s3NotConfiguredResponse(): NextResponse { + return NextResponse.json( + { error: 'Audiobooks storage is not configured. Set S3_* environment variables.' }, + { status: 503 }, + ); +} + +function chapterFileMimeType(format: TTSAudiobookFormat): string { + return format === 'mp3' ? 'audio/mpeg' : 'audio/mp4'; +} + +function buildAtempoFilter(speed: number): string { + const clamped = Math.max(0.5, Math.min(speed, 3)); + if (clamped <= 2) return `atempo=${clamped.toFixed(3)}`; + const second = clamped / 2; + return `atempo=2.0,atempo=${second.toFixed(3)}`; +} + +function listChapterObjects(objectNames: string[]): ChapterObject[] { + const chapters = objectNames + .filter((name) => !name.startsWith('complete.')) + .map((fileName) => { + const decoded = decodeChapterFileName(fileName); + if (!decoded) return null; + return { + index: decoded.index, + title: decoded.title, + format: decoded.format, + fileName, + } satisfies ChapterObject; + }) + .filter((value): value is ChapterObject => Boolean(value)) + .sort((a, b) => a.index - b.index); + + const deduped = new Map(); + for (const chapter of chapters) { + const existing = deduped.get(chapter.index); + if (!existing) { + deduped.set(chapter.index, chapter); + continue; + } + if (chapter.fileName > existing.fileName) { + deduped.set(chapter.index, chapter); + } + } + + return Array.from(deduped.values()).sort((a, b) => a.index - b.index); +} + +function streamBuffer(buffer: Buffer): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(buffer)); + controller.close(); + }, + }); +} + +async function runFFmpeg(args: string[], signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const ffmpeg = spawn(getFFmpegPath(), args); + let finished = false; + + const onAbort = () => { + if (finished) return; + finished = true; + try { + ffmpeg.kill('SIGKILL'); + } catch {} + reject(new Error('ABORTED')); + }; + + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener('abort', onAbort, { once: true }); + } + + ffmpeg.stderr.on('data', (data) => { + console.error(`ffmpeg stderr: ${data}`); + }); + + ffmpeg.on('close', (code) => { + if (finished) return; + finished = true; + signal?.removeEventListener('abort', onAbort); + if (code === 0) { + resolve(); + } else { + reject(new Error(`FFmpeg process exited with code ${code}`)); + } + }); + + ffmpeg.on('error', (err) => { + if (finished) return; + finished = true; + signal?.removeEventListener('abort', onAbort); + reject(err); + }); + }); +} + async function getAudioDuration(filePath: string, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { - const ffprobe = spawn('ffprobe', [ - '-i', filePath, - '-show_entries', 'format=duration', - '-v', 'quiet', - '-of', 'csv=p=0' + const ffprobe = spawn(getFFprobePath(), [ + '-i', + filePath, + '-show_entries', + 'format=duration', + '-v', + 'quiet', + '-of', + 'csv=p=0', ]); let output = ''; @@ -44,7 +179,7 @@ async function getAudioDuration(filePath: string, signal?: AbortSignal): Promise finished = true; try { ffprobe.kill('SIGKILL'); - } catch { } + } catch {} reject(new Error('ABORTED')); }; @@ -85,95 +220,25 @@ async function getAudioDuration(filePath: string, signal?: AbortSignal): Promise }); } -async function runFFmpeg(args: string[], signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - const ffmpeg = spawn('ffmpeg', args); - - let finished = false; - - const onAbort = () => { - if (finished) return; - finished = true; - try { - ffmpeg.kill('SIGKILL'); - } catch { } - reject(new Error('ABORTED')); - }; - - if (signal) { - if (signal.aborted) { - onAbort(); - return; - } - signal.addEventListener('abort', onAbort, { once: true }); - } - - ffmpeg.stderr.on('data', (data) => { - console.error(`ffmpeg stderr: ${data}`); - }); - - ffmpeg.on('close', (code) => { - if (finished) return; - finished = true; - signal?.removeEventListener('abort', onAbort); - if (code === 0) { - resolve(); - } else { - reject(new Error(`FFmpeg process exited with code ${code}`)); - } - }); - - ffmpeg.on('error', (err) => { - if (finished) return; - finished = true; - signal?.removeEventListener('abort', onAbort); - reject(err); - }); - }); -} - -function buildAtempoFilter(speed: number): string { - const clamped = Math.max(0.5, Math.min(speed, 3)); - // atempo supports 0.5..2.0 per filter; chain for >2.0 - if (clamped <= 2) return `atempo=${clamped.toFixed(3)}`; - const second = clamped / 2; - return `atempo=2.0,atempo=${second.toFixed(3)}`; -} - -const SAFE_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; - -function isSafeId(value: string): boolean { - return SAFE_ID_REGEX.test(value); -} - export async function POST(request: NextRequest) { + let workDir: string | null = null; try { - // Parse the request body + if (!isS3Configured()) return s3NotConfiguredResponse(); + const data: ConversionRequest = await request.json(); const requestedFormat = data.format || 'm4b'; - await ensureAudiobooksV1Ready(); - if (!(await isAudiobooksV1Ready())) { - return NextResponse.json( - { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, - { status: 409 }, - ); - } - const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; - const userId = ctxOrRes.userId; - const testNamespace = getOpenReaderTestNamespace(request.headers); - const storageUserId = userId ?? getUnclaimedUserIdForNamespace(testNamespace); - // Generate or use existing book ID + const testNamespace = getOpenReaderTestNamespace(request.headers); + const storageUserId = ctxOrRes.userId ?? getUnclaimedUserIdForNamespace(testNamespace); const bookId = data.bookId || randomUUID(); if (!isSafeId(bookId)) { return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 }); } - // DB Check / Insert Audiobook await db .insert(audiobooks) .values({ @@ -183,57 +248,36 @@ export async function POST(request: NextRequest) { }) .onConflictDoNothing(); - const intermediateDir = join( - getAudiobooksRootDir({ - userId, - authEnabled: ctxOrRes.authEnabled, - namespace: testNamespace, - }), - `${bookId}-audiobook`, - ); - - // Create intermediate directory - await mkdir(intermediateDir, { recursive: true }); - - const existingChapters = await listStoredChapters(intermediateDir, request.signal); + const objects = await listAudiobookObjects(bookId, storageUserId, testNamespace); + const objectNames = objects.map((item) => item.fileName); + const existingChapters = listChapterObjects(objectNames); const hasChapters = existingChapters.length > 0; - const metaPath = join(intermediateDir, 'audiobook.meta.json'); - const incomingSettings = data.settings; let existingSettings: AudiobookGenerationSettings | null = null; try { - existingSettings = JSON.parse(await readFile(metaPath, 'utf8')) as AudiobookGenerationSettings; - } catch { + existingSettings = JSON.parse((await getAudiobookObjectBuffer(bookId, storageUserId, 'audiobook.meta.json', testNamespace)).toString('utf8')) as AudiobookGenerationSettings; + } catch (error) { + if (!isMissingBlobError(error)) throw error; existingSettings = null; } - // Only enforce mismatch check if we already have generated chapters. - // If no chapters exist, we can overwrite/ignore "existing" settings (which might be stale or partial). - if (existingSettings && hasChapters) { - if (incomingSettings) { - const mismatch = - existingSettings.ttsProvider !== incomingSettings.ttsProvider || - existingSettings.ttsModel !== incomingSettings.ttsModel || - existingSettings.voice !== incomingSettings.voice || - existingSettings.nativeSpeed !== incomingSettings.nativeSpeed || - existingSettings.postSpeed !== incomingSettings.postSpeed || - existingSettings.format !== incomingSettings.format; - if (mismatch) { - return NextResponse.json( - { error: 'Audiobook settings mismatch', settings: existingSettings }, - { status: 409 }, - ); - } + const incomingSettings = data.settings; + if (existingSettings && hasChapters && incomingSettings) { + const mismatch = + existingSettings.ttsProvider !== incomingSettings.ttsProvider || + existingSettings.ttsModel !== incomingSettings.ttsModel || + existingSettings.voice !== incomingSettings.voice || + existingSettings.nativeSpeed !== incomingSettings.nativeSpeed || + existingSettings.postSpeed !== incomingSettings.postSpeed || + existingSettings.format !== incomingSettings.format; + if (mismatch) { + return NextResponse.json({ error: 'Audiobook settings mismatch', settings: existingSettings }, { status: 409 }); } } - // Note: We deliberately do NOT write the meta file here yet. - // We wait until a chapter is successfully generated/saved below. - const existingFormats = new Set(existingChapters.map((c) => c.format)); + + const existingFormats = new Set(existingChapters.map((chapter) => chapter.format)); if (existingFormats.size > 1) { - return NextResponse.json( - { error: 'Mixed chapter formats detected; reset the audiobook to continue' }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Mixed chapter formats detected; reset the audiobook to continue' }, { status: 400 }); } const format: TTSAudiobookFormat = @@ -244,8 +288,6 @@ export async function POST(request: NextRequest) { const rawPostSpeed = incomingSettings?.postSpeed ?? existingSettings?.postSpeed ?? 1; const postSpeed = Number.isFinite(Number(rawPostSpeed)) ? Number(rawPostSpeed) : 1; - // Use provided chapter index or find the next available index robustly (handles gaps) - // Use provided chapter index or find the next available index robustly (handles gaps) let chapterIndex: number; if (data.chapterIndex !== undefined) { const normalized = Number(data.chapterIndex); @@ -255,7 +297,6 @@ export async function POST(request: NextRequest) { chapterIndex = normalized; } else { const indices = existingChapters.map((c) => c.index); - // Find smallest non-negative integer not present let next = 0; for (const idx of indices) { if (idx === next) { @@ -267,76 +308,82 @@ export async function POST(request: NextRequest) { chapterIndex = next; } - // Write input file (MP3 from TTS) - const inputPath = join(intermediateDir, `${chapterIndex}-input.mp3`); - const chapterOutputTempPath = join(intermediateDir, `${chapterIndex}-chapter.tmp.${format}`); + workDir = await mkdtemp(join(tmpdir(), 'openreader-audiobook-')); + const inputPath = join(workDir, `${chapterIndex}-input.mp3`); + const chapterOutputTempPath = join(workDir, `${chapterIndex}-chapter.tmp.${format}`); const titleTag = encodeChapterTitleTag(chapterIndex, data.chapterTitle); - // Write the chapter audio to a temp file await writeFile(inputPath, Buffer.from(new Uint8Array(data.buffer))); - // We intentionally do not delete the existing chapter file up-front. This avoids a long - // window where the chapter is "missing" while ffmpeg is running (which can lead to - // partial/stale "complete.*" downloads). We clean up duplicates and invalidate the - // combined output only after the new chapter is written successfully. - if (format === 'mp3') { - // For MP3, re-encode to ensure proper headers and consistent format - await runFFmpeg([ - '-y', // Overwrite output file without asking - '-i', inputPath, - ...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []), - '-c:a', 'libmp3lame', - '-b:a', '64k', - '-metadata', `title=${titleTag}`, - chapterOutputTempPath - ], request.signal); + await runFFmpeg( + [ + '-y', + '-i', + inputPath, + ...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []), + '-c:a', + 'libmp3lame', + '-b:a', + '64k', + '-metadata', + `title=${titleTag}`, + chapterOutputTempPath, + ], + request.signal, + ); } else { - // Convert MP3 to M4B container with proper encoding and metadata - await runFFmpeg([ - '-y', // Overwrite output file without asking - '-i', inputPath, - ...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []), - '-c:a', 'aac', - '-b:a', '64k', - '-metadata', `title=${titleTag}`, - '-f', 'mp4', - chapterOutputTempPath - ], request.signal); + await runFFmpeg( + [ + '-y', + '-i', + inputPath, + ...(postSpeed !== 1 ? ['-filter:a', buildAtempoFilter(postSpeed)] : []), + '-c:a', + 'aac', + '-b:a', + '64k', + '-metadata', + `title=${titleTag}`, + '-f', + 'mp4', + chapterOutputTempPath, + ], + request.signal, + ); } const probe = await ffprobeAudio(chapterOutputTempPath, request.signal); const duration = probe.durationSec ?? (await getAudioDuration(chapterOutputTempPath, request.signal)); - const finalChapterPath = join(intermediateDir, encodeChapterFileName(chapterIndex, data.chapterTitle, format)); - await unlink(finalChapterPath).catch(() => { }); - await rename(chapterOutputTempPath, finalChapterPath); + const finalChapterName = encodeChapterFileName(chapterIndex, data.chapterTitle, format); + const finalChapterBytes = await readFile(chapterOutputTempPath); + await putAudiobookObject(bookId, storageUserId, finalChapterName, finalChapterBytes, chapterFileMimeType(format), testNamespace); - // Remove any existing chapter files for this index (e.g., if the title changed and the - // filename changed) and invalidate the combined output now that the chapter is updated. const chapterPrefix = `${String(chapterIndex + 1).padStart(4, '0')}__`; - const finalChapterName = basename(finalChapterPath); - const existingFiles = await readdir(intermediateDir).catch(() => []); - for (const file of existingFiles) { - if (!file.startsWith(chapterPrefix)) continue; - if (!file.endsWith('.mp3') && !file.endsWith('.m4b')) continue; - if (file === finalChapterName) continue; - await unlink(join(intermediateDir, file)).catch(() => { }); + for (const fileName of objectNames) { + if (!fileName.startsWith(chapterPrefix)) continue; + if (!fileName.endsWith('.mp3') && !fileName.endsWith('.m4b')) continue; + if (fileName === finalChapterName) continue; + await deleteAudiobookObject(bookId, storageUserId, fileName, testNamespace).catch(() => {}); } - await unlink(join(intermediateDir, 'complete.mp3')).catch(() => { }); - await unlink(join(intermediateDir, 'complete.m4b')).catch(() => { }); - await unlink(join(intermediateDir, 'complete.mp3.manifest.json')).catch(() => { }); - await unlink(join(intermediateDir, 'complete.m4b.manifest.json')).catch(() => { }); - // Ensure meta exists after first successful chapter. + await deleteAudiobookObject(bookId, storageUserId, 'complete.mp3', testNamespace).catch(() => {}); + await deleteAudiobookObject(bookId, storageUserId, 'complete.m4b', testNamespace).catch(() => {}); + await deleteAudiobookObject(bookId, storageUserId, 'complete.mp3.manifest.json', testNamespace).catch(() => {}); + await deleteAudiobookObject(bookId, storageUserId, 'complete.m4b.manifest.json', testNamespace).catch(() => {}); + if (!existingSettings && incomingSettings) { - await writeFile(metaPath, JSON.stringify(incomingSettings, null, 2)).catch(() => { }); + await putAudiobookObject( + bookId, + storageUserId, + 'audiobook.meta.json', + Buffer.from(JSON.stringify(incomingSettings, null, 2), 'utf8'), + 'application/json; charset=utf-8', + testNamespace, + ); } - // Clean up input file - await unlink(inputPath).catch(console.error); - - // Insert Chapter Record (Denormalized) await db .insert(audiobookChapters) .values({ @@ -360,26 +407,24 @@ export async function POST(request: NextRequest) { duration, status: 'completed' as const, bookId, - format + format, }); - } catch (error) { if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) { - return NextResponse.json( - { error: 'cancelled' }, - { status: 499 } - ); + return NextResponse.json({ error: 'cancelled' }, { status: 499 }); } console.error('Error processing audio chapter:', error); - return NextResponse.json( - { error: 'Failed to process audio chapter' }, - { status: 500 } - ); + return NextResponse.json({ error: 'Failed to process audio chapter' }, { status: 500 }); + } finally { + if (workDir) await rm(workDir, { recursive: true, force: true }).catch(() => {}); } } export async function GET(request: NextRequest) { + let workDir: string | null = null; try { + if (!isS3Configured()) return s3NotConfiguredResponse(); + const bookId = request.nextUrl.searchParams.get('bookId'); const requestedFormat = request.nextUrl.searchParams.get('format') as TTSAudiobookFormat | null; if (!bookId) { @@ -389,25 +434,15 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 }); } - await ensureAudiobooksV1Ready(); - if (!(await isAudiobooksV1Ready())) { - return NextResponse.json( - { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, - { status: 409 }, - ); - } - const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; + const { userId, authEnabled } = ctxOrRes; const testNamespace = getOpenReaderTestNamespace(request.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const storageUserId = userId ?? unclaimedUserId; const allowedUserIds = authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; - await ensureDbIndexed(); - - // Check if audiobook exists for user OR is unclaimed (similar to documents) const [existingBook] = await db .select({ userId: audiobooks.userId }) .from(audiobooks) @@ -416,193 +451,163 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Book not found' }, { status: 404 }); } - const intermediateDir = join( - getAudiobooksRootDir({ - userId: existingBook.userId, - authEnabled, - namespace: testNamespace, - }), - `${bookId}-audiobook`, - ); - - if (!existsSync(intermediateDir)) { - await pruneAudiobookIfMissingDir(bookId, existingBook.userId, false); - return NextResponse.json({ error: 'Book not found' }, { status: 404 }); - } - - const stored = await listStoredChapters(intermediateDir, request.signal); - const chapters = stored.map((chapter) => ({ - title: chapter.title, - duration: chapter.durationSec ?? 0, - index: chapter.index, - format: chapter.format, - filePath: chapter.filePath, - })); - + const objects = await listAudiobookObjects(bookId, existingBook.userId, testNamespace); + const objectNames = objects.map((item) => item.fileName); + const chapters = listChapterObjects(objectNames); if (chapters.length === 0) { return NextResponse.json({ error: 'No chapters found' }, { status: 404 }); } const chapterFormats = new Set(chapters.map((chapter) => chapter.format)); if (chapterFormats.size > 1) { - return NextResponse.json( - { error: 'Mixed chapter formats detected; reset the audiobook to continue' }, - { status: 400 }, - ); + return NextResponse.json({ error: 'Mixed chapter formats detected; reset the audiobook to continue' }, { status: 400 }); } - // Sort chapters by index - chapters.sort((a, b) => a.index - b.index); - const format: TTSAudiobookFormat = requestedFormat ?? (chapters[0]?.format as TTSAudiobookFormat) ?? 'm4b'; - const outputPath = join(intermediateDir, `complete.${format}`); - const manifestPath = join(intermediateDir, `complete.${format}.manifest.json`); - const metadataPath = join(intermediateDir, 'metadata.txt'); - const listPath = join(intermediateDir, 'list.txt'); + const format: TTSAudiobookFormat = requestedFormat ?? chapters[0].format; + const completeName = `complete.${format}`; + const manifestName = `${completeName}.manifest.json`; + const signature = chapters.map((chapter) => ({ index: chapter.index, fileName: chapter.fileName })); - const signature = chapters.map((chapter) => ({ - index: chapter.index, - fileName: basename(chapter.filePath), - })); - - if (existsSync(outputPath)) { - let cached: typeof signature | null = null; + if (objectNames.includes(completeName) && objectNames.includes(manifestName)) { try { - cached = JSON.parse(await readFile(manifestPath, 'utf8')) as typeof signature; - } catch { - cached = null; - } - - if (cached && JSON.stringify(cached) === JSON.stringify(signature)) { - return streamFile(outputPath, format); - } - - await unlink(outputPath).catch(() => { }); - await unlink(manifestPath).catch(() => { }); - } - - // Ensure we have chapter durations for chapter markers / ordering. - for (const chapter of chapters) { - if (chapter.duration && chapter.duration > 0) continue; - try { - const probe = await ffprobeAudio(chapter.filePath, request.signal); - if (probe.durationSec && probe.durationSec > 0) { - chapter.duration = probe.durationSec; - continue; + const manifest = JSON.parse((await getAudiobookObjectBuffer(bookId, existingBook.userId, manifestName, testNamespace)).toString('utf8')); + if (JSON.stringify(manifest) === JSON.stringify(signature)) { + const cached = await getAudiobookObjectBuffer(bookId, existingBook.userId, completeName, testNamespace); + return new NextResponse(streamBuffer(cached), { + headers: { + 'Content-Type': chapterFileMimeType(format), + 'Content-Disposition': `attachment; filename="audiobook.${format}"`, + 'Cache-Control': 'no-cache', + }, + }); } - } catch { } - - try { - chapter.duration = await getAudioDuration(chapter.filePath, request.signal); } catch { - chapter.duration = 0; + // Force regeneration below. } + + await deleteAudiobookObject(bookId, existingBook.userId, completeName, testNamespace).catch(() => {}); + await deleteAudiobookObject(bookId, existingBook.userId, manifestName, testNamespace).catch(() => {}); + } + + const chapterRows = await db + .select({ chapterIndex: audiobookChapters.chapterIndex, duration: audiobookChapters.duration }) + .from(audiobookChapters) + .where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, existingBook.userId))); + const durationByIndex = new Map(); + for (const row of chapterRows) { + durationByIndex.set(row.chapterIndex, Number(row.duration ?? 0)); + } + + workDir = await mkdtemp(join(tmpdir(), 'openreader-audiobook-combine-')); + const metadataPath = join(workDir, 'metadata.txt'); + const listPath = join(workDir, 'list.txt'); + const outputPath = join(workDir, completeName); + + const localChapters: Array<{ index: number; title: string; localPath: string; duration: number }> = []; + for (const chapter of chapters) { + const localPath = join(workDir, chapter.fileName); + const bytes = await getAudiobookObjectBuffer(bookId, existingBook.userId, chapter.fileName, testNamespace); + await writeFile(localPath, bytes); + + let duration = durationByIndex.get(chapter.index) ?? 0; + if (!duration || duration <= 0) { + try { + const probe = await ffprobeAudio(localPath, request.signal); + if (probe.durationSec && probe.durationSec > 0) { + duration = probe.durationSec; + } else { + duration = await getAudioDuration(localPath, request.signal); + } + } catch { + duration = 0; + } + } + + localChapters.push({ + index: chapter.index, + title: chapter.title, + localPath, + duration, + }); } - // Create chapter metadata file for M4B const metadata: string[] = []; let currentTime = 0; - - for (const chapter of chapters) { + for (const chapter of localChapters) { const startMs = Math.floor(currentTime * 1000); currentTime += chapter.duration; const endMs = Math.floor(currentTime * 1000); - metadata.push('[CHAPTER]', 'TIMEBASE=1/1000', `START=${startMs}`, `END=${endMs}`, `title=${escapeFFMetadata(chapter.title)}`); } await writeFile(metadataPath, ';FFMETADATA1\n' + metadata.join('\n')); - - // Create list file for concat await writeFile( listPath, - chapters.map(c => `file '${c.filePath}'`).join('\n') + localChapters + .map((chapter) => `file '${chapter.localPath.replace(/'/g, "'\\''")}'`) + .join('\n'), ); if (format === 'mp3') { - // For MP3, re-encode to properly rebuild headers and duration metadata - // Using libmp3lame to ensure proper MP3 structure - await runFFmpeg([ - '-f', 'concat', - '-safe', '0', - '-i', listPath, - '-c:a', 'libmp3lame', - '-b:a', '64k', - outputPath - ], request.signal); + await runFFmpeg(['-f', 'concat', '-safe', '0', '-i', listPath, '-c:a', 'libmp3lame', '-b:a', '64k', outputPath], request.signal); } else { - // Combine all files into a single M4B with chapter metadata - await runFFmpeg([ - '-f', 'concat', - '-safe', '0', - '-i', listPath, - '-i', metadataPath, - '-map_metadata', '1', - '-c:a', 'aac', - '-b:a', '64k', - '-f', 'mp4', - outputPath - ], request.signal); - } - - // Clean up temporary files (but keep the chapters and complete file) - await Promise.all([ - unlink(metadataPath).catch(console.error), - unlink(listPath).catch(console.error) - ]); - - await writeFile(manifestPath, JSON.stringify(signature, null, 2)).catch(() => { }); - - // Stream the file back to the client - return streamFile(outputPath, format); - - } catch (error) { - if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) { - return NextResponse.json( - { error: 'cancelled' }, - { status: 499 } + await runFFmpeg( + [ + '-f', + 'concat', + '-safe', + '0', + '-i', + listPath, + '-i', + metadataPath, + '-map_metadata', + '1', + '-c:a', + 'aac', + '-b:a', + '64k', + '-f', + 'mp4', + outputPath, + ], + request.signal, ); } - console.error('Error creating M4B:', error); - return NextResponse.json( - { error: 'Failed to create M4B file' }, - { status: 500 } + + const outputBytes = await readFile(outputPath); + await putAudiobookObject(bookId, existingBook.userId, completeName, outputBytes, chapterFileMimeType(format), testNamespace); + await putAudiobookObject( + bookId, + existingBook.userId, + manifestName, + Buffer.from(JSON.stringify(signature, null, 2), 'utf8'), + 'application/json; charset=utf-8', + testNamespace, ); + + return new NextResponse(streamBuffer(outputBytes), { + headers: { + 'Content-Type': chapterFileMimeType(format), + 'Content-Disposition': `attachment; filename="audiobook.${format}"`, + 'Cache-Control': 'no-cache', + }, + }); + } catch (error) { + if ((error as Error)?.message === 'ABORTED' || request.signal.aborted) { + return NextResponse.json({ error: 'cancelled' }, { status: 499 }); + } + console.error('Error creating full audiobook:', error); + return NextResponse.json({ error: 'Failed to create full audiobook file' }, { status: 500 }); + } finally { + if (workDir) await rm(workDir, { recursive: true, force: true }).catch(() => {}); } } -// Helper function to stream file -function streamFile(filePath: string, format: string) { - const stream = createReadStream(filePath); - - const readableWebStream = new ReadableStream({ - start(controller) { - stream.on('data', (chunk) => { - controller.enqueue(chunk); - }); - stream.on('end', () => { - controller.close(); - }); - stream.on('error', (err) => { - controller.error(err); - }); - }, - cancel() { - stream.destroy(); - } - }); - - const mimeType = format === 'mp3' ? 'audio/mpeg' : 'audio/mp4'; - - return new NextResponse(readableWebStream, { - headers: { - 'Content-Type': mimeType, - 'Content-Disposition': `attachment; filename="audiobook.${format}"`, - 'Cache-Control': 'no-cache', - }, - }); -} export async function DELETE(request: NextRequest) { try { + if (!isS3Configured()) return s3NotConfiguredResponse(); + const bookId = request.nextUrl.searchParams.get('bookId'); if (!bookId) { return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 }); @@ -611,23 +616,11 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 }); } - await ensureAudiobooksV1Ready(); - if (!(await isAudiobooksV1Ready())) { - return NextResponse.json( - { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, - { status: 409 }, - ); - } - const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; - const { userId, authEnabled } = ctxOrRes; const testNamespace = getOpenReaderTestNamespace(request.headers); - const storageUserId = userId ?? getUnclaimedUserIdForNamespace(testNamespace); + const storageUserId = ctxOrRes.userId ?? getUnclaimedUserIdForNamespace(testNamespace); - await ensureDbIndexed(); - - // Delete from DB - with composite PK, we delete by both id and userId const [existingBook] = await db .select({ userId: audiobooks.userId }) .from(audiobooks) @@ -637,36 +630,16 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'Book not found' }, { status: 404 }); } - // Delete chapters first (no foreign key constraint with composite PK) await db .delete(audiobookChapters) .where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, storageUserId))); await db.delete(audiobooks).where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId))); - const intermediateDir = join( - getAudiobooksRootDir({ - userId, - authEnabled, - namespace: testNamespace, - }), - `${bookId}-audiobook`, - ); - - // If directory doesn't exist, consider it already reset - if (!existsSync(intermediateDir)) { - return NextResponse.json({ success: true, existed: false }); - } - - // Recursively delete the entire audiobook directory - await rm(intermediateDir, { recursive: true, force: true }); - - return NextResponse.json({ success: true, existed: true }); + const deleted = await deleteAudiobookPrefix(audiobookPrefix(bookId, storageUserId, testNamespace)).catch(() => 0); + return NextResponse.json({ success: true, existed: deleted > 0 }); } catch (error) { console.error('Error resetting audiobook:', error); - return NextResponse.json( - { error: 'Failed to reset audiobook' }, - { status: 500 } - ); + return NextResponse.json({ error: 'Failed to reset audiobook' }, { status: 500 }); } } diff --git a/src/app/api/audiobook/status/route.ts b/src/app/api/audiobook/status/route.ts index 1f502ca..3530704 100644 --- a/src/app/api/audiobook/status/route.ts +++ b/src/app/api/audiobook/status/route.ts @@ -1,18 +1,15 @@ import { NextRequest, NextResponse } from 'next/server'; -import { existsSync } from 'fs'; -import { join } from 'path'; -import { getAudiobooksRootDir, ensureAudiobooksV1Ready, isAudiobooksV1Ready } from '@/lib/server/docstore'; -import { listStoredChapters } from '@/lib/server/audiobook'; -import type { AudiobookGenerationSettings } from '@/types/client'; -import type { TTSAudiobookFormat, TTSAudiobookChapter } from '@/types/tts'; -import { readFile } from 'fs/promises'; -import { requireAuthContext } from '@/lib/server/auth'; +import { and, eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; -import { audiobooks } from '@/db/schema'; -import { eq, and, inArray } from 'drizzle-orm'; -import { ensureDbIndexed } from '@/lib/server/db-indexing'; +import { audiobooks, audiobookChapters } from '@/db/schema'; +import { requireAuthContext } from '@/lib/server/auth'; +import { getAudiobookObjectBuffer, isMissingBlobError, listAudiobookObjects } from '@/lib/server/audiobooks-blobstore'; +import { decodeChapterFileName } from '@/lib/server/audiobook'; +import { pruneAudiobookChaptersNotOnDisk } from '@/lib/server/audiobook-prune'; +import { isS3Configured } from '@/lib/server/s3'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; -import { pruneAudiobookChaptersNotOnDisk, pruneAudiobookIfMissingDir } from '@/lib/server/audiobook-prune'; +import type { AudiobookGenerationSettings } from '@/types/client'; +import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; export const dynamic = 'force-dynamic'; @@ -22,21 +19,55 @@ function isSafeId(value: string): boolean { return SAFE_ID_REGEX.test(value); } +function s3NotConfiguredResponse(): NextResponse { + return NextResponse.json( + { error: 'Audiobooks storage is not configured. Set S3_* environment variables.' }, + { status: 503 }, + ); +} + +type ChapterObject = { + index: number; + title: string; + format: TTSAudiobookFormat; + fileName: string; +}; + +function listChapterObjects(fileNames: string[]): ChapterObject[] { + const chapters = fileNames + .map((fileName) => { + const decoded = decodeChapterFileName(fileName); + if (!decoded) return null; + return { + index: decoded.index, + title: decoded.title, + format: decoded.format, + fileName, + } satisfies ChapterObject; + }) + .filter((value): value is ChapterObject => Boolean(value)) + .sort((a, b) => a.index - b.index); + + const deduped = new Map(); + for (const chapter of chapters) { + const current = deduped.get(chapter.index); + if (!current || chapter.fileName > current.fileName) { + deduped.set(chapter.index, chapter); + } + } + + return Array.from(deduped.values()).sort((a, b) => a.index - b.index); +} + export async function GET(request: NextRequest) { try { + if (!isS3Configured()) return s3NotConfiguredResponse(); + const bookId = request.nextUrl.searchParams.get('bookId'); if (!bookId || !isSafeId(bookId)) { return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 }); } - await ensureAudiobooksV1Ready(); - if (!(await isAudiobooksV1Ready())) { - return NextResponse.json( - { error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' }, - { status: 409 }, - ); - } - const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; @@ -46,15 +77,12 @@ export async function GET(request: NextRequest) { const storageUserId = userId ?? unclaimedUserId; const allowedUserIds = authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; - await ensureDbIndexed(); - - // Check if audiobook exists for user OR is unclaimed (similar to documents) const [existingBook] = await db .select({ userId: audiobooks.userId }) .from(audiobooks) .where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds))); + if (!existingBook) { - // Book doesn't exist for this user or unclaimed - return empty state return NextResponse.json({ chapters: [], exists: false, @@ -64,49 +92,56 @@ export async function GET(request: NextRequest) { }); } - const intermediateDir = join( - getAudiobooksRootDir({ - userId: existingBook.userId, - authEnabled, - namespace: testNamespace, - }), - `${bookId}-audiobook`, - ); + const objects = await listAudiobookObjects(bookId, existingBook.userId, testNamespace); + const objectNames = objects.map((object) => object.fileName); + const chapterObjects = listChapterObjects(objectNames); - if (!existsSync(intermediateDir)) { - await pruneAudiobookIfMissingDir(bookId, existingBook.userId, false); - return NextResponse.json({ - chapters: [], - exists: false, - hasComplete: false, - bookId: null, - settings: null, - }); - } - - const stored = await listStoredChapters(intermediateDir, request.signal); await pruneAudiobookChaptersNotOnDisk( bookId, existingBook.userId, - stored.map((c) => c.index), + chapterObjects.map((chapter) => chapter.index), ); - const chapters: TTSAudiobookChapter[] = stored.map((chapter) => ({ + + const chapterRows = await db + .select({ chapterIndex: audiobookChapters.chapterIndex, duration: audiobookChapters.duration }) + .from(audiobookChapters) + .where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, existingBook.userId))); + const durationByIndex = new Map(); + for (const row of chapterRows) { + durationByIndex.set(row.chapterIndex, Number(row.duration ?? 0)); + } + + const chapters: TTSAudiobookChapter[] = chapterObjects.map((chapter) => ({ index: chapter.index, title: chapter.title, - duration: chapter.durationSec, + duration: durationByIndex.get(chapter.index), status: 'completed', bookId, - format: chapter.format as TTSAudiobookFormat, + format: chapter.format, })); let settings: AudiobookGenerationSettings | null = null; try { - settings = JSON.parse(await readFile(join(intermediateDir, 'audiobook.meta.json'), 'utf8')) as AudiobookGenerationSettings; - } catch { + settings = JSON.parse((await getAudiobookObjectBuffer(bookId, existingBook.userId, 'audiobook.meta.json', testNamespace)).toString('utf8')) as AudiobookGenerationSettings; + } catch (error) { + if (!isMissingBlobError(error)) throw error; settings = null; } - const hasComplete = existsSync(join(intermediateDir, 'complete.mp3')) || existsSync(join(intermediateDir, 'complete.m4b')); + const hasComplete = objectNames.includes('complete.mp3') || objectNames.includes('complete.m4b'); + const exists = chapters.length > 0 || hasComplete || settings !== null; + + if (!exists) { + await db.delete(audiobookChapters).where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, existingBook.userId))); + await db.delete(audiobooks).where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, existingBook.userId))); + return NextResponse.json({ + chapters: [], + exists: false, + hasComplete: false, + bookId: null, + settings: null, + }); + } return NextResponse.json({ chapters, @@ -115,12 +150,8 @@ export async function GET(request: NextRequest) { bookId, settings, }); - } catch (error) { console.error('Error fetching chapters:', error); - return NextResponse.json( - { error: 'Failed to fetch chapters' }, - { status: 500 } - ); + return NextResponse.json({ error: 'Failed to fetch chapters' }, { status: 500 }); } } diff --git a/src/app/api/migrations/v1/route.ts b/src/app/api/migrations/v1/route.ts deleted file mode 100644 index cbee2db..0000000 --- a/src/app/api/migrations/v1/route.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { existsSync } from 'fs'; -import { mkdir, readdir, rename, rm } from 'fs/promises'; -import { join } from 'path'; -import { - AUDIOBOOKS_V1_DIR, - ensureAudiobooksV1Ready, - ensureDocumentsV1Ready, - isAudiobooksV1Ready, - isDocumentsV1Ready, -} from '@/lib/server/docstore'; -import { auth } from '@/lib/server/auth'; - -type Mapping = { oldId: string; id: string }; - -function isSafeId(value: string): boolean { - return /^[a-zA-Z0-9._-]{1,128}$/.test(value); -} - -async function mergeDirectoryContents(sourceDir: string, targetDir: string): Promise<{ moved: number; skipped: number }> { - let moved = 0; - let skipped = 0; - - let entries: Array = []; - try { - entries = await readdir(sourceDir, { withFileTypes: true }); - } catch { - return { moved, skipped }; - } - - for (const entry of entries) { - const sourcePath = join(sourceDir, entry.name); - const targetPath = join(targetDir, entry.name); - - if (entry.isDirectory()) { - await mkdir(targetPath, { recursive: true }); - const nested = await mergeDirectoryContents(sourcePath, targetPath); - moved += nested.moved; - skipped += nested.skipped; - - try { - const remaining = await readdir(sourcePath); - if (remaining.length === 0) { - await rm(sourcePath); - } - } catch { } - continue; - } - - if (!entry.isFile()) continue; - - if (existsSync(targetPath)) { - skipped++; - continue; - } - - try { - await rename(sourcePath, targetPath); - moved++; - } catch { - skipped++; - } - } - - return { moved, skipped }; -} - -async function rekeyAudiobooksV1(mappings: Mapping[]): Promise<{ renamed: number; merged: number; skipped: number }> { - let renamed = 0; - let merged = 0; - let skipped = 0; - - for (const mapping of mappings) { - if (mapping.oldId === mapping.id) continue; - const sourceDir = join(AUDIOBOOKS_V1_DIR, `${mapping.oldId}-audiobook`); - if (!existsSync(sourceDir)) continue; - - const targetDir = join(AUDIOBOOKS_V1_DIR, `${mapping.id}-audiobook`); - if (!existsSync(targetDir)) { - try { - await rename(sourceDir, targetDir); - renamed++; - continue; - } catch { - // Fall through to merge. - } - } - - await mkdir(targetDir, { recursive: true }); - const res = await mergeDirectoryContents(sourceDir, targetDir); - if (res.moved > 0) merged++; - skipped += res.skipped; - - try { - const remaining = await readdir(sourceDir); - if (remaining.length === 0) { - await rm(sourceDir); - } - } catch { } - } - - return { renamed, merged, skipped }; -} - -export async function POST(request: NextRequest) { - try { - // Auth check - require session - const session = await auth?.api.getSession({ headers: request.headers }); - if (auth && !session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const raw = (await request.json().catch(() => null)) as { mappings?: Mapping[] } | null; - const mappings = (raw?.mappings ?? []).filter( - (m): m is Mapping => Boolean(m && typeof m.oldId === 'string' && typeof m.id === 'string'), - ); - - for (const mapping of mappings) { - if (!isSafeId(mapping.oldId) || !isSafeId(mapping.id)) { - return NextResponse.json({ error: 'Invalid document id mapping' }, { status: 400 }); - } - } - - const documentsMigrated = await ensureDocumentsV1Ready(); - const audiobooksMigrated = await ensureAudiobooksV1Ready(); - const rekey = await rekeyAudiobooksV1(mappings); - - const documentsReady = await isDocumentsV1Ready(); - const audiobooksReady = await isAudiobooksV1Ready(); - - return NextResponse.json({ - success: true, - documentsReady, - audiobooksReady, - documentsMigrated, - audiobooksMigrated, - rekey, - }); - } catch (error) { - console.error('Error running v1 migrations:', error); - return NextResponse.json({ error: 'Failed to run v1 migrations' }, { status: 500 }); - } -} - diff --git a/src/app/api/migrations/v2/route.ts b/src/app/api/migrations/v2/route.ts deleted file mode 100644 index d289f03..0000000 --- a/src/app/api/migrations/v2/route.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { createHash } from 'crypto'; -import { existsSync } from 'fs'; -import { readdir, readFile, stat, unlink } from 'fs/promises'; -import path from 'path'; -import { and, eq } from 'drizzle-orm'; -import { NextRequest, NextResponse } from 'next/server'; -import { db } from '@/db'; -import { documents } from '@/db/schema'; -import { auth } from '@/lib/server/auth'; -import { DOCUMENTS_V1_DIR } from '@/lib/server/docstore'; -import { isValidDocumentId, putDocumentBlob } from '@/lib/server/documents-blobstore'; -import { toDocumentTypeFromName } from '@/lib/server/documents-utils'; -import { contentTypeForName } from '@/lib/server/library'; -import { isS3Configured } from '@/lib/server/s3'; -import type { DocumentType } from '@/types/documents'; -import { - applyOpenReaderTestNamespacePath, - getOpenReaderTestNamespace, - getUnclaimedUserIdForNamespace, -} from '@/lib/server/test-namespace'; - -export const dynamic = 'force-dynamic'; - -type V2Body = { - deleteLocal?: boolean; - dryRun?: boolean; -}; - -type LegacyDocumentCandidate = { - id: string; - name: string; - type: string; - size: number; - lastModified: number; -}; - -function isPreconditionFailed(error: unknown): boolean { - if (!error || typeof error !== 'object') return false; - const maybe = error as { name?: string; $metadata?: { httpStatusCode?: number } }; - return maybe.$metadata?.httpStatusCode === 412 || maybe.name === 'PreconditionFailed'; -} - -function extractIdFromFileName(fileName: string): string | null { - const match = /^([a-f0-9]{64})__/i.exec(fileName); - if (!match) return null; - const id = match[1].toLowerCase(); - return isValidDocumentId(id) ? id : null; -} - -function decodeNameFromFileName(fileName: string, id: string): string { - const prefix = `${id}__`; - if (!fileName.startsWith(prefix)) return `${id}.bin`; - const encoded = fileName.slice(prefix.length); - try { - return decodeURIComponent(encoded); - } catch { - return `${id}.bin`; - } -} - -function sniffBinaryDocumentType(bytes: Buffer): Exclude | null { - // PDF signature: "%PDF-" - if (bytes.length >= 5 && bytes.subarray(0, 5).toString('ascii') === '%PDF-') { - return 'pdf'; - } - - // ZIP signatures: PK.. - const isZip = - bytes.length >= 4 && - bytes[0] === 0x50 && - bytes[1] === 0x4b && - (bytes[2] === 0x03 || bytes[2] === 0x05 || bytes[2] === 0x07) && - (bytes[3] === 0x04 || bytes[3] === 0x06 || bytes[3] === 0x08); - if (!isZip) return null; - - // EPUB/DOCX markers usually appear in ZIP local headers near the start. - const probe = bytes.subarray(0, Math.min(bytes.length, 1024 * 1024)).toString('latin1'); - if (probe.includes('application/epub+zip') || probe.includes('META-INF/container.xml')) { - return 'epub'; - } - if (probe.includes('[Content_Types].xml') && probe.includes('word/')) { - return 'docx'; - } - - return null; -} - -function normalizeNameForType(name: string, id: string, type: DocumentType): string { - if (type === 'html') return name; - const expectedExt = type === 'pdf' ? '.pdf' : type === 'epub' ? '.epub' : '.docx'; - if (name.toLowerCase().endsWith(expectedExt)) return name; - const base = name.replace(/\.bin$/i, ''); - return `${base || id}${expectedExt}`; -} - -function contentTypeForDocument(type: DocumentType, name: string): string { - if (type === 'pdf') return 'application/pdf'; - if (type === 'epub') return 'application/epub+zip'; - if (type === 'docx') return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; - return contentTypeForName(name); -} - -async function migrateDocumentRowsToBlobHandle(dryRun: boolean): Promise { - const rows = (await db.select().from(documents)) as Array<{ - id: string; - userId: string; - filePath: string; - }>; - - let updated = 0; - for (const row of rows) { - if (!row.id || !row.userId || row.filePath === row.id) continue; - updated++; - if (dryRun) continue; - await db - .update(documents) - .set({ filePath: row.id }) - .where(and(eq(documents.id, row.id), eq(documents.userId, row.userId))); - } - return updated; -} - -async function seedMissingDocumentRows( - userId: string, - candidates: LegacyDocumentCandidate[], - dryRun: boolean, -): Promise { - if (candidates.length === 0) return 0; - - const existingRows = (await db.select().from(documents).where(eq(documents.userId, userId))) as Array<{ - id: string; - }>; - const existingIds = new Set(existingRows.map((row) => row.id)); - - const seen = new Set(); - const toInsert: LegacyDocumentCandidate[] = []; - for (const candidate of candidates) { - if (seen.has(candidate.id)) continue; - seen.add(candidate.id); - if (existingIds.has(candidate.id)) continue; - toInsert.push(candidate); - } - - if (toInsert.length === 0) return 0; - if (dryRun) return toInsert.length; - - await db.insert(documents).values( - toInsert.map((candidate) => ({ - id: candidate.id, - userId, - name: candidate.name, - type: candidate.type, - size: candidate.size, - lastModified: candidate.lastModified, - filePath: candidate.id, - })), - ).onConflictDoNothing(); - - return toInsert.length; -} - -export async function POST(request: NextRequest) { - try { - const session = await auth?.api.getSession({ headers: request.headers }); - if (auth && !session?.user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - if (!isS3Configured()) { - return NextResponse.json( - { error: 'S3 is not configured. Set S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY.' }, - { status: 409 }, - ); - } - - const raw = (await request.json().catch(() => ({}))) as V2Body; - const dryRun = raw.dryRun === true; - const deleteLocal = raw.deleteLocal === true; - const testNamespace = getOpenReaderTestNamespace(request.headers); - const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const docsDir = applyOpenReaderTestNamespacePath(DOCUMENTS_V1_DIR, testNamespace); - - if (!existsSync(docsDir)) { - const rowsUpdated = await migrateDocumentRowsToBlobHandle(dryRun); - return NextResponse.json({ - success: true, - dryRun, - deleteLocal, - docsDir, - filesScanned: 0, - uploaded: 0, - alreadyPresent: 0, - skippedInvalid: 0, - deletedLocal: 0, - dbRowsUpdated: rowsUpdated, - dbRowsSeeded: 0, - }); - } - - const entries = await readdir(docsDir, { withFileTypes: true }); - const files = entries.filter((entry) => entry.isFile()).map((entry) => entry.name); - - let uploaded = 0; - let alreadyPresent = 0; - let skippedInvalid = 0; - let deletedLocal = 0; - const candidates: LegacyDocumentCandidate[] = []; - - for (const fileName of files) { - const fullPath = path.join(docsDir, fileName); - const bytes = await readFile(fullPath); - const fileStats = await stat(fullPath); - - const extractedId = extractIdFromFileName(fileName); - const id = extractedId ?? createHash('sha256').update(bytes).digest('hex'); - if (!isValidDocumentId(id)) { - skippedInvalid++; - continue; - } - - const inferredName = decodeNameFromFileName(fileName, id); - const inferredType = toDocumentTypeFromName(inferredName); - const type = inferredType === 'html' ? (sniffBinaryDocumentType(bytes) ?? inferredType) : inferredType; - const normalizedName = normalizeNameForType(inferredName, id, type); - const contentType = contentTypeForDocument(type, normalizedName); - const lastModified = Number.isFinite(fileStats.mtimeMs) ? Math.floor(fileStats.mtimeMs) : Date.now(); - - candidates.push({ - id, - name: normalizedName, - type, - size: bytes.length, - lastModified, - }); - - if (!dryRun) { - try { - await putDocumentBlob(id, bytes, contentType, testNamespace); - uploaded++; - } catch (error) { - if (isPreconditionFailed(error)) { - alreadyPresent++; - } else { - throw error; - } - } - } - - if (deleteLocal && !dryRun) { - await unlink(fullPath).catch(() => {}); - deletedLocal++; - } - } - - const rowsUpdated = await migrateDocumentRowsToBlobHandle(dryRun); - const rowsSeeded = await seedMissingDocumentRows(unclaimedUserId, candidates, dryRun); - - return NextResponse.json({ - success: true, - dryRun, - deleteLocal, - docsDir, - filesScanned: files.length, - uploaded, - alreadyPresent, - skippedInvalid, - deletedLocal, - dbRowsUpdated: rowsUpdated, - dbRowsSeeded: rowsSeeded, - }); - } catch (error) { - console.error('Error running v2 migrations:', error); - return NextResponse.json({ error: 'Failed to run v2 migrations' }, { status: 500 }); - } -} diff --git a/src/app/api/user/claim/route.ts b/src/app/api/user/claim/route.ts index 592cbfa..c537cd6 100644 --- a/src/app/api/user/claim/route.ts +++ b/src/app/api/user/claim/route.ts @@ -1,21 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; import { claimAnonymousData } from '@/lib/server/claim-data'; import { auth } from '@/lib/server/auth'; -import { ensureDbIndexed, getUnclaimedCounts } from '@/lib/server/db-indexing'; -import { isDocumentsV1Ready } from '@/lib/server/docstore'; import { db } from '@/db'; -import { documents } from '@/db/schema'; -import { count, ne } from 'drizzle-orm'; +import { audiobooks, documents } from '@/db/schema'; +import { count, eq, ne } from 'drizzle-orm'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; async function checkClaimMigrationReadiness(): Promise { - const documentsV1Ready = await isDocumentsV1Ready(); - if (!documentsV1Ready) { - return NextResponse.json( - { error: 'Document migration is not ready. Run startup migrations first.' }, - { status: 409 }, - ); - } - const [legacyRows] = await db .select({ count: count() }) .from(documents) @@ -31,6 +22,22 @@ async function checkClaimMigrationReadiness(): Promise { return null; } +async function getClaimableCounts(unclaimedUserId: string): Promise<{ documents: number; audiobooks: number }> { + const [docCount] = await db + .select({ count: count() }) + .from(documents) + .where(eq(documents.userId, unclaimedUserId)); + const [bookCount] = await db + .select({ count: count() }) + .from(audiobooks) + .where(eq(audiobooks.userId, unclaimedUserId)); + + return { + documents: Number(docCount?.count ?? 0), + audiobooks: Number(bookCount?.count ?? 0), + }; +} + export async function GET(req: NextRequest) { try { const session = await auth?.api.getSession({ headers: req.headers }); @@ -41,8 +48,9 @@ export async function GET(req: NextRequest) { const readiness = await checkClaimMigrationReadiness(); if (readiness) return readiness; - await ensureDbIndexed(); - const counts = await getUnclaimedCounts(); + const testNamespace = getOpenReaderTestNamespace(req.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + const counts = await getClaimableCounts(unclaimedUserId); return NextResponse.json({ success: true, ...counts }); } catch (error) { console.error('Error checking claimable data:', error); @@ -60,10 +68,11 @@ export async function POST(req: NextRequest) { const readiness = await checkClaimMigrationReadiness(); if (readiness) return readiness; + const testNamespace = getOpenReaderTestNamespace(req.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const userId = session.user.id; - await ensureDbIndexed(); - const result = await claimAnonymousData(userId); + const result = await claimAnonymousData(userId, unclaimedUserId, testNamespace); return NextResponse.json({ success: true, diff --git a/src/app/api/whisper/route.ts b/src/app/api/whisper/route.ts index bcec3db..44364f3 100644 --- a/src/app/api/whisper/route.ts +++ b/src/app/api/whisper/route.ts @@ -7,6 +7,7 @@ import { spawn } from 'child_process'; import type { TTSSentenceAlignment, TTSAudioBytes, TTSAudioBuffer } from '@/types/tts'; import { preprocessSentenceForAudio } from '@/lib/nlp'; import { auth } from '@/lib/server/auth'; +import { getFFmpegPath } from '@/lib/server/ffmpeg-bin'; export const runtime = 'nodejs'; @@ -353,7 +354,7 @@ async function alignAudioWithText( await writeFile(inputPath, Buffer.from(new Uint8Array(audioBuffer))); await new Promise((resolve, reject) => { - const ffmpeg = spawn('ffmpeg', [ + const ffmpeg = spawn(getFFmpegPath(), [ '-y', '-i', inputPath, diff --git a/src/app/epub/[id]/page.tsx b/src/app/epub/[id]/page.tsx index 7744c67..51d5d0e 100644 --- a/src/app/epub/[id]/page.tsx +++ b/src/app/epub/[id]/page.tsx @@ -19,7 +19,7 @@ import { resolveDocumentId } from '@/lib/dexie'; import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; -const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; +const canExportAudiobook = process.env.NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT !== 'false'; export default function EPUBPage() { const { id } = useParams(); @@ -158,7 +158,7 @@ export default function EPUBPage() { onZoomDecrease={() => setPadPct(p => Math.max(p - 10, 0))} onOpenSettings={() => setIsSettingsOpen(true)} onOpenAudiobook={() => setIsAudiobookModalOpen(true)} - isDev={isDev} + showAudiobookExport={canExportAudiobook} minZoom={0} maxZoom={100} /> @@ -177,7 +177,7 @@ export default function EPUBPage() { )} - {isDev && ( + {canExportAudiobook && ( setIsSettingsOpen(true)} onOpenAudiobook={() => setIsAudiobookModalOpen(true)} - isDev={isDev} + showAudiobookExport={canExportAudiobook} minZoom={50} maxZoom={300} /> @@ -171,7 +171,7 @@ export default function PDFViewerPage() { )} - {isDev && ( + {canExportAudiobook && ( void; onOpenSettings: () => void; onOpenAudiobook?: () => void; - isDev?: boolean; + showAudiobookExport?: boolean; minZoom?: number; maxZoom?: number; } @@ -23,7 +23,7 @@ export function DocumentHeaderMenu({ onZoomDecrease, onOpenSettings, onOpenAudiobook, - isDev, + showAudiobookExport, minZoom = 0, maxZoom = 100 }: DocumentHeaderMenuProps) { @@ -38,7 +38,7 @@ export function DocumentHeaderMenu({ min={minZoom} max={maxZoom} /> - {isDev && onOpenAudiobook && ( + {showAudiobookExport && onOpenAudiobook && ( } @@ -355,7 +357,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { updateConfigKey('pdfWordHighlightEnabled', e.target.checked) } @@ -366,7 +368,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {

- Highlight individual words using audio timestamps generated by whisper.cpp {!isDev && '(requires self-hosted)'} + Highlight individual words using audio timestamps generated by whisper.cpp {!canWordHighlight && '(disabled by configuration)'}

@@ -392,7 +394,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { updateConfigKey('epubWordHighlightEnabled', e.target.checked) } @@ -403,7 +405,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {

- Highlight individual words using audio timestamps generated by whisper.cpp {!isDev && '(requires self-hosted)'} + Highlight individual words using audio timestamps generated by whisper.cpp {!canWordHighlight && '(disabled by configuration)'}

diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index 9773e20..74737b9 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -2,7 +2,7 @@ import { createContext, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react'; import { useLiveQuery } from 'dexie-react-hooks'; -import { db, getDocumentIdMappings, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/dexie'; +import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/dexie'; import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config'; import toast from 'react-hot-toast'; export type { ViewType } from '@/types/config'; @@ -99,58 +99,6 @@ export function ConfigProvider({ children }: { children: ReactNode }) { const run = async () => { try { await migrateLegacyDexieDocumentIdsToSha(); - const mappings = await getDocumentIdMappings(); - - // Run server-side v1 migrations proactively, since the client may now - // reference SHA-based IDs immediately after the Dexie migration. - const response = await fetch('/api/migrations/v1', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mappings }), - }).catch(() => null); - - if (response?.ok) { - const data = await response.json(); - const v2ApplyResponse = await fetch('/api/migrations/v2', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ dryRun: false, deleteLocal: false }), - }).catch(() => null); - const v2ApplyData = v2ApplyResponse?.ok ? await v2ApplyResponse.json().catch(() => null) : null; - - const didMigrateV1 = - data.documentsMigrated || - data.audiobooksMigrated || - (data.rekey?.renamed ?? 0) > 0 || - (data.rekey?.merged ?? 0) > 0; - - if (v2ApplyData) { - const uploaded = Number(v2ApplyData.uploaded ?? 0); - const alreadyPresent = Number(v2ApplyData.alreadyPresent ?? 0); - const dbRowsUpdated = Number(v2ApplyData.dbRowsUpdated ?? 0); - const dbRowsSeeded = Number(v2ApplyData.dbRowsSeeded ?? 0); - const deletedLocal = Number(v2ApplyData.deletedLocal ?? 0); - const dbRowsMigrated = dbRowsUpdated + dbRowsSeeded; - const didMigrateV2 = uploaded > 0 || dbRowsMigrated > 0 || deletedLocal > 0; - - if (didMigrateV2) { - toast.success( - `Legacy document migration complete: ${uploaded} uploaded, ${alreadyPresent} already in S3, ${dbRowsMigrated} DB row(s) migrated.`, - { duration: 6000, icon: '📦' }, - ); - window.dispatchEvent(new CustomEvent('openreader:documentsChanged', { - detail: { reason: 'migration-v2-complete' }, - })); - } - } - - if (didMigrateV1) { - toast.success('Library migration complete', { - duration: 5000, - icon: '📦', - }); - } - } } catch (error) { console.warn('Startup migrations failed:', error); } diff --git a/src/lib/server/audiobook.ts b/src/lib/server/audiobook.ts index 86993b5..9c1300a 100644 --- a/src/lib/server/audiobook.ts +++ b/src/lib/server/audiobook.ts @@ -1,6 +1,7 @@ import { spawn } from 'child_process'; import path from 'path'; import { readdir } from 'fs/promises'; +import { getFFprobePath } from '@/lib/server/ffmpeg-bin'; export type StoredChapter = { index: number; @@ -78,7 +79,7 @@ type ProbeResult = { export async function ffprobeAudio(filePath: string, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { - const ffprobe = spawn('ffprobe', [ + const ffprobe = spawn(getFFprobePath(), [ '-v', 'quiet', '-print_format', diff --git a/src/lib/server/audiobooks-blobstore.ts b/src/lib/server/audiobooks-blobstore.ts new file mode 100644 index 0000000..127e219 --- /dev/null +++ b/src/lib/server/audiobooks-blobstore.ts @@ -0,0 +1,251 @@ +import { + DeleteObjectCommand, + DeleteObjectsCommand, + GetObjectCommand, + HeadObjectCommand, + ListObjectsV2Command, + PutObjectCommand, +} from '@aws-sdk/client-s3'; +import { getS3Client, getS3Config } from '@/lib/server/s3'; + +const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; +const SAFE_BOOK_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; +const SAFE_USER_ID_REGEX = /^[a-zA-Z0-9._:-]{1,256}$/; + +export type AudiobookBlobObject = { + key: string; + fileName: string; + size: number; + lastModified: number; + eTag: string | null; +}; + +function sanitizeNamespace(namespace: string | null): string | null { + if (!namespace) return null; + if (!SAFE_NAMESPACE_REGEX.test(namespace)) return null; + return namespace; +} + +function assertSafeBookId(bookId: string): void { + if (!SAFE_BOOK_ID_REGEX.test(bookId)) { + throw new Error(`Invalid audiobook id: ${bookId}`); + } +} + +function assertSafeUserId(userId: string): void { + if (!SAFE_USER_ID_REGEX.test(userId)) { + throw new Error(`Invalid user id for audiobook storage scope: ${userId}`); + } +} + +function assertSafeFileName(fileName: string): void { + if (!fileName || fileName === '.' || fileName === '..' || fileName.includes('/') || fileName.includes('\\')) { + throw new Error(`Invalid audiobook file name: ${fileName}`); + } +} + +function isNodeReadableStream(value: unknown): value is NodeJS.ReadableStream { + return !!value && typeof value === 'object' && 'on' in value && typeof (value as NodeJS.ReadableStream).on === 'function'; +} + +async function streamToBuffer(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + if (Buffer.isBuffer(chunk)) { + chunks.push(chunk); + } else if (typeof chunk === 'string') { + chunks.push(Buffer.from(chunk)); + } else { + chunks.push(Buffer.from(chunk as Uint8Array)); + } + } + return Buffer.concat(chunks); +} + +async function bodyToBuffer(body: unknown): Promise { + if (!body) return Buffer.alloc(0); + + if (body instanceof Uint8Array) return Buffer.from(body); + if (ArrayBuffer.isView(body)) return Buffer.from(body.buffer, body.byteOffset, body.byteLength); + if (body instanceof ArrayBuffer) return Buffer.from(body); + + if (typeof body === 'object' && body !== null && 'transformToByteArray' in body) { + const maybe = body as { transformToByteArray?: () => Promise }; + if (typeof maybe.transformToByteArray === 'function') { + return Buffer.from(await maybe.transformToByteArray()); + } + } + + if (isNodeReadableStream(body)) { + return streamToBuffer(body); + } + + throw new Error('Unsupported S3 response body type'); +} + +export function isPreconditionFailed(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const maybe = error as { name?: string; $metadata?: { httpStatusCode?: number } }; + return maybe.$metadata?.httpStatusCode === 412 || maybe.name === 'PreconditionFailed'; +} + +export function isMissingBlobError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const maybe = error as { name?: string; Code?: string; $metadata?: { httpStatusCode?: number } }; + if (maybe.$metadata?.httpStatusCode === 404) return true; + if (maybe.name === 'NotFound' || maybe.name === 'NoSuchKey') return true; + if (maybe.Code === 'NotFound' || maybe.Code === 'NoSuchKey') return true; + return false; +} + +export function audiobookPrefix(bookId: string, userId: string, namespace: string | null): string { + assertSafeBookId(bookId); + assertSafeUserId(userId); + const cfg = getS3Config(); + const ns = sanitizeNamespace(namespace); + const nsSegment = ns ? `ns/${ns}/` : ''; + return `${cfg.prefix}/audiobooks_v1/${nsSegment}users/${encodeURIComponent(userId)}/${bookId}-audiobook/`; +} + +export function audiobookKey(bookId: string, userId: string, fileName: string, namespace: string | null): string { + assertSafeFileName(fileName); + return `${audiobookPrefix(bookId, userId, namespace)}${fileName}`; +} + +export async function listAudiobookObjects(bookId: string, userId: string, namespace: string | null): Promise { + const cfg = getS3Config(); + const client = getS3Client(); + const prefix = audiobookPrefix(bookId, userId, namespace); + let continuationToken: string | undefined; + const objects: AudiobookBlobObject[] = []; + + do { + const listRes = await client.send( + new ListObjectsV2Command({ + Bucket: cfg.bucket, + Prefix: prefix, + ContinuationToken: continuationToken, + }), + ); + + for (const entry of listRes.Contents ?? []) { + const key = entry.Key; + if (!key || !key.startsWith(prefix)) continue; + const fileName = key.slice(prefix.length); + if (!fileName || fileName.includes('/')) continue; + objects.push({ + key, + fileName, + size: Number(entry.Size ?? 0), + lastModified: entry.LastModified?.getTime() ?? 0, + eTag: entry.ETag ?? null, + }); + } + + continuationToken = listRes.IsTruncated ? listRes.NextContinuationToken : undefined; + } while (continuationToken); + + return objects; +} + +export async function headAudiobookObject( + bookId: string, + userId: string, + fileName: string, + namespace: string | null, +): Promise<{ contentLength: number; contentType: string | null; eTag: string | null }> { + const cfg = getS3Config(); + const client = getS3Client(); + const key = audiobookKey(bookId, userId, fileName, namespace); + const res = await client.send(new HeadObjectCommand({ Bucket: cfg.bucket, Key: key })); + return { + contentLength: Number(res.ContentLength ?? 0), + contentType: res.ContentType ?? null, + eTag: res.ETag ?? null, + }; +} + +export async function getAudiobookObjectBuffer(bookId: string, userId: string, fileName: string, namespace: string | null): Promise { + const cfg = getS3Config(); + const client = getS3Client(); + const key = audiobookKey(bookId, userId, fileName, namespace); + const res = await client.send(new GetObjectCommand({ Bucket: cfg.bucket, Key: key })); + return bodyToBuffer(res.Body); +} + +export async function getAudiobookObjectStream(bookId: string, userId: string, fileName: string, namespace: string | null): Promise { + const cfg = getS3Config(); + const client = getS3Client(); + const key = audiobookKey(bookId, userId, fileName, namespace); + const res = await client.send(new GetObjectCommand({ Bucket: cfg.bucket, Key: key })); + return res.Body; +} + +export async function putAudiobookObject( + bookId: string, + userId: string, + fileName: string, + body: Buffer, + contentType: string, + namespace: string | null, + options?: { ifNoneMatch?: boolean }, +): Promise { + const cfg = getS3Config(); + const client = getS3Client(); + const key = audiobookKey(bookId, userId, fileName, namespace); + await client.send( + new PutObjectCommand({ + Bucket: cfg.bucket, + Key: key, + Body: body, + ContentType: contentType, + ...(options?.ifNoneMatch ? { IfNoneMatch: '*' } : {}), + }), + ); +} + +export async function deleteAudiobookObject(bookId: string, userId: string, fileName: string, namespace: string | null): Promise { + const cfg = getS3Config(); + const client = getS3Client(); + const key = audiobookKey(bookId, userId, fileName, namespace); + await client.send(new DeleteObjectCommand({ Bucket: cfg.bucket, Key: key })); +} + +export async function deleteAudiobookPrefix(prefix: string): Promise { + const cfg = getS3Config(); + const client = getS3Client(); + const cleanedPrefix = prefix.replace(/^\/+/, ''); + let deleted = 0; + let continuationToken: string | undefined; + + do { + const listRes = await client.send( + new ListObjectsV2Command({ + Bucket: cfg.bucket, + Prefix: cleanedPrefix, + ContinuationToken: continuationToken, + }), + ); + + const keys = (listRes.Contents ?? []) + .map((item) => item.Key) + .filter((value): value is string => typeof value === 'string' && value.length > 0); + + if (keys.length > 0) { + const deleteRes = await client.send( + new DeleteObjectsCommand({ + Bucket: cfg.bucket, + Delete: { + Objects: keys.map((Key) => ({ Key })), + Quiet: true, + }, + }), + ); + deleted += deleteRes.Deleted?.length ?? 0; + } + + continuationToken = listRes.IsTruncated ? listRes.NextContinuationToken : undefined; + } while (continuationToken); + + return deleted; +} diff --git a/src/lib/server/claim-data.ts b/src/lib/server/claim-data.ts index f9b3dbd..32624f4 100644 --- a/src/lib/server/claim-data.ts +++ b/src/lib/server/claim-data.ts @@ -1,85 +1,85 @@ import { db } from '@/db'; import { documents, audiobooks, audiobookChapters } from '@/db/schema'; -import { eq, and } from 'drizzle-orm'; -import fs from 'fs/promises'; +import { eq } from 'drizzle-orm'; +import { UNCLAIMED_USER_ID } from './docstore'; import { - UNCLAIMED_USER_ID, - getUserAudiobookDir, - moveAudiobookToUser, - listUserAudiobookIds, -} from './docstore'; + deleteAudiobookObject, + getAudiobookObjectBuffer, + listAudiobookObjects, + putAudiobookObject, +} from './audiobooks-blobstore'; +import { isS3Configured } from './s3'; import { isAuthEnabled } from '@/lib/server/auth-config'; -export async function claimAnonymousData(userId: string) { - if (!isAuthEnabled() || !userId) return { documents: 0, audiobooks: 0 }; +type AudiobookRow = { + id: string; + userId: string; + title: string; + author: string | null; + description: string | null; + coverPath: string | null; + duration: number | null; + createdAt: unknown; +}; - // Get list of unclaimed audiobook IDs before updating DB - const unclaimedBookIds = await listUserAudiobookIds(UNCLAIMED_USER_ID); +type AudiobookChapterRow = { + id: string; + bookId: string; + userId: string; + chapterIndex: number; + title: string; + duration: number | null; + filePath: string; + format: string; +}; - // Update Documents - documents use shared storage, only DB update needed - const docResult = await db.update(documents) - .set({ userId }) - .where(eq(documents.userId, UNCLAIMED_USER_ID)) - .returning({ id: documents.id }); +function contentTypeForAudiobookObject(fileName: string): string { + if (fileName.endsWith('.mp3')) return 'audio/mpeg'; + if (fileName.endsWith('.m4b')) return 'audio/mp4'; + if (fileName.endsWith('.json')) return 'application/json; charset=utf-8'; + return 'application/octet-stream'; +} - // For audiobooks, we need to: - // 1. Move the physical folders from unclaimed to user's folder - // 2. Update the DB records +async function moveAudiobookBlobScope( + bookId: string, + fromUserId: string, + toUserId: string, + namespace: string | null, +): Promise { + if (fromUserId === toUserId) return; - let audiobooksClaimedCount = 0; - const userDir = getUserAudiobookDir(userId); - await fs.mkdir(userDir, { recursive: true }); + const objects = await listAudiobookObjects(bookId, fromUserId, namespace); + if (objects.length === 0) return; - for (const bookId of unclaimedBookIds) { - try { - // Move the audiobook folder - const moved = await moveAudiobookToUser(bookId, UNCLAIMED_USER_ID, userId); - if (moved) { - // Update DB - delete old record and insert new one (composite PK requires this) - const [oldRecord] = await db.select().from(audiobooks).where( - and(eq(audiobooks.id, bookId), eq(audiobooks.userId, UNCLAIMED_USER_ID)) - ); - - if (oldRecord) { - // Get chapters - const oldChapters = await db.select().from(audiobookChapters).where( - and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, UNCLAIMED_USER_ID)) - ); - - // Delete old records - await db.delete(audiobookChapters).where( - and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, UNCLAIMED_USER_ID)) - ); - await db.delete(audiobooks).where( - and(eq(audiobooks.id, bookId), eq(audiobooks.userId, UNCLAIMED_USER_ID)) - ); - - // Insert new records with new userId - await db.insert(audiobooks).values({ - ...oldRecord, - userId, - }); - - for (const chapter of oldChapters) { - await db.insert(audiobookChapters).values({ - ...chapter, - userId, - }); - } - - audiobooksClaimedCount++; - console.log(`Claimed audiobook: ${bookId}`); - } - } - } catch (err) { - console.error(`Error claiming audiobook ${bookId}:`, err); - } + for (const object of objects) { + const bytes = await getAudiobookObjectBuffer(bookId, fromUserId, object.fileName, namespace); + await putAudiobookObject( + bookId, + toUserId, + object.fileName, + bytes, + contentTypeForAudiobookObject(object.fileName), + namespace, + ); } + for (const object of objects) { + await deleteAudiobookObject(bookId, fromUserId, object.fileName, namespace).catch(() => {}); + } +} + +export async function claimAnonymousData(userId: string, unclaimedUserId: string = UNCLAIMED_USER_ID, namespace: string | null = null) { + if (!isAuthEnabled() || !userId) return { documents: 0, audiobooks: 0 }; + + const [documentsClaimed, audiobooksClaimed] = await Promise.all([ + transferUserDocuments(unclaimedUserId, userId), + transferUserAudiobooks(unclaimedUserId, userId, namespace), + ]); + return { - documents: docResult.length, - audiobooks: audiobooksClaimedCount + documents: documentsClaimed, + audiobooks: audiobooksClaimed, }; } @@ -120,60 +120,44 @@ export async function transferUserDocuments( * Used when an anonymous user creates a real account. * @returns number of audiobooks transferred */ -export async function transferUserAudiobooks(fromUserId: string, toUserId: string): Promise { +export async function transferUserAudiobooks( + fromUserId: string, + toUserId: string, + namespace: string | null = null, +): Promise { if (!isAuthEnabled() || !fromUserId || !toUserId) return 0; + if (fromUserId === toUserId) return 0; - const bookIds = await listUserAudiobookIds(fromUserId); - let transferred = 0; + const books = (await db + .select() + .from(audiobooks) + .where(eq(audiobooks.userId, fromUserId))) as AudiobookRow[]; + if (books.length === 0) return 0; - const toUserDir = getUserAudiobookDir(toUserId); - await fs.mkdir(toUserDir, { recursive: true }); - - for (const bookId of bookIds) { - try { - // Move the audiobook folder - const moved = await moveAudiobookToUser(bookId, fromUserId, toUserId); - if (moved) { - // Update DB - delete old record and insert new one (composite PK) - const [oldRecord] = await db.select().from(audiobooks).where( - and(eq(audiobooks.id, bookId), eq(audiobooks.userId, fromUserId)) - ); - - if (oldRecord) { - // Get chapters - const oldChapters = await db.select().from(audiobookChapters).where( - and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, fromUserId)) - ); - - // Delete old records - await db.delete(audiobookChapters).where( - and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, fromUserId)) - ); - await db.delete(audiobooks).where( - and(eq(audiobooks.id, bookId), eq(audiobooks.userId, fromUserId)) - ); - - // Insert new records with new userId - await db.insert(audiobooks).values({ - ...oldRecord, - userId: toUserId, - }); - - for (const chapter of oldChapters) { - await db.insert(audiobookChapters).values({ - ...chapter, - userId: toUserId, - }); - } - - transferred++; - console.log(`Transferred audiobook ${bookId} from ${fromUserId} to ${toUserId}`); - } - } - } catch (err) { - console.error(`Error transferring audiobook ${bookId}:`, err); + if (isS3Configured()) { + for (const book of books) { + await moveAudiobookBlobScope(book.id, fromUserId, toUserId, namespace); } } - return transferred; + await db + .insert(audiobooks) + .values(books.map((book) => ({ ...book, userId: toUserId }))) + .onConflictDoNothing(); + + const chapters = (await db + .select() + .from(audiobookChapters) + .where(eq(audiobookChapters.userId, fromUserId))) as AudiobookChapterRow[]; + if (chapters.length > 0) { + await db + .insert(audiobookChapters) + .values(chapters.map((chapter) => ({ ...chapter, userId: toUserId }))) + .onConflictDoNothing(); + } + + await db.delete(audiobookChapters).where(eq(audiobookChapters.userId, fromUserId)); + await db.delete(audiobooks).where(eq(audiobooks.userId, fromUserId)); + + return books.length; } diff --git a/src/lib/server/db-indexing.ts b/src/lib/server/db-indexing.ts deleted file mode 100644 index 1572fcd..0000000 --- a/src/lib/server/db-indexing.ts +++ /dev/null @@ -1,186 +0,0 @@ -import fs from 'fs/promises'; -import { existsSync } from 'fs'; -import path from 'path'; -import { isAuthEnabled } from '@/lib/server/auth-config'; -import { db } from '@/db'; -import { audiobookChapters, audiobooks, documents } from '@/db/schema'; -import { and, count, eq } from 'drizzle-orm'; -import { listStoredChapters } from '@/lib/server/audiobook'; -import { AUDIOBOOKS_V1_DIR, UNCLAIMED_USER_ID, getUnclaimedAudiobookDir } from '@/lib/server/docstore'; - -const DOCSTORE_DIR = path.join(process.cwd(), 'docstore'); -const MIGRATIONS_DIR = path.join(DOCSTORE_DIR, '.migrations'); -const STATE_PATH = path.join(MIGRATIONS_DIR, 'db-index.json'); - -type DbIndexState = { - indexedAt: number; - mode: 'auth' | 'noauth'; -}; - -let inflight: Promise | null = null; -let memoryIndexedMode: DbIndexState['mode'] | null = null; - -async function readState(): Promise { - try { - const raw = await fs.readFile(STATE_PATH, 'utf8'); - return JSON.parse(raw) as DbIndexState; - } catch { - return null; - } -} - -async function writeState(): Promise { - await fs.mkdir(MIGRATIONS_DIR, { recursive: true }); - const state: DbIndexState = { - indexedAt: Date.now(), - mode: isAuthEnabled() ? 'auth' : 'noauth', - }; - await fs.writeFile(STATE_PATH, JSON.stringify(state, null, 2)); -} - -async function hasAudiobookFilesystemContent(mode: DbIndexState['mode']): Promise { - const audiobookDir = mode === 'auth' ? getUnclaimedAudiobookDir() : AUDIOBOOKS_V1_DIR; - try { - const entries = await fs.readdir(audiobookDir, { withFileTypes: true }); - return entries.some((entry) => entry.isDirectory() && entry.name.endsWith('-audiobook')); - } catch { - return false; - } -} - -async function isAudiobookIndexedForUser(id: string, userId: string): Promise { - const result = await db - .select({ id: audiobooks.id }) - .from(audiobooks) - .where(and(eq(audiobooks.id, id), eq(audiobooks.userId, userId))); - return result.length > 0; -} - -async function migrateLegacyAudiobooksToUnclaimed(): Promise { - if (!existsSync(AUDIOBOOKS_V1_DIR)) return 0; - - const unclaimedDir = getUnclaimedAudiobookDir(); - await fs.mkdir(unclaimedDir, { recursive: true }); - - const entries = await fs.readdir(AUDIOBOOKS_V1_DIR, { withFileTypes: true }); - let migrated = 0; - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (!entry.name.endsWith('-audiobook')) continue; - - const sourceDir = path.join(AUDIOBOOKS_V1_DIR, entry.name); - const targetDir = path.join(unclaimedDir, entry.name); - if (existsSync(targetDir)) continue; - - try { - await fs.rename(sourceDir, targetDir); - migrated++; - console.log(`Migrated legacy audiobook to unclaimed: ${entry.name}`); - } catch (err) { - console.error(`Error migrating legacy audiobook ${entry.name}:`, err); - } - } - - return migrated; -} - -export async function getUnclaimedCounts(): Promise<{ documents: number; audiobooks: number }> { - const [docCount] = await db.select({ count: count() }).from(documents).where(eq(documents.userId, UNCLAIMED_USER_ID)); - const [bookCount] = await db.select({ count: count() }).from(audiobooks).where(eq(audiobooks.userId, UNCLAIMED_USER_ID)); - - return { - documents: Number(docCount?.count ?? 0), - audiobooks: Number(bookCount?.count ?? 0), - }; -} - -async function scanAndPopulateAudiobookDb(): Promise { - const authEnabled = isAuthEnabled(); - console.log('Scanning file system for un-indexed audiobooks...'); - - if (authEnabled) { - await migrateLegacyAudiobooksToUnclaimed(); - } - - const audiobookScanDir = authEnabled ? getUnclaimedAudiobookDir() : AUDIOBOOKS_V1_DIR; - if (!existsSync(audiobookScanDir)) return; - - const entries = await fs.readdir(audiobookScanDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (!entry.name.endsWith('-audiobook')) continue; - - const bookId = entry.name.replace('-audiobook', ''); - if (await isAudiobookIndexedForUser(bookId, UNCLAIMED_USER_ID)) continue; - - const dirPath = path.join(audiobookScanDir, entry.name); - - let title = 'Unknown Title'; - try { - const metaPath = path.join(dirPath, 'audiobook.meta.json'); - const metaContent = await fs.readFile(metaPath, 'utf8'); - JSON.parse(metaContent); - } catch { - // ignore - } - - const chapters = await listStoredChapters(dirPath); - const totalDuration = chapters.reduce((acc, chapter) => acc + (chapter.durationSec || 0), 0); - if (chapters.length > 0) title = chapters[0].title || title; - - await db.insert(audiobooks).values({ - id: bookId, - userId: UNCLAIMED_USER_ID, - title, - duration: totalDuration, - }); - console.log(`Indexed audiobook: ${bookId}`); - - for (const chapter of chapters) { - await db.insert(audiobookChapters).values({ - id: `${bookId}-${chapter.index}`, - bookId, - userId: UNCLAIMED_USER_ID, - chapterIndex: chapter.index, - title: chapter.title, - duration: chapter.durationSec || 0, - filePath: chapter.filePath, - format: chapter.format, - }); - } - } -} - -export async function ensureAudiobooksIndexed(): Promise { - const mode: DbIndexState['mode'] = isAuthEnabled() ? 'auth' : 'noauth'; - if (memoryIndexedMode === mode) return; - - inflight ??= (async () => { - const hasState = existsSync(STATE_PATH) ? await readState() : null; - if (hasState && hasState.mode === mode) { - const [counts, fsHasAudiobooks] = await Promise.all([ - getUnclaimedCounts(), - hasAudiobookFilesystemContent(mode), - ]); - const audiobooksOk = counts.audiobooks > 0 || !fsHasAudiobooks; - if (audiobooksOk) { - memoryIndexedMode = mode; - return; - } - } - - await fs.mkdir(DOCSTORE_DIR, { recursive: true }); - await scanAndPopulateAudiobookDb(); - await writeState(); - memoryIndexedMode = mode; - })().finally(() => { - inflight = null; - }); - - await inflight; -} - -export async function ensureDbIndexed(): Promise { - await ensureAudiobooksIndexed(); -} diff --git a/src/lib/server/docstore.ts b/src/lib/server/docstore.ts index 79e76a7..3ed1d5a 100644 --- a/src/lib/server/docstore.ts +++ b/src/lib/server/docstore.ts @@ -1,210 +1,11 @@ import { createHash } from 'crypto'; -import { spawn } from 'child_process'; -import { existsSync } from 'fs'; -import { mkdir, readdir, readFile, rename, rm, stat, unlink, utimes, writeFile } from 'fs/promises'; import path from 'path'; -import { decodeChapterTitleTag, encodeChapterFileName, encodeChapterTitleTag, ffprobeAudio } from '@/lib/server/audiobook'; export const DOCSTORE_DIR = path.join(process.cwd(), 'docstore'); export const DOCUMENTS_V1_DIR = path.join(DOCSTORE_DIR, 'documents_v1'); export const AUDIOBOOKS_V1_DIR = path.join(DOCSTORE_DIR, 'audiobooks_v1'); -export const AUDIOBOOKS_USERS_DIR = path.join(DOCSTORE_DIR, 'audiobooks_users_v1'); export const UNCLAIMED_USER_ID = 'unclaimed'; -const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; - -/** - * Get the audiobook directory for a specific user when auth is enabled. - * Returns path like: docstore/audiobooks_users_v1/{userId} - */ -export function getUserAudiobookDir(userId: string): string { - // Sanitize userId to prevent path traversal - const safeUserId = userId.replace(/[^a-zA-Z0-9._-]/g, ''); - if (!safeUserId || safeUserId === '.' || safeUserId === '..' || safeUserId.includes('..')) { - throw new Error('Invalid userId for audiobook directory'); - } - return path.join(AUDIOBOOKS_USERS_DIR, safeUserId); -} - -/** - * Get the unclaimed audiobooks directory for pre-auth content. - * Returns path like: docstore/audiobooks_users_v1/unclaimed - */ -export function getUnclaimedAudiobookDir(): string { - return path.join(AUDIOBOOKS_USERS_DIR, UNCLAIMED_USER_ID); -} - -/** - * Resolve the base audiobooks directory for request context, including optional test namespace. - * - When auth is disabled or userId is absent: docstore/audiobooks_v1[/] - * - When auth is enabled: docstore/audiobooks_users_v1/{userId}[/] - */ -export function getAudiobooksRootDir({ - userId, - authEnabled, - namespace, -}: { - userId: string | null; - authEnabled: boolean; - namespace: string | null; -}): string { - const baseDir = !authEnabled || !userId ? AUDIOBOOKS_V1_DIR : getUserAudiobookDir(userId); - if (!namespace) return baseDir; - - const safe = namespace.trim(); - if (!safe || safe === '.' || safe === '..' || safe.includes('..')) return baseDir; - if (!SAFE_NAMESPACE_REGEX.test(safe)) return baseDir; - - const resolved = path.resolve(baseDir, safe); - if (!resolved.startsWith(path.resolve(baseDir) + path.sep)) return baseDir; - return resolved; -} - -/** - * Move an audiobook folder from one user's directory to another. - * Used for claiming unclaimed audiobooks or transferring on account linking. - * @returns true if moved successfully, false if source doesn't exist - */ -export async function moveAudiobookToUser( - bookId: string, - fromUserId: string, - toUserId: string -): Promise { - const sourceDir = path.join(getUserAudiobookDir(fromUserId), `${bookId}-audiobook`); - const targetUserDir = getUserAudiobookDir(toUserId); - const targetDir = path.join(targetUserDir, `${bookId}-audiobook`); - - if (!existsSync(sourceDir)) { - return false; - } - - // Ensure target user directory exists - await mkdir(targetUserDir, { recursive: true }); - - // If target already exists, we need to merge or skip - if (existsSync(targetDir)) { - // Target exists - merge contents (move files that don't exist in target) - const result = await mergeDirectoryContents(sourceDir, targetDir); - // Try to remove source if empty - try { - const remaining = await readdir(sourceDir); - if (remaining.length === 0) { - await rm(sourceDir, { recursive: true, force: true }); - } - } catch { /* ignore */ } - return result.moved > 0 || result.skipped > 0; - } - - // Simple rename/move - await rename(sourceDir, targetDir); - return true; -} - -/** - * List all audiobook IDs in a user's directory. - */ -export async function listUserAudiobookIds(userId: string): Promise { - const userDir = getUserAudiobookDir(userId); - if (!existsSync(userDir)) return []; - - const entries = await readdir(userDir, { withFileTypes: true }); - const bookIds: string[] = []; - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (!entry.name.endsWith('-audiobook')) continue; - bookIds.push(entry.name.replace('-audiobook', '')); - } - - return bookIds; -} - -const MIGRATIONS_DIR = path.join(DOCSTORE_DIR, '.migrations'); -const MIGRATIONS_STATE_PATH = path.join(MIGRATIONS_DIR, 'state.json'); - -type MigrationState = { - documentsV1Migrated?: boolean; - audiobooksV1Migrated?: boolean; - updatedAt?: number; -}; - -type LegacyDocumentMetadata = { - id: string; - name: string; - size: number; - lastModified: number; - type: string; -}; - -function isLegacyDocumentMetadata(value: unknown): value is LegacyDocumentMetadata { - if (!value || typeof value !== 'object') return false; - const v = value as Record; - return ( - typeof v.id === 'string' && - typeof v.name === 'string' && - typeof v.size === 'number' && - typeof v.lastModified === 'number' && - typeof v.type === 'string' - ); -} - -async function loadMigrationState(): Promise { - try { - return JSON.parse(await readFile(MIGRATIONS_STATE_PATH, 'utf8')) as MigrationState; - } catch { - return {}; - } -} - -async function saveMigrationState(update: Partial): Promise { - const state = await loadMigrationState(); - const next: MigrationState = { - documentsV1Migrated: state.documentsV1Migrated, - audiobooksV1Migrated: state.audiobooksV1Migrated, - ...update, - updatedAt: Date.now(), - }; - await mkdir(MIGRATIONS_DIR, { recursive: true }); - await writeFile(MIGRATIONS_STATE_PATH, JSON.stringify(next, null, 2)); -} - -async function hasLegacyDocumentFiles(): Promise { - let entries: Array = []; - try { - entries = await readdir(DOCSTORE_DIR, { withFileTypes: true }); - } catch { - return false; - } - - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!entry.name.endsWith('.json')) continue; - - const metadataPath = path.join(DOCSTORE_DIR, entry.name); - let parsed: unknown; - try { - parsed = JSON.parse(await readFile(metadataPath, 'utf8')); - } catch { - continue; - } - if (!isLegacyDocumentMetadata(parsed)) continue; - - const contentPath = path.join(DOCSTORE_DIR, `${parsed.id}.${parsed.type}`); - if (!existsSync(contentPath)) continue; - - return true; - } - - return false; -} - -export async function isDocumentsV1Ready(): Promise { - if (!existsSync(DOCSTORE_DIR) || !existsSync(DOCUMENTS_V1_DIR)) return false; - const state = await loadMigrationState(); - if (!state.documentsV1Migrated) return false; - if (await hasLegacyDocumentFiles()) return false; - return true; -} function safeDocumentName(rawName: string, fallback: string): string { const baseName = path.basename(rawName || fallback); @@ -212,456 +13,15 @@ function safeDocumentName(rawName: string, fallback: string): string { } export function getMigratedDocumentFileName(id: string, name: string): string { + const normalizedName = safeDocumentName(name, `${id}.bin`); const prefix = `${id}__`; - const encodedName = encodeURIComponent(name); + const encodedName = encodeURIComponent(normalizedName); let targetFileName = `${prefix}${encodedName}`; - // Ensure total filename length is within safe limits (e.g. 240 chars). - // If too long, use a deterministic hash of the name instead of the full encoded name. + // Keep migrated document filenames under conservative filesystem length limits. if (targetFileName.length > 240) { - const nameHash = createHash('sha256').update(name).digest('hex').slice(0, 32); + const nameHash = createHash('sha256').update(normalizedName).digest('hex').slice(0, 32); targetFileName = `${prefix}truncated-${nameHash}`; } return targetFileName; } - -export async function ensureDocumentsV1Ready(): Promise { - await mkdir(DOCSTORE_DIR, { recursive: true }); - await mkdir(DOCUMENTS_V1_DIR, { recursive: true }); - - const state = await loadMigrationState(); - if (state.documentsV1Migrated && !(await hasLegacyDocumentFiles())) { - return false; - } - - if (!(await hasLegacyDocumentFiles())) { - await saveMigrationState({ documentsV1Migrated: true }); - return false; - } - - let entries: Array = []; - try { - entries = await readdir(DOCSTORE_DIR, { withFileTypes: true }); - } catch { - entries = []; - } - - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!entry.name.endsWith('.json')) continue; - - const metadataPath = path.join(DOCSTORE_DIR, entry.name); - let parsed: unknown; - try { - parsed = JSON.parse(await readFile(metadataPath, 'utf8')); - } catch { - continue; - } - if (!isLegacyDocumentMetadata(parsed)) continue; - const metadata = parsed; - - const contentPath = path.join(DOCSTORE_DIR, `${metadata.id}.${metadata.type}`); - let contentStat: Awaited>; - try { - contentStat = await stat(contentPath); - } catch { - continue; - } - if (!contentStat.isFile()) continue; - - const content = await readFile(contentPath); - const id = createHash('sha256').update(content).digest('hex'); - const fallbackName = `${id}.${metadata.type}`; - const name = safeDocumentName(metadata.name, fallbackName); - - const targetFileName = getMigratedDocumentFileName(id, name); - const targetPath = path.join(DOCUMENTS_V1_DIR, targetFileName); - - if (!existsSync(targetPath)) { - await writeFile(targetPath, content); - if (Number.isFinite(metadata.lastModified) && metadata.lastModified > 0) { - const stamp = new Date(metadata.lastModified); - await utimes(targetPath, stamp, stamp).catch(() => { }); - } - } - - await unlink(metadataPath).catch(() => { }); - await unlink(contentPath).catch(() => { }); - } - - await saveMigrationState({ documentsV1Migrated: !(await hasLegacyDocumentFiles()) }); - return true; -} - -async function hasLegacyAudiobookDirs(): Promise { - let entries: Array = []; - try { - entries = await readdir(DOCSTORE_DIR, { withFileTypes: true }); - } catch { - return false; - } - - return entries.some((entry) => entry.isDirectory() && entry.name.endsWith('-audiobook')); -} - -async function hasLegacyAudiobookChapterLayout(): Promise { - let entries: Array = []; - try { - entries = await readdir(AUDIOBOOKS_V1_DIR, { withFileTypes: true }); - } catch { - return false; - } - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (!entry.name.endsWith('-audiobook')) continue; - - const dir = path.join(AUDIOBOOKS_V1_DIR, entry.name); - let files: string[] = []; - try { - files = await readdir(dir); - } catch { - continue; - } - - for (const file of files) { - // Per-audiobook settings file is the new format; ignore it. - if (file === 'audiobook.meta.json') continue; - - if (file.endsWith('.meta.json')) return true; - if (/^\d+-chapter\.(mp3|m4b)$/i.test(file)) return true; - if (/^[a-f0-9]{64}\.(mp3|m4b)$/i.test(file)) return true; - } - } - - return false; -} - -export async function isAudiobooksV1Ready(): Promise { - if (!existsSync(DOCSTORE_DIR) || !existsSync(AUDIOBOOKS_V1_DIR)) return false; - const state = await loadMigrationState(); - if (!state.audiobooksV1Migrated) return false; - const legacyDirsPresent = await hasLegacyAudiobookDirs(); - const legacyChaptersPresent = await hasLegacyAudiobookChapterLayout(); - if (legacyDirsPresent || legacyChaptersPresent) return false; - return true; -} - -async function mergeDirectoryContents(sourceDir: string, targetDir: string): Promise<{ moved: number; skipped: number }> { - let moved = 0; - let skipped = 0; - - let entries: Array = []; - try { - entries = await readdir(sourceDir, { withFileTypes: true }); - } catch { - return { moved, skipped }; - } - - for (const entry of entries) { - const sourcePath = path.join(sourceDir, entry.name); - const targetPath = path.join(targetDir, entry.name); - - if (entry.isDirectory()) { - await mkdir(targetPath, { recursive: true }); - const nested = await mergeDirectoryContents(sourcePath, targetPath); - moved += nested.moved; - skipped += nested.skipped; - - try { - const remaining = await readdir(sourcePath); - if (remaining.length === 0) { - await rm(sourcePath); - } - } catch { } - continue; - } - - if (!entry.isFile()) continue; - - if (existsSync(targetPath)) { - skipped++; - continue; - } - - try { - await rename(sourcePath, targetPath); - moved++; - } catch { - skipped++; - } - } - - return { moved, skipped }; -} - -export async function ensureAudiobooksV1Ready(): Promise { - await mkdir(DOCSTORE_DIR, { recursive: true }); - await mkdir(AUDIOBOOKS_V1_DIR, { recursive: true }); - // Also ensure the user-specific audiobooks directory exists for auth-enabled scenarios - await mkdir(AUDIOBOOKS_USERS_DIR, { recursive: true }); - - const state = await loadMigrationState(); - const legacyDirsPresent = await hasLegacyAudiobookDirs(); - const legacyChaptersPresent = await hasLegacyAudiobookChapterLayout(); - - if (state.audiobooksV1Migrated && !legacyDirsPresent && !legacyChaptersPresent) { - const stateRaw = state as unknown as Record; - const allowedKeys = new Set(['documentsV1Migrated', 'audiobooksV1Migrated', 'updatedAt']); - const hasExtraKeys = Object.keys(stateRaw).some((key) => !allowedKeys.has(key)); - if (hasExtraKeys) { - await saveMigrationState({ audiobooksV1Migrated: true }); - } - return false; - } - - let entries: Array = []; - try { - entries = await readdir(DOCSTORE_DIR, { withFileTypes: true }); - } catch { - entries = []; - } - - if (legacyDirsPresent) { - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (!entry.name.endsWith('-audiobook')) continue; - - const sourceDir = path.join(DOCSTORE_DIR, entry.name); - const targetDir = path.join(AUDIOBOOKS_V1_DIR, entry.name); - - try { - if (!existsSync(targetDir)) { - await rename(sourceDir, targetDir); - continue; - } - - await mkdir(targetDir, { recursive: true }); - await mergeDirectoryContents(sourceDir, targetDir); - - try { - const remaining = await readdir(sourceDir); - if (remaining.length === 0) { - await rm(sourceDir); - } else { - console.warn(`Legacy audiobook dir not fully migrated (kept): ${sourceDir}`); - } - } catch { } - } catch (error) { - console.error('Error migrating legacy audiobook directory:', error); - throw error; - } - } - } - - if (legacyDirsPresent || legacyChaptersPresent) { - await normalizeAudiobookChapterLayout(); - } - - const finalLegacyRemaining = await hasLegacyAudiobookDirs(); - const finalLegacyChaptersRemaining = await hasLegacyAudiobookChapterLayout(); - await saveMigrationState({ audiobooksV1Migrated: !finalLegacyRemaining && !finalLegacyChaptersRemaining }); - return true; -} - -type LegacyChapterMeta = { - title?: string; - duration?: number; - index?: number; - format?: string; -}; - -async function runProcess(command: string, args: string[]): Promise { - return new Promise((resolve, reject) => { - const child = spawn(command, args); - let stderr = ''; - child.stderr.on('data', (data) => { - stderr += data.toString(); - }); - child.on('close', (code) => { - if (code === 0) resolve(); - else reject(new Error(`${command} exited with code ${code}: ${stderr}`)); - }); - child.on('error', (err) => reject(err)); - }); -} - -async function rewriteAudioTitleTag(inputPath: string, outputPath: string, format: 'mp3' | 'm4b', titleTag: string): Promise { - const baseArgs = ['-y', '-i', inputPath, '-metadata', `title=${titleTag}`]; - if (format === 'mp3') { - await runProcess('ffmpeg', [...baseArgs, '-c', 'copy', '-write_id3v2', '1', '-id3v2_version', '3', outputPath]); - return; - } - await runProcess('ffmpeg', [...baseArgs, '-c', 'copy', '-f', 'mp4', outputPath]); -} - -async function transcodeWithTitleTag(inputPath: string, outputPath: string, format: 'mp3' | 'm4b', titleTag: string): Promise { - if (format === 'mp3') { - await runProcess('ffmpeg', [ - '-y', - '-i', - inputPath, - '-c:a', - 'libmp3lame', - '-b:a', - '64k', - '-metadata', - `title=${titleTag}`, - outputPath, - ]); - return; - } - - await runProcess('ffmpeg', [ - '-y', - '-i', - inputPath, - '-c:a', - 'aac', - '-b:a', - '64k', - '-metadata', - `title=${titleTag}`, - '-f', - 'mp4', - outputPath, - ]); -} - -async function normalizeAudiobookDirectoryChapterLayout(intermediateDir: string): Promise { - let files: string[] = []; - try { - files = await readdir(intermediateDir); - } catch { - return; - } - - // Remove any combined output files from older layouts. - await unlink(path.join(intermediateDir, 'complete.mp3')).catch(() => { }); - await unlink(path.join(intermediateDir, 'complete.m4b')).catch(() => { }); - await unlink(path.join(intermediateDir, 'metadata.txt')).catch(() => { }); - await unlink(path.join(intermediateDir, 'list.txt')).catch(() => { }); - - const metaFiles = files.filter((file) => file.endsWith('.meta.json')); - const migratedIndices = new Set(); - - for (const metaFile of metaFiles) { - const metaPath = path.join(intermediateDir, metaFile); - let metaRaw: unknown; - try { - metaRaw = JSON.parse(await readFile(metaPath, 'utf8')); - } catch { - continue; - } - const meta = metaRaw as LegacyChapterMeta; - const index = Number(meta.index); - if (!Number.isFinite(index) || !Number.isInteger(index) || index < 0) continue; - - const format = meta.format === 'mp3' ? 'mp3' : 'm4b'; - const sourceAudio = path.join(intermediateDir, `${index}-chapter.${format}`); - if (!existsSync(sourceAudio)) { - await unlink(metaPath).catch(() => { }); - continue; - } - - const titleTag = encodeChapterTitleTag(index, meta.title ?? `Chapter ${index + 1}`); - const taggedTemp = path.join(intermediateDir, `${index}.tagged.tmp.${format}`); - - try { - await rewriteAudioTitleTag(sourceAudio, taggedTemp, format, titleTag); - } catch { - await transcodeWithTitleTag(sourceAudio, taggedTemp, format, titleTag); - } - - const finalName = encodeChapterFileName(index, meta.title ?? `Chapter ${index + 1}`, format); - const finalPath = path.join(intermediateDir, finalName); - await unlink(finalPath).catch(() => { }); - await rename(taggedTemp, finalPath); - - await unlink(sourceAudio).catch(() => { }); - await unlink(metaPath).catch(() => { }); - migratedIndices.add(index); - } - - // Migrate any remaining legacy chapter files without metadata. - files = await readdir(intermediateDir).catch(() => []); - for (const file of files) { - const match = /^(\d+)-chapter\.(mp3|m4b)$/i.exec(file); - if (!match) continue; - const index = Number(match[1]); - if (!Number.isInteger(index) || index < 0) continue; - if (migratedIndices.has(index)) continue; - - const format = match[2].toLowerCase() as 'mp3' | 'm4b'; - const sourceAudio = path.join(intermediateDir, file); - const titleTag = encodeChapterTitleTag(index, `Chapter ${index + 1}`); - const taggedTemp = path.join(intermediateDir, `${index}.tagged.tmp.${format}`); - - try { - await rewriteAudioTitleTag(sourceAudio, taggedTemp, format, titleTag); - } catch { - await transcodeWithTitleTag(sourceAudio, taggedTemp, format, titleTag); - } - - const finalName = encodeChapterFileName(index, `Chapter ${index + 1}`, format); - const finalPath = path.join(intermediateDir, finalName); - await unlink(finalPath).catch(() => { }); - await rename(taggedTemp, finalPath); - - await unlink(sourceAudio).catch(() => { }); - } - - // Rename any sha-named chapter files from previous runs into the index__title scheme. - files = await readdir(intermediateDir).catch(() => []); - const shaCandidates = files.filter((file) => /^[a-f0-9]{64}\.(mp3|m4b)$/i.test(file)); - for (const file of shaCandidates) { - const sourceAudio = path.join(intermediateDir, file); - const format = file.toLowerCase().endsWith('.mp3') ? 'mp3' : 'm4b'; - - let decoded: { index: number; title: string } | null = null; - try { - const probe = await ffprobeAudio(sourceAudio); - decoded = probe.titleTag ? decodeChapterTitleTag(probe.titleTag) : null; - } catch { - decoded = null; - } - if (!decoded) continue; - - const finalName = encodeChapterFileName(decoded.index, decoded.title, format); - const finalPath = path.join(intermediateDir, finalName); - await unlink(finalPath).catch(() => { }); - await rename(sourceAudio, finalPath).catch(() => { }); - } - - // Remove any leftover input temp files. - files = await readdir(intermediateDir).catch(() => []); - for (const file of files) { - if (/^\d+-input\.mp3$/i.test(file)) { - await unlink(path.join(intermediateDir, file)).catch(() => { }); - } - if (file.endsWith('.meta.json') && file !== 'audiobook.meta.json') { - await unlink(path.join(intermediateDir, file)).catch(() => { }); - } - } -} - -async function normalizeAudiobookChapterLayout(): Promise { - let entries: Array = []; - try { - entries = await readdir(AUDIOBOOKS_V1_DIR, { withFileTypes: true }); - } catch { - return; - } - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (!entry.name.endsWith('-audiobook')) continue; - const dir = path.join(AUDIOBOOKS_V1_DIR, entry.name); - try { - await normalizeAudiobookDirectoryChapterLayout(dir); - } catch (error) { - console.error('Error migrating audiobook chapter layout:', error); - throw error; - } - } -} diff --git a/src/lib/server/ffmpeg-bin.ts b/src/lib/server/ffmpeg-bin.ts new file mode 100644 index 0000000..dabd7ab --- /dev/null +++ b/src/lib/server/ffmpeg-bin.ts @@ -0,0 +1,47 @@ +import { existsSync } from 'fs'; +import ffmpegStatic from 'ffmpeg-static'; +import ffprobeStatic from 'ffprobe-static'; + +function normalizePath(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function resolveBinary(envValue: string | null, bundledValue: string | null, envVarName: string, packageName: string): string { + if (envValue) { + if ((envValue.includes('/') || envValue.includes('\\')) && !existsSync(envValue)) { + throw new Error(`${envVarName} points to a missing binary: ${envValue}`); + } + return envValue; + } + + if (!bundledValue) { + throw new Error( + `${packageName} binary is unavailable on this platform. Set ${envVarName} to an installed binary path.`, + ); + } + + if ((bundledValue.includes('/') || bundledValue.includes('\\')) && !existsSync(bundledValue)) { + throw new Error(`${packageName} resolved to a missing binary path: ${bundledValue}`); + } + return bundledValue; +} + +export function getFFmpegPath(): string { + return resolveBinary( + normalizePath(process.env.FFMPEG_BIN), + normalizePath(ffmpegStatic), + 'FFMPEG_BIN', + 'ffmpeg-static', + ); +} + +export function getFFprobePath(): string { + return resolveBinary( + normalizePath(process.env.FFPROBE_BIN), + normalizePath(ffprobeStatic?.path), + 'FFPROBE_BIN', + 'ffprobe-static', + ); +} diff --git a/tests/global-teardown.ts b/tests/global-teardown.ts index fb2bea2..81b7319 100644 --- a/tests/global-teardown.ts +++ b/tests/global-teardown.ts @@ -1,11 +1,106 @@ +import { ListObjectsV2Command } from '@aws-sdk/client-s3'; +import { and, eq, inArray, like } from 'drizzle-orm'; +import { db } from '../src/db'; +import { audiobooks, audiobookChapters, documents } from '../src/db/schema'; import { deleteDocumentPrefix } from '../src/lib/server/documents-blobstore'; -import { getS3Config, isS3Configured } from '../src/lib/server/s3'; +import { deleteAudiobookPrefix } from '../src/lib/server/audiobooks-blobstore'; +import { getS3Client, getS3Config, isS3Configured } from '../src/lib/server/s3'; + +function chunk(items: T[], size: number): T[][] { + if (items.length === 0) return []; + const groups: T[][] = []; + for (let i = 0; i < items.length; i += size) { + groups.push(items.slice(i, i + size)); + } + return groups; +} + +async function listKeysByPrefix(prefix: string): Promise { + const config = getS3Config(); + const client = getS3Client(); + let continuationToken: string | undefined; + const keys: string[] = []; + + do { + const response = await client.send( + new ListObjectsV2Command({ + Bucket: config.bucket, + Prefix: prefix, + ContinuationToken: continuationToken, + }), + ); + + for (const entry of response.Contents ?? []) { + if (entry.Key) keys.push(entry.Key); + } + continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined; + } while (continuationToken); + + return keys; +} + +function parseAudiobookScopeFromKey( + key: string, + audiobooksNsRootPrefix: string, +): { userId: string; bookId: string } | null { + if (!key.startsWith(audiobooksNsRootPrefix)) return null; + const rel = key.slice(audiobooksNsRootPrefix.length); + const parts = rel.split('/'); + // ns//users//-audiobook/ + if (parts.length < 5) return null; + if (parts[1] !== 'users') return null; + const encodedUserId = parts[2]; + const dirName = parts[3]; + if (!encodedUserId || !dirName.endsWith('-audiobook')) return null; + const bookId = dirName.slice(0, -'-audiobook'.length); + if (!bookId) return null; + + let userId: string; + try { + userId = decodeURIComponent(encodedUserId); + } catch { + return null; + } + return { userId, bookId }; +} export default async function globalTeardown(): Promise { + // Always clear namespaced no-auth SQL rows from prior runs. + await db.delete(audiobookChapters).where(like(audiobookChapters.userId, 'unclaimed::%')); + await db.delete(audiobooks).where(like(audiobooks.userId, 'unclaimed::%')); + await db.delete(documents).where(like(documents.userId, 'unclaimed::%')); + if (!isS3Configured()) return; const config = getS3Config(); - const nsRootPrefix = `${config.prefix}/documents_v1/ns/`; - await deleteDocumentPrefix(nsRootPrefix); -} + const docsNsRootPrefix = `${config.prefix}/documents_v1/ns/`; + const audiobooksNsRootPrefix = `${config.prefix}/audiobooks_v1/ns/`; + // Remove SQL audiobook rows for namespaced objects (covers auth claim flows too). + const audiobookKeys = await listKeysByPrefix(audiobooksNsRootPrefix); + const byUser = new Map>(); + for (const key of audiobookKeys) { + const scope = parseAudiobookScopeFromKey(key, audiobooksNsRootPrefix); + if (!scope) continue; + let set = byUser.get(scope.userId); + if (!set) { + set = new Set(); + byUser.set(scope.userId, set); + } + set.add(scope.bookId); + } + + for (const [userId, bookIds] of byUser) { + for (const ids of chunk(Array.from(bookIds), 200)) { + await db + .delete(audiobookChapters) + .where(and(eq(audiobookChapters.userId, userId), inArray(audiobookChapters.bookId, ids))); + await db + .delete(audiobooks) + .where(and(eq(audiobooks.userId, userId), inArray(audiobooks.id, ids))); + } + } + + await deleteDocumentPrefix(docsNsRootPrefix); + await deleteAudiobookPrefix(audiobooksNsRootPrefix); +} diff --git a/tests/unit/audiobooks-blobstore.spec.ts b/tests/unit/audiobooks-blobstore.spec.ts new file mode 100644 index 0000000..a64087e --- /dev/null +++ b/tests/unit/audiobooks-blobstore.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from '@playwright/test'; +import { + audiobookKey, + audiobookPrefix, + isMissingBlobError, + isPreconditionFailed, +} from '../../src/lib/server/audiobooks-blobstore'; + +function configureS3Env() { + process.env.S3_BUCKET = process.env.S3_BUCKET || 'test-bucket'; + process.env.S3_REGION = process.env.S3_REGION || 'us-east-1'; + process.env.S3_ACCESS_KEY_ID = process.env.S3_ACCESS_KEY_ID || 'test-access'; + process.env.S3_SECRET_ACCESS_KEY = process.env.S3_SECRET_ACCESS_KEY || 'test-secret'; + process.env.S3_PREFIX = 'openreader-test'; +} + +test.describe('audiobooks-blobstore', () => { + test.beforeAll(() => { + configureS3Env(); + }); + + test('builds audiobook prefix with namespace', () => { + const prefix = audiobookPrefix('book-123', 'user-abc', 'worker1'); + expect(prefix).toBe('openreader-test/audiobooks_v1/ns/worker1/users/user-abc/book-123-audiobook/'); + }); + + test('builds audiobook prefix without namespace', () => { + const prefix = audiobookPrefix('book-123', 'unclaimed', null); + expect(prefix).toBe('openreader-test/audiobooks_v1/users/unclaimed/book-123-audiobook/'); + }); + + test('builds key for chapter file', () => { + const key = audiobookKey('book-123', 'user-abc', '0001__Chapter%201.mp3', null); + expect(key).toBe('openreader-test/audiobooks_v1/users/user-abc/book-123-audiobook/0001__Chapter%201.mp3'); + }); + + test('rejects invalid file names in keys', () => { + expect(() => audiobookKey('book-123', 'user-abc', '../bad.mp3', null)).toThrow(/Invalid audiobook file name/); + }); + + test('detects missing blob errors', () => { + expect(isMissingBlobError({ name: 'NoSuchKey' })).toBeTruthy(); + expect(isMissingBlobError({ $metadata: { httpStatusCode: 404 } })).toBeTruthy(); + expect(isMissingBlobError({ name: 'AccessDenied' })).toBeFalsy(); + }); + + test('detects precondition failed errors', () => { + expect(isPreconditionFailed({ name: 'PreconditionFailed' })).toBeTruthy(); + expect(isPreconditionFailed({ $metadata: { httpStatusCode: 412 } })).toBeTruthy(); + expect(isPreconditionFailed({ $metadata: { httpStatusCode: 409 } })).toBeFalsy(); + }); +}); diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..553c3cf --- /dev/null +++ b/vercel.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "functions": { + "app/api/audiobook/route.ts": { + "memory": 3009 + }, + "app/api/whisper/route.ts": { + "memory": 3009 + } + } +} From 4e2d18962d674725978520fb85f5ededb5ce889c Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 11 Feb 2026 03:20:22 -0700 Subject: [PATCH 20/55] fix(config): update ffmpeg tracing for audiobook APIs Configure Next.js output tracing to properly include ffmpeg and ffprobe static binaries for audiobook processing endpoints. This ensures the required binaries are bundled correctly during deployment across all audiobook API routes. --- next.config.ts | 21 +++++++++++++-------- pnpm-lock.yaml | 12 ++++++------ 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/next.config.ts b/next.config.ts index c10b3b8..42eb5d2 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,17 +6,22 @@ const nextConfig: NextConfig = { canvas: './empty-module.ts', }, }, - serverExternalPackages: ["better-sqlite3"], + serverExternalPackages: ["better-sqlite3", "ffmpeg-static", "ffprobe-static"], outputFileTracingIncludes: { - '/api/audiobook(.*)': [ - 'node_modules/ffmpeg-static/ffmpeg', - 'node_modules/ffprobe-static/bin/linux/*/ffprobe', - 'node_modules/.pnpm/ffmpeg-static@*/node_modules/ffmpeg-static/ffmpeg', - 'node_modules/.pnpm/ffprobe-static@*/node_modules/ffprobe-static/bin/linux/*/ffprobe', + '/api/audiobook': [ + './node_modules/ffmpeg-static/ffmpeg', + './node_modules/ffprobe-static/bin/*/*/ffprobe', + ], + '/api/audiobook/chapter': [ + './node_modules/ffmpeg-static/ffmpeg', + './node_modules/ffprobe-static/bin/*/*/ffprobe', + ], + '/api/audiobook/status': [ + './node_modules/ffmpeg-static/ffmpeg', + './node_modules/ffprobe-static/bin/*/*/ffprobe', ], '/api/whisper': [ - 'node_modules/ffmpeg-static/ffmpeg', - 'node_modules/.pnpm/ffmpeg-static@*/node_modules/ffmpeg-static/ffmpeg', + './node_modules/ffmpeg-static/ffmpeg', ], }, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e69f8f..972419d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,12 +22,6 @@ importers: '@headlessui/react': specifier: ^2.2.9 version: 2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@types/howler': - specifier: ^2.2.12 - version: 2.2.12 - '@types/uuid': - specifier: ^10.0.0 - version: 10.0.0 '@vercel/analytics': specifier: ^1.6.1 version: 1.6.1(next@15.5.12(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) @@ -137,6 +131,9 @@ importers: '@types/ffprobe-static': specifier: ^2.0.3 version: 2.0.3 + '@types/howler': + specifier: ^2.2.12 + version: 2.2.12 '@types/node': specifier: ^20.19.33 version: 20.19.33 @@ -149,6 +146,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.13) + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 eslint: specifier: ^9.39.2 version: 9.39.2(jiti@1.21.7) From 63483e055e78970b29e5eb28acfc33779e66f436 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 11 Feb 2026 04:16:02 -0700 Subject: [PATCH 21/55] refactor(docs): reorganize and enhance documentation structure - Rename configuration.md to auth.md for clarity - Rename storage-and-blob-behavior.md to object-blob-storage.md - Split server library import into dedicated documentation page - Rename intro.md to introduction.md - Merge support.md into support-and-contributing.md - Add interactive tabs to configuration and setup guides - Improve cross-references and navigation between documentation pages - Update sidebar structure and Docusaurus config for new file paths --- .../configure/{configuration.md => auth.md} | 2 +- .../docs/configure/database-and-migrations.md | 24 +++++- ...lob-behavior.md => object-blob-storage.md} | 27 ++++--- .../docs/configure/server-library-import.md | 66 +++++++++++++++ docs-site/docs/configure/tts-providers.md | 80 +++++++++++++------ docs-site/docs/configure/tts-rate-limiting.md | 2 +- docs-site/docs/intro.md | 40 ---------- docs-site/docs/introduction.md | 48 +++++++++++ ...support.md => support-and-contributing.md} | 0 .../docs/start-here/docker-quick-start.md | 58 ++++++++++---- .../docs/start-here/local-development.md | 44 +++++++--- .../docs/start-here/vercel-deployment.md | 31 +++++-- docs-site/docusaurus.config.ts | 2 +- docs-site/sidebars.ts | 11 +-- 14 files changed, 315 insertions(+), 120 deletions(-) rename docs-site/docs/configure/{configuration.md => auth.md} (88%) rename docs-site/docs/configure/{storage-and-blob-behavior.md => object-blob-storage.md} (59%) create mode 100644 docs-site/docs/configure/server-library-import.md delete mode 100644 docs-site/docs/intro.md create mode 100644 docs-site/docs/introduction.md rename docs-site/docs/project/{support.md => support-and-contributing.md} (100%) diff --git a/docs-site/docs/configure/configuration.md b/docs-site/docs/configure/auth.md similarity index 88% rename from docs-site/docs/configure/configuration.md rename to docs-site/docs/configure/auth.md index 9e84114..0e8d9df 100644 --- a/docs-site/docs/configure/configuration.md +++ b/docs-site/docs/configure/auth.md @@ -15,5 +15,5 @@ This page covers application-level configuration for provider access and authent - For the complete variable reference: [Environment Variables](../reference/environment-variables) - For TTS character limits and quota behavior: [TTS Rate Limiting](./tts-rate-limiting) - For provider-specific guidance: [TTS Providers](./tts-providers) -- For storage/S3/SeaweedFS behavior: [Object / Blob Storage](./storage-and-blob-behavior) +- For storage/S3/SeaweedFS behavior: [Object / Blob Storage](./object-blob-storage) - For database mode and migration commands: [Database and Migrations](./database-and-migrations) diff --git a/docs-site/docs/configure/database-and-migrations.md b/docs-site/docs/configure/database-and-migrations.md index 50bc434..1f89edd 100644 --- a/docs-site/docs/configure/database-and-migrations.md +++ b/docs-site/docs/configure/database-and-migrations.md @@ -2,12 +2,15 @@ title: Database and Migrations --- +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + This page covers database mode selection and migration behavior for OpenReader WebUI. ## Database mode -- Default mode: embedded SQLite at `docstore/sqlite3.db` -- External mode: Postgres when `POSTGRES_URL` is set +- SQLite (default): embedded DB at `docstore/sqlite3.db`; good for local/self-host single-instance setups. +- Postgres: enabled when `POSTGRES_URL` is set; recommended for production/distributed deployments. ## Startup migration behavior @@ -22,6 +25,10 @@ Startup migration phases: - DB schema migrations (`pnpm migrate`) - Storage/data migration (`pnpm migrate-fs`) for legacy filesystem content into S3 + DB rows +:::info +In most setups, you do not need to run migration commands manually because startup handles this automatically. +::: + To skip automatic startup migrations: - Set `RUN_DRIZZLE_MIGRATIONS=false` @@ -33,6 +40,9 @@ Database variables are documented in [Environment Variables](../reference/enviro In most cases, you do not need manual migration commands because startup runs migrations automatically. + + + ```bash # Run pending migrations (uses Postgres config when POSTGRES_URL is set, otherwise SQLite) pnpm migrate @@ -47,7 +57,8 @@ pnpm migrate-fs:dry-run pnpm generate ``` -## Manual Drizzle commands (advanced) + + ```bash # Migrate SQLite @@ -62,3 +73,10 @@ pnpm exec drizzle-kit generate --config drizzle.config.sqlite.ts # Generate Postgres migrations pnpm exec drizzle-kit generate --config drizzle.config.pg.ts ``` + + + + +:::warning +If you disable startup migrations, ensure your deployment process runs migrations before serving traffic. +::: diff --git a/docs-site/docs/configure/storage-and-blob-behavior.md b/docs-site/docs/configure/object-blob-storage.md similarity index 59% rename from docs-site/docs/configure/storage-and-blob-behavior.md rename to docs-site/docs/configure/object-blob-storage.md index f437ce9..2aad353 100644 --- a/docs-site/docs/configure/storage-and-blob-behavior.md +++ b/docs-site/docs/configure/object-blob-storage.md @@ -2,12 +2,12 @@ title: Object / Blob Storage --- -This page documents storage backends, blob upload routing, and Docker mount behavior. +This page documents storage backends, blob upload routing, and core Docker mount behavior. ## Storage backends -- Default: embedded SQLite metadata + embedded SeaweedFS (`weed mini`) blobs -- External option: Postgres + external S3-compatible object storage +- Embedded (default): SQLite metadata + embedded SeaweedFS (`weed mini`) blobs. +- External: Postgres + external S3-compatible object storage. Storage variables are documented in [Environment Variables](../reference/environment-variables) under the storage sections. @@ -16,24 +16,23 @@ Storage variables are documented in [Environment Variables](../reference/environ - `3003`: OpenReader app and API routes - `8333`: Embedded SeaweedFS S3 endpoint for direct browser blob access +:::info +`8333` is only needed for direct browser presigned access to embedded SeaweedFS. +::: + ## Upload behavior -- Primary path: browser uploads to presigned URL from `/api/documents/blob/upload/presign` -- Fallback path: `/api/documents/blob/upload/fallback` if direct upload fails or endpoint is unreachable -- Content serving path: `/api/documents/blob` +- Primary path: browser uploads to presigned URL from `/api/documents/blob/upload/presign`. +- Fallback path: `/api/documents/blob/upload/fallback` when direct upload fails/unreachable. +- Read/download path: blob/content serving route `/api/documents/blob` (not the upload fallback route). ## Recommended Docker mounts | Mount | Type | Recommended | Purpose | Example | | --- | --- | --- | --- | --- | | `/app/docstore` | Docker named volume | Yes (for persistence) | Persists SeaweedFS blob data, SQLite metadata DB, migrations, and local runtime temp state | `-v openreader_docstore:/app/docstore` | -| `/app/docstore/library` | Bind mount | Optional + `:ro` | Read-only source for server library import (files are copied/imported into client storage) | `-v /path/to/your/library:/app/docstore/library:ro` | -To import from mounted library: **Settings -> Documents -> Server Library Import**. - -:::note -Every file in the mounted library is imported into client browser storage. Keep the library reasonably sized. -::: +For server library mounts/import behavior, see [Server Library Import](./server-library-import). ## Private blob endpoint mode @@ -43,6 +42,10 @@ If `8333` is not published externally: - Reads/snippets continue through app API routes - Direct presigned browser upload/download to embedded endpoint is unavailable +:::warning +Without `8333`, expect higher app-server traffic because uploads/downloads go through API routes instead of direct object endpoint access. +::: + ## Audiobook storage note - In current versions, audiobook assets live in object storage (`audiobooks_v1` keyspace), not as durable files under `/app/docstore`. diff --git a/docs-site/docs/configure/server-library-import.md b/docs-site/docs/configure/server-library-import.md new file mode 100644 index 0000000..9301fa3 --- /dev/null +++ b/docs-site/docs/configure/server-library-import.md @@ -0,0 +1,66 @@ +--- +title: Server Library Import +--- + +This page documents how server library import works and how to configure it. + +## What it does + +Server library import lets you browse files from one or more server directories and import selected files into OpenReader. + +- Import is user-driven via a selection modal +- Only selected files are imported +- Imported files become normal OpenReader documents + +## Configure library roots + +Library roots are resolved from environment variables: + +- `IMPORT_LIBRARY_DIRS` (takes precedence): multiple roots separated by comma, colon, or semicolon +- `IMPORT_LIBRARY_DIR`: single root +- Fallback when neither is set: `docstore/library` + +See [Environment Variables](../reference/environment-variables#import_library_dir) for details. + +## Docker mount example + +Mount a host folder to the default library path: + +```bash +docker run --name openreader-webui \ + --restart unless-stopped \ + -p 3003:3003 \ + -v openreader_docstore:/app/docstore \ + -v /path/to/your/library:/app/docstore/library:ro \ + ghcr.io/richardr1126/openreader-webui:latest +``` + +Using `:ro` is recommended so the app treats the library as a read-only source. + +## Import flow + +1. Open **Settings -> Documents -> Server Library Import**. +2. Select files in the modal. +3. Click **Import**. + +Selected files are fetched from the server library endpoint and imported into OpenReader storage. + +:::warning Shared Library Roots +Library roots are configured at the server level (not per-user). Any user who can access Server Library Import can browse/import from the same configured roots. + +Imported documents are still saved to the importing user's document scope. +::: + +## Supported file types + +- `.pdf` +- `.epub` +- `.html`, `.htm` +- `.txt` +- `.md`, `.mdown`, `.markdown` + +## Notes + +- Library listing is capped per request (up to 10,000 files). +- When auth is enabled, library import endpoints require a valid session. +- The mounted library is a source; removing it does not delete already imported documents. diff --git a/docs-site/docs/configure/tts-providers.md b/docs-site/docs/configure/tts-providers.md index 8351ff2..e5b41df 100644 --- a/docs-site/docs/configure/tts-providers.md +++ b/docs-site/docs/configure/tts-providers.md @@ -2,47 +2,75 @@ title: TTS Providers --- +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + OpenReader WebUI supports OpenAI-compatible TTS providers through a common API shape. -## Supported provider patterns +:::tip +If you are running a self-hosted TTS server (Kokoro/Orpheus/etc.), use **Custom OpenAI-Like** in Settings. +::: -- OpenAI API -- DeepInfra -- Kokoro-FastAPI -- Orpheus-FastAPI -- Custom OpenAI-compatible endpoints +## Quick Setup by Provider -## Provider dropdown behavior + + -In Settings, the provider dropdown includes: +1. In Settings, choose provider: `OpenAI`. +2. Keep the default `API_BASE` (auto-filled). +3. Set `API_KEY` to your OpenAI key. +4. Choose model/voice. + + + + +1. In Settings, choose provider: `Deepinfra`. +2. Keep the default `API_BASE` (auto-filled). +3. Set `API_KEY` to your DeepInfra key. +4. Choose model/voice. + + + + +1. In Settings, choose provider: `Custom OpenAI-Like`. +2. Set `API_BASE` to your endpoint (example: `http://host.docker.internal:8880/v1`). +3. Set `API_KEY` if your provider requires one. +4. Choose model/voice. + + + + +## Provider Dropdown Behavior + +In Settings, provider options include: - `OpenAI` - `Deepinfra` -- `Custom OpenAI-Like` (for Kokoro, Orpheus, and other compatible endpoints) +- `Custom OpenAI-Like` (Kokoro, Orpheus, and other OpenAI-compatible endpoints) `API_BASE` guidance: -- For `OpenAI` and `Deepinfra`, OpenReader auto-fills the default endpoint. -- For `Custom OpenAI-Like`, set `API_BASE` to your server endpoint. -- In practice, you usually only set `API_BASE` when using a provider/endpoint that is not directly covered by the built-in dropdown defaults. +- `OpenAI` and `Deepinfra` auto-fill default endpoints. +- `Custom OpenAI-Like` requires setting `API_BASE` manually. -## Custom provider compatibility - -For custom providers, OpenReader expects these endpoints: +:::info OpenAI-Compatible API Shape +Custom providers should expose: - `GET /v1/audio/voices` - `POST /v1/audio/speech` +::: -If your provider exposes this interface, it can be used as an OpenAI-compatible TTS backend. +:::warning Server-Reachable API Base +TTS requests are sent from the Next.js server, not directly from the browser. `API_BASE` must be reachable from the server runtime. +::: -## Setup flow +## Related Guides -1. Select your provider in the OpenReader Settings modal. -2. If using `Custom OpenAI-Like` (or overriding a default), set `API_BASE`. -3. Set `API_KEY` if required by your provider. -4. Choose model and voice. - -For environment variables, see [Environment Variables](../reference/environment-variables). -For TTS quota behavior, see [TTS Rate Limiting](./tts-rate-limiting). -For auth behavior, see [Auth](./configuration). -For provider-specific integration guides, see [Kokoro-FastAPI](../integrations/kokoro-fastapi), [Orpheus-FastAPI](../integrations/orpheus-fastapi), [Deepinfra](../integrations/deepinfra), [OpenAI](../integrations/openai), and [Custom OpenAI](../integrations/custom-openai). +- [Environment Variables](../reference/environment-variables) +- [TTS Rate Limiting](./tts-rate-limiting) +- [Auth](./auth) +- [Kokoro-FastAPI](../integrations/kokoro-fastapi) +- [Orpheus-FastAPI](../integrations/orpheus-fastapi) +- [Deepinfra](../integrations/deepinfra) +- [OpenAI](../integrations/openai) +- [Custom OpenAI](../integrations/custom-openai) diff --git a/docs-site/docs/configure/tts-rate-limiting.md b/docs-site/docs/configure/tts-rate-limiting.md index 8bdf336..cdd892b 100644 --- a/docs-site/docs/configure/tts-rate-limiting.md +++ b/docs-site/docs/configure/tts-rate-limiting.md @@ -47,5 +47,5 @@ IP backstop daily limits: ## Related docs - Full variable list: [Environment Variables](../reference/environment-variables) -- Auth configuration: [Auth](./configuration) +- Auth configuration: [Auth](./auth) - Provider setup: [TTS Providers](./tts-providers) diff --git a/docs-site/docs/intro.md b/docs-site/docs/intro.md deleted file mode 100644 index 8a7b2e2..0000000 --- a/docs-site/docs/intro.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -id: intro -title: Introduction -slug: / ---- - -OpenReader WebUI is an open source text-to-speech document reader built with Next.js. It provides a read-along experience with narration for **EPUB, PDF, TXT, MD, and DOCX documents**. - -It supports multiple TTS providers including OpenAI, DeepInfra, and custom OpenAI-compatible endpoints such as [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI) and [Orpheus-FastAPI](https://github.com/Lex-au/Orpheus-FastAPI). - -## Highlights - -- Multi-provider TTS support - - [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI) (including multi-voice combinations) - - [Orpheus-FastAPI](https://github.com/Lex-au/Orpheus-FastAPI) - - Custom OpenAI-compatible endpoints (`/v1/audio/voices` and `/v1/audio/speech`) - - Cloud providers such as [DeepInfra](https://deepinfra.com/models/text-to-speech) and [OpenAI API](https://platform.openai.com/docs/pricing#transcription-and-speech) -- Server-side sync and storage - - External library import from a server-mounted folder - - Sync documents between browser and server for multi-device use -- Server-side audiobook export in `m4b`/`mp3`, with resumable chapter-based export -- Read-along highlighting for PDF and EPUB - - Optional word-by-word highlighting using server-side timestamps from [whisper.cpp](https://github.com/ggml-org/whisper.cpp) -- Sentence-aware narration that merges across pages/chapters for smoother playback -- Optimized Next.js TTS proxy with caching for faster repeat playback -- Customizable themes, TTS settings, and document handling - -## Start Here - -- [Docker Quick Start](./start-here/docker-quick-start) -- [Local Development](./start-here/local-development) -- [Environment Variables](./reference/environment-variables) -- [Auth](./configure/configuration) -- [Database and Migrations](./configure/database-and-migrations) -- [Object / Blob Storage](./configure/storage-and-blob-behavior) -- [TTS Providers](./configure/tts-providers) - -## Source Repository - -- GitHub: [richardr1126/OpenReader-WebUI](https://github.com/richardr1126/OpenReader-WebUI) diff --git a/docs-site/docs/introduction.md b/docs-site/docs/introduction.md new file mode 100644 index 0000000..c608431 --- /dev/null +++ b/docs-site/docs/introduction.md @@ -0,0 +1,48 @@ +--- +id: intro +title: Introduction +slug: / +--- + +OpenReader WebUI is an open source text-to-speech document reader built with Next.js. It provides a read-along experience with narration for **EPUB, PDF, TXT, MD, and DOCX documents**. + +It supports multiple TTS providers including OpenAI, DeepInfra, and custom OpenAI-compatible endpoints such as [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI) and [Orpheus-FastAPI](https://github.com/Lex-au/Orpheus-FastAPI). + +## ✨ Highlights + +- 🎯 **Multi-Provider TTS Support** + - [**Kokoro-FastAPI**](https://github.com/remsky/Kokoro-FastAPI): supports multi-voice combinations (for example `af_heart+af_bella`) + - [**Orpheus-FastAPI**](https://github.com/Lex-au/Orpheus-FastAPI) + - **Custom OpenAI-compatible**: any TTS API with `/v1/audio/voices` and `/v1/audio/speech` endpoints + - **Cloud TTS providers**: + - [**DeepInfra**](https://deepinfra.com/models/text-to-speech): Kokoro-82M and other hosted models + - [**OpenAI API**](https://platform.openai.com/docs/pricing#transcription-and-speech): `tts-1`, `tts-1-hd`, and `gpt-4o-mini-tts` +- 🛜 **Server-side Document Storage** + - Documents are persisted in server blob/object storage for consistent access +- 📚 **External Library Import** + - Import documents from server-mounted folders +- 🎧 **Server-side Audiobook Export** in `m4b`/`mp3` with resumable chapter generation +- 📖 **Read Along Experience** + - Real-time highlighting for PDF/EPUB, with optional word-level [whisper.cpp](https://github.com/ggml-org/whisper.cpp) timestamps +- 🔐 **Auth Optional by Design** + - Run no-auth for local use, or enable auth with user isolation and claim flow +- 🗂️ **Flexible Storage and Database Modes** with embedded defaults or external S3/Postgres +- 🚀 **Production-ready Server Behavior** with TTS caching/retries/rate limits and startup migrations +- 🎨 **Customizable Experience** + - Theme, TTS, and document handling controls + +## 🚀 Start Here + +- [Docker Quick Start](./start-here/docker-quick-start) +- [Vercel Deployment](./start-here/vercel-deployment) +- [Local Development](./start-here/local-development) +- [Environment Variables](./reference/environment-variables) +- [Auth](./configure/auth) +- [Database and Migrations](./configure/database-and-migrations) +- [Object / Blob Storage](./configure/object-blob-storage) +- [Server Library Import](./configure/server-library-import) +- [TTS Providers](./configure/tts-providers) + +## Source Repository + +- GitHub: [richardr1126/OpenReader-WebUI](https://github.com/richardr1126/OpenReader-WebUI) diff --git a/docs-site/docs/project/support.md b/docs-site/docs/project/support-and-contributing.md similarity index 100% rename from docs-site/docs/project/support.md rename to docs-site/docs/project/support-and-contributing.md diff --git a/docs-site/docs/start-here/docker-quick-start.md b/docs-site/docs/start-here/docker-quick-start.md index 62bafa4..eec13a8 100644 --- a/docs-site/docs/start-here/docker-quick-start.md +++ b/docs-site/docs/start-here/docker-quick-start.md @@ -2,6 +2,9 @@ title: Docker Quick Start --- +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + ## Prerequisites - A recent Docker version installed @@ -13,7 +16,10 @@ If you have suitable hardware, you can run Kokoro locally with Docker. See [Koko ## 1. Start the Docker container -Minimal setup (auth disabled, embedded storage ephemeral, no library import): + + + +Auth disabled, embedded storage ephemeral, no library import: ```bash docker run --name openreader-webui \ @@ -23,7 +29,10 @@ docker run --name openreader-webui \ ghcr.io/richardr1126/openreader-webui:latest ``` -Fully featured setup (persistent storage, embedded SeaweedFS `weed mini`, optional auth): + + + +Persistent storage, embedded SeaweedFS `weed mini`, optional auth, optional library mount: ```bash docker run --name openreader-webui \ @@ -39,21 +48,38 @@ docker run --name openreader-webui \ ghcr.io/richardr1126/openreader-webui:latest ``` -You can remove `/app/docstore/library` if you do not need server library import. -You can remove either `BASE_URL` or `AUTH_SECRET` to keep auth disabled. + + -Quick notes: +:::tip +Remove `/app/docstore/library` if you do not need server library import. +::: -- `API_BASE` should point to your TTS server base URL. -- Expose `8333` for direct browser access to embedded SeaweedFS presigned URLs. -- If `8333` is not exposed, uploads still work through `/api/documents/blob/upload/fallback`. -- To enable auth, set both `BASE_URL` and `AUTH_SECRET`. -- DB migrations run automatically during container startup via the shared entrypoint. +:::tip +Remove either `BASE_URL` or `AUTH_SECRET` to keep auth disabled. +::: -For all environment variables, see [Environment Variables](../reference/environment-variables). -For app/auth behavior, see [Auth](../configure/configuration). -For database startup and migration behavior, see [Database and Migrations](../configure/database-and-migrations). -For blob behavior and mounts, see [Object / Blob Storage](../configure/storage-and-blob-behavior). +:::tip TTS API Base +Set `API_BASE` to your reachable TTS server base URL. +::: + +:::warning Port `8333` Exposure +Expose `8333` for direct browser presigned upload/download with embedded SeaweedFS. + +If `8333` is not reachable from the browser, direct presigned access is unavailable. Uploads can still fall back to `/api/documents/blob/upload/fallback`, and document reads/downloads continue through `/api/documents/blob`. +::: + +:::info Auth and Migrations +- Auth is enabled only when both `BASE_URL` and `AUTH_SECRET` are set. +- DB/storage migrations run automatically at container startup via the shared entrypoint. +::: + +:::info Related Docs +- [Environment Variables](../reference/environment-variables) +- [Auth](../configure/auth) +- [Database and Migrations](../configure/database-and-migrations) +- [Object / Blob Storage](../configure/object-blob-storage) +::: ## 2. Configure settings in the app UI @@ -70,4 +96,8 @@ docker image rm ghcr.io/richardr1126/openreader-webui:latest || true && \ docker pull ghcr.io/richardr1126/openreader-webui:latest ``` +:::tip +If you use a mounted volume for `/app/docstore`, your persisted data remains after image updates. +::: + Visit [http://localhost:3003](http://localhost:3003) after startup. diff --git a/docs-site/docs/start-here/local-development.md b/docs-site/docs/start-here/local-development.md index 521a9b2..2a7e3bd 100644 --- a/docs-site/docs/start-here/local-development.md +++ b/docs-site/docs/start-here/local-development.md @@ -2,6 +2,9 @@ title: Local Development --- +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + ## Prerequisites - Node.js (recommended with [nvm](https://github.com/nvm-sh/nvm)) @@ -14,10 +17,21 @@ npm install -g pnpm - A reachable TTS API server - [SeaweedFS](https://github.com/seaweedfs/seaweedfs) `weed` binary (required) + + + ```bash brew install seaweedfs ``` + + + +Install the `weed` binary from the [SeaweedFS releases](https://github.com/seaweedfs/seaweedfs/releases) and ensure it is available on `PATH`. + + + + Optional, depending on features: - [libreoffice](https://www.libreoffice.org) (required for DOCX conversion) @@ -39,7 +53,7 @@ cmake --build build -j --config Release echo WHISPER_CPP_BIN="$(pwd)/build/bin/whisper-cli" ``` -:::note +:::tip Set `WHISPER_CPP_BIN` in your `.env` to enable word-by-word highlighting. ::: @@ -66,10 +80,8 @@ cp .env.example .env Then edit `.env`. -Auth is enabled when both are set: - -- `BASE_URL` (for local dev, typically `http://localhost:3003`) -- `AUTH_SECRET` (generate with `openssl rand -base64 32`) +- No auth mode: leave `BASE_URL` or `AUTH_SECRET` unset. +- Auth enabled mode: set both `BASE_URL` (typically `http://localhost:3003`) and `AUTH_SECRET` (generate with `openssl rand -base64 32`). Optional: @@ -77,9 +89,12 @@ Optional: - Stable S3 credentials via `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY` - External S3 storage by setting `USE_EMBEDDED_WEED_MINI=false` and related S3 vars +:::info For all environment variables, see [Environment Variables](../reference/environment-variables). -For app/auth behavior, see [Auth](../configure/configuration). -For storage configuration, see [Object / Blob Storage](../configure/storage-and-blob-behavior). +::: + +For app/auth behavior, see [Auth](../configure/auth). +For storage configuration, see [Object / Blob Storage](../configure/object-blob-storage). For database mode and migrations, see [Database and Migrations](../configure/database-and-migrations). 4. Run DB migrations. @@ -91,21 +106,32 @@ For database mode and migrations, see [Database and Migrations](../configure/dat pnpm migrate ``` -:::note +:::info If `POSTGRES_URL` is set, migrations target Postgres; otherwise local SQLite is used. To disable automatic startup migrations, set `RUN_DRIZZLE_MIGRATIONS=false` and/or `RUN_FS_MIGRATIONS=false`. You can run storage migration manually with `pnpm migrate-fs`. ::: 5. Start the app. + + + ```bash pnpm dev ``` -Or build + start production mode: + + ```bash pnpm build pnpm start ``` + + + +:::warning API Base Reachability +`API_BASE` must be reachable from the Next.js server process, not just your browser. +::: + Visit [http://localhost:3003](http://localhost:3003). diff --git a/docs-site/docs/start-here/vercel-deployment.md b/docs-site/docs/start-here/vercel-deployment.md index 50d06fe..a1dbcb4 100644 --- a/docs-site/docs/start-here/vercel-deployment.md +++ b/docs-site/docs/start-here/vercel-deployment.md @@ -2,17 +2,24 @@ title: Vercel Deployment --- +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + This guide covers deploying OpenReader WebUI to Vercel with external Postgres and S3-compatible object storage. ## What works on Vercel - Documents (PDF/EPUB/TXT/MD) work with `POSTGRES_URL` + external S3 storage. - Audiobook routes work on Node.js serverless functions using `ffmpeg-static`/`ffprobe-static`. -- `docx` conversion requires `soffice` (LibreOffice), which is not available in a standard Vercel runtime. -## 1. Required environment variables +:::warning DOCX Conversion Limitation +`docx` conversion requires `soffice` (LibreOffice), which is not available in a standard Vercel runtime. +::: -Set these in your Vercel project: +## 1. Environment Variables + + + ```bash POSTGRES_URL=postgres://... @@ -26,7 +33,8 @@ S3_FORCE_PATH_STYLE=true S3_PREFIX=openreader ``` -Optional but common: + + ```bash BASE_URL=https://your-app.vercel.app @@ -36,15 +44,26 @@ NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT=true NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT=true ``` + + + +:::tip For all variables and defaults, see [Environment Variables](../reference/environment-variables). +::: ## 2. FFmpeg/ffprobe packaging in Vercel functions `ffmpeg-static` and `ffprobe-static` binaries must be included in function traces. This repo already does that in `next.config.ts` via `outputFileTracingIncludes` for: -- `/api/audiobook(.*)` +- `/api/audiobook` +- `/api/audiobook/chapter` +- `/api/audiobook/status` - `/api/whisper` +:::info +`serverExternalPackages` should include `ffmpeg-static` and `ffprobe-static` so package paths resolve at runtime instead of being bundled into route output. +::: + If you change route paths or split handlers, update `outputFileTracingIncludes` accordingly. ## 3. Function memory sizing @@ -66,7 +85,7 @@ Adjust memory per route if your files are larger or your plan differs. ## 4. Runtime expectations and caveats - Audiobook APIs require S3 configuration; otherwise they return `503`. -- `better-sqlite3` remains in `serverExternalPackages` for mixed/self-host setups, but production Vercel should use `POSTGRES_URL`. +- For production Vercel deploys, use `POSTGRES_URL` instead of SQLite. - Filesystem-to-object-store migrations run via server scripts/entrypoint (`scripts/migrate-fs-v2.mjs`), not API routes. - Vercel deployments do not run `scripts/openreader-entrypoint.mjs`, so run `pnpm migrate-fs` in a controlled environment when migrating legacy filesystem data. diff --git a/docs-site/docusaurus.config.ts b/docs-site/docusaurus.config.ts index 8cbef16..4abad5d 100644 --- a/docs-site/docusaurus.config.ts +++ b/docs-site/docusaurus.config.ts @@ -98,7 +98,7 @@ const config: Config = { { title: 'Community', items: [ - { label: 'Support', to: '/project/support' }, + { label: 'Support', to: '/project/support-and-contributing' }, { label: 'GitHub Discussions', href: 'https://github.com/richardr1126/OpenReader-WebUI/discussions' }, { label: 'Issues', href: 'https://github.com/richardr1126/OpenReader-WebUI/issues' }, ], diff --git a/docs-site/sidebars.ts b/docs-site/sidebars.ts index 291cf19..b400937 100644 --- a/docs-site/sidebars.ts +++ b/docs-site/sidebars.ts @@ -21,14 +21,11 @@ const sidebars: SidebarsConfig = { label: 'Configure', items: [ 'configure/tts-providers', - { - type: 'doc', - id: 'configure/configuration', - label: 'Auth (Reccomended)', - }, + 'configure/auth', 'configure/tts-rate-limiting', 'configure/database-and-migrations', - 'configure/storage-and-blob-behavior', + 'configure/object-blob-storage', + 'configure/server-library-import', ], }, { @@ -45,7 +42,7 @@ const sidebars: SidebarsConfig = { { type: 'category', label: 'Project', - items: ['project/support', 'project/acknowledgements', 'project/license'], + items: ['project/support-and-contributing', 'project/acknowledgements', 'project/license'], }, ], }; From 852092c5588c74ac6dcc03e244bd3293f4b3c3e4 Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 11 Feb 2026 06:03:51 -0700 Subject: [PATCH 22/55] docs: reorganize documentation structure and fix word highlight behavior --- .../{project => about}/acknowledgements.md | 0 docs-site/docs/{project => about}/license.md | 0 .../support-and-contributing.md | 0 docs-site/docs/configure/auth.md | 5 +- docs-site/docs/configure/database.md | 26 +++++++ ...tabase-and-migrations.md => migrations.md} | 68 +++++++++++++----- .../docs/configure/object-blob-storage.md | 62 +++++++++++++--- .../docs/configure/server-library-import.md | 42 +++++------ .../tts-provider-guides/custom-openai.md | 40 +++++++++++ .../tts-provider-guides/deepinfra.md | 33 +++++++++ .../tts-provider-guides/kokoro-fastapi.md | 68 ++++++++++++++++++ .../configure/tts-provider-guides/openai.md | 33 +++++++++ .../tts-provider-guides/orpheus-fastapi.md | 36 ++++++++++ docs-site/docs/configure/tts-providers.md | 17 +++-- docs-site/docs/configure/tts-rate-limiting.md | 2 +- .../local-development.md | 3 +- .../vercel-deployment.md | 48 ++++++++----- .../{start-here => }/docker-quick-start.md | 11 +-- docs-site/docs/integrations/custom-openai.md | 28 -------- docs-site/docs/integrations/deepinfra.md | 19 ----- docs-site/docs/integrations/kokoro-fastapi.md | 49 ------------- docs-site/docs/integrations/openai.md | 18 ----- .../docs/integrations/orpheus-fastapi.md | 23 ------ docs-site/docs/introduction.md | 11 +-- .../docs/reference/environment-variables.md | 62 ++++++++++++++-- docs-site/docusaurus.config.ts | 2 +- docs-site/sidebars.ts | 71 ++++++++++++------- src/components/DocumentSettings.tsx | 3 +- src/contexts/TTSContext.tsx | 6 +- src/types/config.ts | 5 +- 30 files changed, 528 insertions(+), 263 deletions(-) rename docs-site/docs/{project => about}/acknowledgements.md (100%) rename docs-site/docs/{project => about}/license.md (100%) rename docs-site/docs/{project => about}/support-and-contributing.md (100%) create mode 100644 docs-site/docs/configure/database.md rename docs-site/docs/configure/{database-and-migrations.md => migrations.md} (53%) create mode 100644 docs-site/docs/configure/tts-provider-guides/custom-openai.md create mode 100644 docs-site/docs/configure/tts-provider-guides/deepinfra.md create mode 100644 docs-site/docs/configure/tts-provider-guides/kokoro-fastapi.md create mode 100644 docs-site/docs/configure/tts-provider-guides/openai.md create mode 100644 docs-site/docs/configure/tts-provider-guides/orpheus-fastapi.md rename docs-site/docs/{start-here => deploy}/local-development.md (95%) rename docs-site/docs/{start-here => deploy}/vercel-deployment.md (65%) rename docs-site/docs/{start-here => }/docker-quick-start.md (90%) delete mode 100644 docs-site/docs/integrations/custom-openai.md delete mode 100644 docs-site/docs/integrations/deepinfra.md delete mode 100644 docs-site/docs/integrations/kokoro-fastapi.md delete mode 100644 docs-site/docs/integrations/openai.md delete mode 100644 docs-site/docs/integrations/orpheus-fastapi.md diff --git a/docs-site/docs/project/acknowledgements.md b/docs-site/docs/about/acknowledgements.md similarity index 100% rename from docs-site/docs/project/acknowledgements.md rename to docs-site/docs/about/acknowledgements.md diff --git a/docs-site/docs/project/license.md b/docs-site/docs/about/license.md similarity index 100% rename from docs-site/docs/project/license.md rename to docs-site/docs/about/license.md diff --git a/docs-site/docs/project/support-and-contributing.md b/docs-site/docs/about/support-and-contributing.md similarity index 100% rename from docs-site/docs/project/support-and-contributing.md rename to docs-site/docs/about/support-and-contributing.md diff --git a/docs-site/docs/configure/auth.md b/docs-site/docs/configure/auth.md index 0e8d9df..0214f21 100644 --- a/docs-site/docs/configure/auth.md +++ b/docs-site/docs/configure/auth.md @@ -12,8 +12,9 @@ This page covers application-level configuration for provider access and authent ## Related docs -- For the complete variable reference: [Environment Variables](../reference/environment-variables) +- For auth environment variables: [Environment Variables](../reference/environment-variables#auth-and-identity) - For TTS character limits and quota behavior: [TTS Rate Limiting](./tts-rate-limiting) - For provider-specific guidance: [TTS Providers](./tts-providers) - For storage/S3/SeaweedFS behavior: [Object / Blob Storage](./object-blob-storage) -- For database mode and migration commands: [Database and Migrations](./database-and-migrations) +- For database mode: [Database](./database) +- For migration behavior and commands: [Migrations](./migrations) diff --git a/docs-site/docs/configure/database.md b/docs-site/docs/configure/database.md new file mode 100644 index 0000000..6663c8e --- /dev/null +++ b/docs-site/docs/configure/database.md @@ -0,0 +1,26 @@ +--- +title: Database +--- + +This page covers database mode selection for OpenReader WebUI. + +## Database mode + +- SQLite (default): embedded DB at `docstore/sqlite3.db`; good for local/self-host single-instance setups. +- Postgres: enabled when `POSTGRES_URL` is set; recommended for production/distributed deployments. + +## What the database stores + +- Document and audiobook metadata/state used by server routes. +- Auth/session tables when auth is enabled. + +## Related variables + +- `POSTGRES_URL` + +For database variable behavior, see [Environment Variables](../reference/environment-variables#database-and-object-blob-storage). + +## Related docs + +- [Migrations](./migrations) +- [Object / Blob Storage](./object-blob-storage) diff --git a/docs-site/docs/configure/database-and-migrations.md b/docs-site/docs/configure/migrations.md similarity index 53% rename from docs-site/docs/configure/database-and-migrations.md rename to docs-site/docs/configure/migrations.md index 1f89edd..934472f 100644 --- a/docs-site/docs/configure/database-and-migrations.md +++ b/docs-site/docs/configure/migrations.md @@ -1,16 +1,11 @@ --- -title: Database and Migrations +title: Migrations --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -This page covers database mode selection and migration behavior for OpenReader WebUI. - -## Database mode - -- SQLite (default): embedded DB at `docstore/sqlite3.db`; good for local/self-host single-instance setups. -- Postgres: enabled when `POSTGRES_URL` is set; recommended for production/distributed deployments. +This page covers migration behavior for both database schema and storage data in OpenReader WebUI. ## Startup migration behavior @@ -34,17 +29,28 @@ To skip automatic startup migrations: - Set `RUN_DRIZZLE_MIGRATIONS=false` - Set `RUN_FS_MIGRATIONS=false` -Database variables are documented in [Environment Variables](../reference/environment-variables). +:::warning +If you disable startup migrations, ensure your deployment process runs migrations before serving traffic. +::: -## Common project commands +## Apply migrations In most cases, you do not need manual migration commands because startup runs migrations automatically. - +`pnpm migrate` applies migrations for one database target: + +- Postgres when `POSTGRES_URL` is set +- SQLite when `POSTGRES_URL` is unset + +You can always override the target explicitly with `--config`. + + ```bash -# Run pending migrations (uses Postgres config when POSTGRES_URL is set, otherwise SQLite) +# Run pending migrations for one target: +# - Postgres if POSTGRES_URL is set +# - SQLite if POSTGRES_URL is unset pnpm migrate # Run storage migration (filesystem -> S3 + DB) @@ -52,13 +58,10 @@ pnpm migrate-fs # Dry-run storage migration without uploading/deleting pnpm migrate-fs:dry-run - -# Generate new migration files for both SQLite and Postgres outputs -pnpm generate ``` - + ```bash # Migrate SQLite @@ -66,7 +69,34 @@ pnpm exec drizzle-kit migrate --config drizzle.config.sqlite.ts # Migrate Postgres pnpm exec drizzle-kit migrate --config drizzle.config.pg.ts +``` + + + +## Generate migrations + +`pnpm generate` creates migration files for both configs in one run: + +- `drizzle.config.sqlite.ts` +- `drizzle.config.pg.ts` + +:::note +Most users do not need to run `pnpm generate`. Use it when contributing or when you have changed Drizzle schema files and need new migration files. +::: + + + + +```bash +# Generate migration files for both SQLite and Postgres outputs +pnpm generate +``` + + + + +```bash # Generate SQLite migrations pnpm exec drizzle-kit generate --config drizzle.config.sqlite.ts @@ -77,6 +107,8 @@ pnpm exec drizzle-kit generate --config drizzle.config.pg.ts -:::warning -If you disable startup migrations, ensure your deployment process runs migrations before serving traffic. -::: +## Related docs + +- [Database](./database) +- [Object / Blob Storage](./object-blob-storage) +- [Migration Environment Variables](../reference/environment-variables#migration-controls) diff --git a/docs-site/docs/configure/object-blob-storage.md b/docs-site/docs/configure/object-blob-storage.md index 2aad353..9664ea1 100644 --- a/docs-site/docs/configure/object-blob-storage.md +++ b/docs-site/docs/configure/object-blob-storage.md @@ -2,6 +2,9 @@ title: Object / Blob Storage --- +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + This page documents storage backends, blob upload routing, and core Docker mount behavior. ## Storage backends @@ -9,7 +12,7 @@ This page documents storage backends, blob upload routing, and core Docker mount - Embedded (default): SQLite metadata + embedded SeaweedFS (`weed mini`) blobs. - External: Postgres + external S3-compatible object storage. -Storage variables are documented in [Environment Variables](../reference/environment-variables) under the storage sections. +Storage variables are documented in [Environment Variables](../reference/environment-variables#database-and-object-blob-storage). ## Ports @@ -26,13 +29,22 @@ Storage variables are documented in [Environment Variables](../reference/environ - Fallback path: `/api/documents/blob/upload/fallback` when direct upload fails/unreachable. - Read/download path: blob/content serving route `/api/documents/blob` (not the upload fallback route). -## Recommended Docker mounts +## FS / Volume Mounts -| Mount | Type | Recommended | Purpose | Example | -| --- | --- | --- | --- | --- | -| `/app/docstore` | Docker named volume | Yes (for persistence) | Persists SeaweedFS blob data, SQLite metadata DB, migrations, and local runtime temp state | `-v openreader_docstore:/app/docstore` | +### App data mount -For server library mounts/import behavior, see [Server Library Import](./server-library-import). +- Target: `/app/docstore` +- Recommended: yes, for persistence +- Purpose: persists SeaweedFS blob data, SQLite metadata DB, migrations, and local runtime temp state +- Mount string: `-v openreader_docstore:/app/docstore` + +### Library source mount (optional) + +- Target: `/app/docstore/library` +- Recommended: optional, use read-only (`:ro`) +- Purpose: exposes host files as a source for server library import +- Mount string: `-v /path/to/your/library:/app/docstore/library:ro` +- Details: [Server Library Import](./server-library-import) ## Private blob endpoint mode @@ -46,7 +58,39 @@ If `8333` is not published externally: Without `8333`, expect higher app-server traffic because uploads/downloads go through API routes instead of direct object endpoint access. ::: -## Audiobook storage note +## Audiobook Storage Debug Commands -- In current versions, audiobook assets live in object storage (`audiobooks_v1` keyspace), not as durable files under `/app/docstore`. -- Local filesystem usage for audiobook routes is temporary processing only. +Audiobook assets are stored in object storage under the `audiobooks_v1` keyspace. Use these commands to inspect and download objects for debugging. + + + + +```bash +# List all audiobook objects +aws s3 ls "s3://$S3_BUCKET/$S3_PREFIX/audiobooks_v1/" --recursive + +# Filter to one book id (replace ) +aws s3 ls "s3://$S3_BUCKET/$S3_PREFIX/audiobooks_v1/" --recursive | grep "-audiobook/" + +# Download one object by full key +aws s3 cp "s3://$S3_BUCKET/$S3_PREFIX/audiobooks_v1//.m4b" "./audiobook.m4b" +``` + + + + +```bash +# List all audiobook objects +aws s3 ls "s3://$S3_BUCKET/$S3_PREFIX/audiobooks_v1/" --recursive --endpoint-url "$S3_ENDPOINT" + +# Filter to one book id (replace ) +aws s3 ls "s3://$S3_BUCKET/$S3_PREFIX/audiobooks_v1/" --recursive --endpoint-url "$S3_ENDPOINT" | grep "-audiobook/" + +# Download one object by full key +aws s3 cp "s3://$S3_BUCKET/$S3_PREFIX/audiobooks_v1//.m4b" "./audiobook.m4b" --endpoint-url "$S3_ENDPOINT" +``` + +Embedded default example: `S3_ENDPOINT=http://127.0.0.1:8333` (or your mapped host/port). + + + diff --git a/docs-site/docs/configure/server-library-import.md b/docs-site/docs/configure/server-library-import.md index 9301fa3..acdecd2 100644 --- a/docs-site/docs/configure/server-library-import.md +++ b/docs-site/docs/configure/server-library-import.md @@ -12,30 +12,21 @@ Server library import lets you browse files from one or more server directories - Only selected files are imported - Imported files become normal OpenReader documents -## Configure library roots +## FS / Volume Mounts -Library roots are resolved from environment variables: +### App data mount -- `IMPORT_LIBRARY_DIRS` (takes precedence): multiple roots separated by comma, colon, or semicolon -- `IMPORT_LIBRARY_DIR`: single root -- Fallback when neither is set: `docstore/library` +- Target: `/app/docstore` +- Recommended: yes, for persistence +- Purpose: stores app runtime data, metadata DB, and embedded storage state +- Mount string: `-v openreader_docstore:/app/docstore` -See [Environment Variables](../reference/environment-variables#import_library_dir) for details. +### Library source mount -## Docker mount example - -Mount a host folder to the default library path: - -```bash -docker run --name openreader-webui \ - --restart unless-stopped \ - -p 3003:3003 \ - -v openreader_docstore:/app/docstore \ - -v /path/to/your/library:/app/docstore/library:ro \ - ghcr.io/richardr1126/openreader-webui:latest -``` - -Using `:ro` is recommended so the app treats the library as a read-only source. +- Target: `/app/docstore/library` +- Recommended: yes for this feature, use read-only (`:ro`) +- Purpose: exposes host files as import candidates in Server Library Import +- Mount string: `-v /path/to/your/library:/app/docstore/library:ro` ## Import flow @@ -59,6 +50,17 @@ Imported documents are still saved to the importing user's document scope. - `.txt` - `.md`, `.mdown`, `.markdown` +## Optional: Configure Library Roots + +You only need this when the default mounted path is not what you want. + +By default, OpenReader uses `docstore/library` as the import root. You can override that with environment variables: + +- `IMPORT_LIBRARY_DIRS` (takes precedence): multiple roots separated by comma, colon, or semicolon +- `IMPORT_LIBRARY_DIR`: single root + +See [Environment Variables](../reference/environment-variables#library-import) for variable details. + ## Notes - Library listing is capped per request (up to 10,000 files). diff --git a/docs-site/docs/configure/tts-provider-guides/custom-openai.md b/docs-site/docs/configure/tts-provider-guides/custom-openai.md new file mode 100644 index 0000000..6822333 --- /dev/null +++ b/docs-site/docs/configure/tts-provider-guides/custom-openai.md @@ -0,0 +1,40 @@ +--- +title: Custom OpenAI +--- + +Use any custom OpenAI-compatible TTS service with OpenReader. + +Use this integration when your endpoint is not directly covered by built-in dropdown defaults. + +## Provider + +- Provider: `Custom OpenAI-Like` +- `API_BASE`: required (your service base URL) +- `API_KEY`: set if required by your service + +Custom providers should expose: + +- `GET /v1/audio/voices` +- `POST /v1/audio/speech` + +## OpenReader setup + +1. In OpenReader Settings, choose provider `Custom OpenAI-Like`. +2. Set `API_BASE` to your service base URL (typically ending with `/v1`). +3. Set `API_KEY` if your service requires authentication. +4. Choose a model and voice supported by your backend. + +## Notes + +:::warning Compatibility required +Custom providers must implement OpenAI-compatible TTS endpoints, including `GET /v1/audio/voices` and `POST /v1/audio/speech`. +::: + +:::info Voice troubleshooting +If voices do not load, verify the `/v1/audio/voices` response shape and that the endpoint is reachable from the OpenReader server. +::: + +## References + +- [TTS Providers](../tts-providers) +- [TTS Environment Variables](../../reference/environment-variables#tts-provider-and-request-behavior) diff --git a/docs-site/docs/configure/tts-provider-guides/deepinfra.md b/docs-site/docs/configure/tts-provider-guides/deepinfra.md new file mode 100644 index 0000000..f7f3116 --- /dev/null +++ b/docs-site/docs/configure/tts-provider-guides/deepinfra.md @@ -0,0 +1,33 @@ +--- +title: Deepinfra +--- + +Use Deepinfra as a hosted OpenAI-compatible TTS provider. + +## Provider + +- Provider: `Deepinfra` +- Default endpoint: `https://api.deepinfra.com/v1/openai` (auto-filled) +- `API_KEY`: required for authenticated DeepInfra usage + +## OpenReader setup + +1. In OpenReader Settings, choose provider `Deepinfra`. +2. Keep the default `API_BASE`. +3. Set `API_KEY`. +4. Choose your model and voice. + +## Notes + +:::tip Built-in endpoint +`Deepinfra` is a built-in provider, so OpenReader auto-fills the default `API_BASE`. +::: + +:::info Model support +DeepInfra exposes multiple TTS models, including Kokoro-family options. +::: + +## References + +- [TTS Providers](../tts-providers) +- [TTS Environment Variables](../../reference/environment-variables#tts-provider-and-request-behavior) diff --git a/docs-site/docs/configure/tts-provider-guides/kokoro-fastapi.md b/docs-site/docs/configure/tts-provider-guides/kokoro-fastapi.md new file mode 100644 index 0000000..fb39a5c --- /dev/null +++ b/docs-site/docs/configure/tts-provider-guides/kokoro-fastapi.md @@ -0,0 +1,68 @@ +--- +title: Kokoro-FastAPI +--- + +You can run the Kokoro TTS API server directly with Docker. + +:::warning +For Kokoro issues and support, use the upstream repository: [remsky/Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI). +::: + +## Provider + +- Provider: `Custom OpenAI-Like` +- Typical model: `Kokoro` +- `API_BASE`: required (typically your Kokoro URL ending with `/v1`) +- `API_KEY`: set only if your deployment requires one + +## Run Kokoro (CPU) + +```bash +docker run -d \ + --name kokoro-tts \ + --restart unless-stopped \ + -p 8880:8880 \ + -e ONNX_NUM_THREADS=8 \ + -e ONNX_INTER_OP_THREADS=4 \ + -e ONNX_EXECUTION_MODE=parallel \ + -e ONNX_OPTIMIZATION_LEVEL=all \ + -e ONNX_MEMORY_PATTERN=true \ + -e ONNX_ARENA_EXTEND_STRATEGY=kNextPowerOfTwo \ + -e API_LOG_LEVEL=DEBUG \ + ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.4 +``` + +## Run Kokoro (GPU) + +```bash +docker run -d \ + --name kokoro-tts \ + --gpus all \ + --user 1001:1001 \ + --restart unless-stopped \ + -p 8880:8880 \ + -e USE_GPU=true \ + -e PYTHONUNBUFFERED=1 \ + -e API_LOG_LEVEL=DEBUG \ + ghcr.io/remsky/kokoro-fastapi-gpu:v0.2.4 +``` + +## OpenReader setup + +1. Start Kokoro using either the CPU or GPU image. +2. In OpenReader Settings, choose provider `Custom OpenAI-Like`. +3. Set `API_BASE` to your Kokoro endpoint (for Docker Compose, commonly `http://kokoro-tts:8880/v1`). +4. Set `API_KEY` only if your deployment requires one. +5. Choose model `Kokoro`. + +## Notes + +:::tip Runtime guidance +GPU mode requires NVIDIA Docker support and is best on NVIDIA hardware. CPU mode is a good default on Apple Silicon and modern x86 CPUs. +::: + +## References + +- [remsky/Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI) +- [TTS Providers](../tts-providers) +- [TTS Environment Variables](../../reference/environment-variables#tts-provider-and-request-behavior) diff --git a/docs-site/docs/configure/tts-provider-guides/openai.md b/docs-site/docs/configure/tts-provider-guides/openai.md new file mode 100644 index 0000000..995c074 --- /dev/null +++ b/docs-site/docs/configure/tts-provider-guides/openai.md @@ -0,0 +1,33 @@ +--- +title: OpenAI +--- + +Use OpenAI directly as an OpenAI-compatible TTS provider. + +## Provider + +- Provider: `OpenAI` +- Default endpoint: `https://api.openai.com/v1` (auto-filled) +- `API_KEY`: required for OpenAI access + +## OpenReader setup + +1. In OpenReader Settings, choose provider `OpenAI`. +2. Keep the default `API_BASE`. +3. Set `API_KEY`. +4. Choose your model and voice. + +## Notes + +:::tip Built-in endpoint +`OpenAI` is a built-in provider, so OpenReader auto-fills the default `API_BASE`. +::: + +:::info Server-side requests +OpenReader sends TTS requests from the server runtime, not directly from the browser. +::: + +## References + +- [TTS Providers](../tts-providers) +- [TTS Environment Variables](../../reference/environment-variables#tts-provider-and-request-behavior) diff --git a/docs-site/docs/configure/tts-provider-guides/orpheus-fastapi.md b/docs-site/docs/configure/tts-provider-guides/orpheus-fastapi.md new file mode 100644 index 0000000..ba4ab5c --- /dev/null +++ b/docs-site/docs/configure/tts-provider-guides/orpheus-fastapi.md @@ -0,0 +1,36 @@ +--- +title: Orpheus-FastAPI +--- + +Use Orpheus-FastAPI as an OpenAI-compatible TTS backend for OpenReader. + +## Provider + +- Provider: `Custom OpenAI-Like` +- Typical model: `Orpheus` +- `API_BASE`: required (usually your Orpheus URL ending with `/v1`) +- `API_KEY`: set only if your deployment requires one + +## OpenReader setup + +1. Start your Orpheus-FastAPI server. +2. In OpenReader Settings, choose provider `Custom OpenAI-Like`. +3. Set `API_BASE` to your Orpheus base URL (typically ending with `/v1`). +4. Set `API_KEY` only if your Orpheus deployment requires one. +5. Choose model `Orpheus` (or another model exposed by your deployment). + +## Notes + +:::info OpenAI-compatible API +OpenReader expects OpenAI-compatible audio endpoints when using Orpheus through `Custom OpenAI-Like`. +::: + +:::tip Endpoint shape +Use an `API_BASE` that points at the Orpheus API root (typically ending with `/v1`). +::: + +## References + +- [Lex-au/Orpheus-FastAPI](https://github.com/Lex-au/Orpheus-FastAPI) +- [TTS Providers](../tts-providers) +- [TTS Environment Variables](../../reference/environment-variables#tts-provider-and-request-behavior) diff --git a/docs-site/docs/configure/tts-providers.md b/docs-site/docs/configure/tts-providers.md index e5b41df..75631c0 100644 --- a/docs-site/docs/configure/tts-providers.md +++ b/docs-site/docs/configure/tts-providers.md @@ -64,13 +64,16 @@ Custom providers should expose: TTS requests are sent from the Next.js server, not directly from the browser. `API_BASE` must be reachable from the server runtime. ::: -## Related Guides +## Provider Guides -- [Environment Variables](../reference/environment-variables) +- [Kokoro-FastAPI](./tts-provider-guides/kokoro-fastapi) +- [Orpheus-FastAPI](./tts-provider-guides/orpheus-fastapi) +- [DeepInfra](./tts-provider-guides/deepinfra) +- [OpenAI](./tts-provider-guides/openai) +- [Custom OpenAI-Like](./tts-provider-guides/custom-openai) + +## Related Configuration + +- [TTS Environment Variables](../reference/environment-variables#tts-provider-and-request-behavior) - [TTS Rate Limiting](./tts-rate-limiting) - [Auth](./auth) -- [Kokoro-FastAPI](../integrations/kokoro-fastapi) -- [Orpheus-FastAPI](../integrations/orpheus-fastapi) -- [Deepinfra](../integrations/deepinfra) -- [OpenAI](../integrations/openai) -- [Custom OpenAI](../integrations/custom-openai) diff --git a/docs-site/docs/configure/tts-rate-limiting.md b/docs-site/docs/configure/tts-rate-limiting.md index cdd892b..ed507d1 100644 --- a/docs-site/docs/configure/tts-rate-limiting.md +++ b/docs-site/docs/configure/tts-rate-limiting.md @@ -46,6 +46,6 @@ IP backstop daily limits: ## Related docs -- Full variable list: [Environment Variables](../reference/environment-variables) +- TTS/rate-limit environment variables: [Environment Variables](../reference/environment-variables#tts-provider-and-request-behavior) - Auth configuration: [Auth](./auth) - Provider setup: [TTS Providers](./tts-providers) diff --git a/docs-site/docs/start-here/local-development.md b/docs-site/docs/deploy/local-development.md similarity index 95% rename from docs-site/docs/start-here/local-development.md rename to docs-site/docs/deploy/local-development.md index 2a7e3bd..e123369 100644 --- a/docs-site/docs/start-here/local-development.md +++ b/docs-site/docs/deploy/local-development.md @@ -95,7 +95,8 @@ For all environment variables, see [Environment Variables](../reference/environm For app/auth behavior, see [Auth](../configure/auth). For storage configuration, see [Object / Blob Storage](../configure/object-blob-storage). -For database mode and migrations, see [Database and Migrations](../configure/database-and-migrations). +For database mode, see [Database](../configure/database). +For migration behavior and commands, see [Migrations](../configure/migrations). 4. Run DB migrations. diff --git a/docs-site/docs/start-here/vercel-deployment.md b/docs-site/docs/deploy/vercel-deployment.md similarity index 65% rename from docs-site/docs/start-here/vercel-deployment.md rename to docs-site/docs/deploy/vercel-deployment.md index a1dbcb4..a77ea2f 100644 --- a/docs-site/docs/start-here/vercel-deployment.md +++ b/docs-site/docs/deploy/vercel-deployment.md @@ -2,9 +2,6 @@ title: Vercel Deployment --- -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - This guide covers deploying OpenReader WebUI to Vercel with external Postgres and S3-compatible object storage. ## What works on Vercel @@ -18,8 +15,7 @@ This guide covers deploying OpenReader WebUI to Vercel with external Postgres an ## 1. Environment Variables - - +Recommended production setup (auth enabled): ```bash POSTGRES_URL=postgres://... @@ -28,24 +24,42 @@ S3_ACCESS_KEY_ID=... S3_SECRET_ACCESS_KEY=... S3_BUCKET=... S3_REGION=us-east-1 -S3_ENDPOINT=https://... -S3_FORCE_PATH_STYLE=true S3_PREFIX=openreader -``` - - - - -```bash BASE_URL=https://your-app.vercel.app AUTH_SECRET=... NEXT_PUBLIC_NODE_ENV=production -NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT=true -NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT=true +# Optional client/runtime feature overrides: +# NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT=false +# NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT=true +# Optional (non-AWS S3-compatible providers): +# S3_ENDPOINT=https://... +# S3_FORCE_PATH_STYLE=true ``` - - +:::info `NEXT_PUBLIC_*` feature flags +- `NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT=false`: hides audiobook export UI entry points. +- `NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT=true`: enables word-highlight UI and timestamp alignment requests. +::: + +:::warning `NEXT_PUBLIC_NODE_ENV` behavior +Use `NEXT_PUBLIC_NODE_ENV=production` on Vercel unless you explicitly want dev-oriented client behavior. + +With `production`: +- Footer is shown in the app shell +- DOCX upload/conversion option is hidden +- Default provider/model behavior is production-oriented +- DeepInfra model picker is restricted without an API key +- Privacy modal shows hosted-service/operator wording +- Dev-only destructive document actions are hidden + +With unset/non-`production`, the inverse dev behavior applies. + +Full details: [Environment Variables](../reference/environment-variables#next_public_node_env). +::: + +:::warning Auth recommendation +For internet-exposed Vercel deployments, set both `BASE_URL` and `AUTH_SECRET`. Running without auth is possible, but not recommended for public environments. +::: :::tip For all variables and defaults, see [Environment Variables](../reference/environment-variables). diff --git a/docs-site/docs/start-here/docker-quick-start.md b/docs-site/docs/docker-quick-start.md similarity index 90% rename from docs-site/docs/start-here/docker-quick-start.md rename to docs-site/docs/docker-quick-start.md index eec13a8..fdf3239 100644 --- a/docs-site/docs/start-here/docker-quick-start.md +++ b/docs-site/docs/docker-quick-start.md @@ -11,7 +11,7 @@ import TabItem from '@theme/TabItem'; - A TTS API server that OpenReader can reach (Kokoro-FastAPI, Orpheus-FastAPI, DeepInfra, OpenAI, or equivalent) :::note -If you have suitable hardware, you can run Kokoro locally with Docker. See [Kokoro-FastAPI](../integrations/kokoro-fastapi). +If you have suitable hardware, you can run Kokoro locally with Docker. See [Kokoro-FastAPI](./configure/tts-provider-guides/kokoro-fastapi). ::: ## 1. Start the Docker container @@ -75,10 +75,11 @@ If `8333` is not reachable from the browser, direct presigned access is unavaila ::: :::info Related Docs -- [Environment Variables](../reference/environment-variables) -- [Auth](../configure/auth) -- [Database and Migrations](../configure/database-and-migrations) -- [Object / Blob Storage](../configure/object-blob-storage) +- [Environment Variables](./reference/environment-variables) +- [Auth](./configure/auth) +- [Database](./configure/database) +- [Object / Blob Storage](./configure/object-blob-storage) +- [Migrations](./configure/migrations) ::: ## 2. Configure settings in the app UI diff --git a/docs-site/docs/integrations/custom-openai.md b/docs-site/docs/integrations/custom-openai.md deleted file mode 100644 index bacf6a7..0000000 --- a/docs-site/docs/integrations/custom-openai.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -title: Custom OpenAI ---- - -Use any custom OpenAI-compatible TTS service with OpenReader. - -Use this integration when your endpoint is not directly covered by built-in dropdown defaults. - -## Compatibility requirements - -Your provider should expose: - -- `GET /v1/audio/voices` -- `POST /v1/audio/speech` - -## OpenReader setup - -1. In OpenReader settings, choose provider `Custom OpenAI-Like`. -2. Pick model `Kokoro`, `Orpheus`, or `Other` as appropriate. -3. Set `API_BASE` to your service base URL. -4. Set `API_KEY` if required by your service. -5. Choose voice. - -## Notes - -- `API_BASE` is required for this provider path because OpenReader cannot infer your custom host. -- If voices do not load, verify the `/v1/audio/voices` response format. -- For variable details, see [Environment Variables](../reference/environment-variables). diff --git a/docs-site/docs/integrations/deepinfra.md b/docs-site/docs/integrations/deepinfra.md deleted file mode 100644 index f242031..0000000 --- a/docs-site/docs/integrations/deepinfra.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -title: Deepinfra ---- - -Use Deepinfra as a hosted OpenAI-compatible TTS provider. - -## OpenReader setup - -1. In OpenReader settings, choose provider `Deepinfra`. -2. Leave `API_BASE` unset unless you want to override the default endpoint. - - Default value: `https://api.deepinfra.com/v1/openai` -3. Set `API_KEY` to your Deepinfra API key if needed. -4. Choose model and voice. - -## Notes - -- `Deepinfra` is a built-in provider in the dropdown, so `API_BASE` is usually not required. -- Deepinfra supports multiple TTS models, including Kokoro-family options. -- For variable details, see [Environment Variables](../reference/environment-variables). diff --git a/docs-site/docs/integrations/kokoro-fastapi.md b/docs-site/docs/integrations/kokoro-fastapi.md deleted file mode 100644 index 2a39a32..0000000 --- a/docs-site/docs/integrations/kokoro-fastapi.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -title: Kokoro-FastAPI ---- - -You can run the Kokoro TTS API server directly with Docker. - -:::warning -For Kokoro issues and support, use the upstream repository: [remsky/Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI). -::: - -## CPU image - -```bash -docker run -d \ - --name kokoro-tts \ - --restart unless-stopped \ - -p 8880:8880 \ - -e ONNX_NUM_THREADS=8 \ - -e ONNX_INTER_OP_THREADS=4 \ - -e ONNX_EXECUTION_MODE=parallel \ - -e ONNX_OPTIMIZATION_LEVEL=all \ - -e ONNX_MEMORY_PATTERN=true \ - -e ONNX_ARENA_EXTEND_STRATEGY=kNextPowerOfTwo \ - -e API_LOG_LEVEL=DEBUG \ - ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.4 -``` - -## GPU image - -```bash -docker run -d \ - --name kokoro-tts \ - --gpus all \ - --user 1001:1001 \ - --restart unless-stopped \ - -p 8880:8880 \ - -e USE_GPU=true \ - -e PYTHONUNBUFFERED=1 \ - -e API_LOG_LEVEL=DEBUG \ - ghcr.io/remsky/kokoro-fastapi-gpu:v0.2.4 -``` - -## OpenReader integration notes - -- In OpenReader settings, choose provider `Custom OpenAI-Like` and model `Kokoro`. -- Set OpenReader `API_BASE` to your Kokoro endpoint (for Docker Compose, commonly `http://kokoro-tts:8880/v1`). -- `API_BASE` is needed here because Kokoro is used via the custom provider path, not a built-in provider endpoint. -- GPU mode requires NVIDIA Docker support and is best on NVIDIA hardware. -- CPU mode works best on Apple Silicon or modern x86 CPUs. diff --git a/docs-site/docs/integrations/openai.md b/docs-site/docs/integrations/openai.md deleted file mode 100644 index 5636935..0000000 --- a/docs-site/docs/integrations/openai.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: OpenAI ---- - -Use OpenAI directly as an OpenAI-compatible TTS provider. - -## OpenReader setup - -1. In OpenReader settings, choose provider `OpenAI`. -2. Leave `API_BASE` unset unless you want to override the default `https://api.openai.com/v1`. -3. Set `API_KEY` to your OpenAI API key. -4. Choose model and voice. - -## Notes - -- `OpenAI` is a built-in provider in the dropdown, so `API_BASE` is usually not required. -- OpenReader routes TTS calls through its server API. -- For variable details, see [Environment Variables](../reference/environment-variables). diff --git a/docs-site/docs/integrations/orpheus-fastapi.md b/docs-site/docs/integrations/orpheus-fastapi.md deleted file mode 100644 index 76dc760..0000000 --- a/docs-site/docs/integrations/orpheus-fastapi.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -title: Orpheus-FastAPI ---- - -Use Orpheus-FastAPI as an OpenAI-compatible TTS backend for OpenReader. - -## Upstream project - -- [Lex-au/Orpheus-FastAPI](https://github.com/Lex-au/Orpheus-FastAPI) - -## OpenReader setup - -1. Start your Orpheus-FastAPI server. -2. In OpenReader settings, choose provider `Custom OpenAI-Like` and model `Orpheus`. -3. Set OpenReader `API_BASE` to your Orpheus base URL (typically ending with `/v1`). -4. Set `API_KEY` if your Orpheus deployment requires one. -5. Choose voice. - -## Notes - -- `API_BASE` is needed here because Orpheus is configured through the custom provider path. -- OpenReader expects OpenAI-compatible audio endpoints. -- For variable details, see [Environment Variables](../reference/environment-variables). diff --git a/docs-site/docs/introduction.md b/docs-site/docs/introduction.md index c608431..46c4c32 100644 --- a/docs-site/docs/introduction.md +++ b/docs-site/docs/introduction.md @@ -31,15 +31,16 @@ It supports multiple TTS providers including OpenAI, DeepInfra, and custom OpenA - 🎨 **Customizable Experience** - Theme, TTS, and document handling controls -## 🚀 Start Here +## 🧭 Key Docs -- [Docker Quick Start](./start-here/docker-quick-start) -- [Vercel Deployment](./start-here/vercel-deployment) -- [Local Development](./start-here/local-development) +- [Docker Quick Start](./docker-quick-start) +- [Local Development](./deploy/local-development) +- [Vercel Deployment](./deploy/vercel-deployment) - [Environment Variables](./reference/environment-variables) - [Auth](./configure/auth) -- [Database and Migrations](./configure/database-and-migrations) +- [Database](./configure/database) - [Object / Blob Storage](./configure/object-blob-storage) +- [Migrations](./configure/migrations) - [Server Library Import](./configure/server-library-import) - [TTS Providers](./configure/tts-providers) diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index 0292cbb..85fa157 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -9,9 +9,9 @@ This is the single reference page for OpenReader WebUI environment variables. | Variable | Area | Default | When to set | | --- | --- | --- | --- | -| `NEXT_PUBLIC_NODE_ENV` | Runtime mode | `development` | Set `production` for production builds | -| `NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT` | Client feature flags | `true` unless set to `false` | Disable audiobook export UI in any environment | -| `NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT` | Client feature flags | `true` in dev, `false` in production | Force-enable word highlight UI in production | +| `NEXT_PUBLIC_NODE_ENV` | Runtime mode | treated as `development` unless `production` | Set `production` for production client behavior | +| `NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT` | Client feature flags | `true` unless set to `false` | Set `false` to hide audiobook export UI | +| `NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT` | Client feature flags | `false` unless set to `true` | Set `true` to enable word highlight + alignment | | `API_BASE` | TTS provider | none | Point to your OpenAI-compatible TTS base URL | | `API_KEY` | TTS provider | `none` fallback in TTS route | Set when provider requires auth | | `TTS_CACHE_MAX_SIZE_BYTES` | TTS caching | `268435456` (256 MB) | Tune in-memory TTS cache size | @@ -52,12 +52,23 @@ This is the single reference page for OpenReader WebUI environment variables. ## Detailed Reference +## Client Runtime and Feature Flags + ### NEXT_PUBLIC_NODE_ENV Controls development vs production behavior in client/server code paths. - Typical values: `development`, `production` -- In production builds, set `production` +- OpenReader `isDev` checks rely on this variable directly +- If this is not `production`, OpenReader treats the client as development mode +- In deployed environments, set `NEXT_PUBLIC_NODE_ENV=production` explicitly for predictable production behavior +- Affects: + - Footer visibility in the app shell + - DOCX upload/conversion availability in upload UI + - Default provider/model behavior for first-run TTS config + - DeepInfra model picker restrictions when no API key is set + - Privacy modal wording (hosted-service vs local/dev wording) + - Dev-only destructive document actions in settings ### NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT @@ -65,14 +76,20 @@ Controls whether audiobook export UI/actions are shown in the client. - Default behavior: enabled unless explicitly set to `false` - Applies in both development and production +- Affects export entry points in PDF/EPUB pages and document settings UI ### NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT -Controls word-by-word highlighting UI in production builds. +Controls word-by-word highlighting UI and timestamp-alignment behavior. -- Development default: enabled -- Production default: disabled unless set to `true` +- Default behavior: disabled unless set to `true` +- Applies in both development and production - Requires working timestamp generation (for example `WHISPER_CPP_BIN`) +- Affects: + - Word-highlight toggles in document settings + - Alignment requests during TTS playback + +## TTS Provider and Request Behavior ### API_BASE @@ -80,6 +97,7 @@ Base URL for OpenAI-compatible TTS API requests. - Example: `http://host.docker.internal:8880/v1` - Can be overridden per request from UI settings +- Related docs: [TTS Providers](../configure/tts-providers) ### API_KEY @@ -87,6 +105,7 @@ Default API key for TTS provider requests. - Example: `none` or your provider token - Can be overridden by request headers from app settings +- Related docs: [TTS Providers](../configure/tts-providers) ### TTS_CACHE_MAX_SIZE_BYTES @@ -156,12 +175,15 @@ Authenticated IP backstop daily character limit. - Default: `1000000` +## Auth and Identity + ### BASE_URL External base URL for this OpenReader instance. - Required with `AUTH_SECRET` to enable auth - Example: `http://localhost:3003` or `https://reader.example.com` +- Related docs: [Auth](../configure/auth) ### AUTH_SECRET @@ -169,6 +191,7 @@ Secret key used by auth/session handling. - Required with `BASE_URL` to enable auth - Generate with `openssl rand -base64 32` +- Related docs: [Auth](../configure/auth) ### AUTH_TRUSTED_ORIGINS @@ -176,6 +199,7 @@ Additional allowed origins for auth requests. - Comma-separated list - `BASE_URL` origin is always trusted automatically +- Related docs: [Auth](../configure/auth) ### GITHUB_CLIENT_ID @@ -196,6 +220,9 @@ Controls Better Auth rate limiting. - Default behavior: auth-layer rate limiting enabled - Set to `true` to disable auth-layer rate limiting - This does not affect TTS character rate limiting +- Related docs: [Auth](../configure/auth) + +## Database and Object Blob Storage ### POSTGRES_URL @@ -203,6 +230,7 @@ Switches metadata/auth storage from SQLite to Postgres. - Unset: SQLite at `docstore/sqlite3.db` - Set: Postgres mode +- Related docs: [Database](../configure/database) ### USE_EMBEDDED_WEED_MINI @@ -210,18 +238,21 @@ Controls embedded SeaweedFS startup. - Default behavior: treated as enabled when unset - Set `false` to rely on external S3-compatible storage +- Related docs: [Object / Blob Storage](../configure/object-blob-storage) ### WEED_MINI_DIR Data directory for embedded SeaweedFS (`weed mini`). - Default: `docstore/seaweedfs` +- Related docs: [Object / Blob Storage](../configure/object-blob-storage) ### WEED_MINI_WAIT_SEC Maximum seconds to wait for embedded SeaweedFS startup. - Default: `20` +- Related docs: [Object / Blob Storage](../configure/object-blob-storage) ### S3_ACCESS_KEY_ID @@ -229,6 +260,7 @@ Access key for S3-compatible storage. - Auto-generated in embedded mode if unset - Set explicitly for stable credentials or external providers +- Related docs: [Object / Blob Storage](../configure/object-blob-storage) ### S3_SECRET_ACCESS_KEY @@ -236,6 +268,7 @@ Secret key for S3-compatible storage. - Auto-generated in embedded mode if unset - Set explicitly for stable credentials or external providers +- Related docs: [Object / Blob Storage](../configure/object-blob-storage) ### S3_BUCKET @@ -243,12 +276,14 @@ Bucket name used for document blobs. - Default in embedded mode: `openreader-documents` - Required for external S3-compatible storage +- Related docs: [Object / Blob Storage](../configure/object-blob-storage) ### S3_REGION Region used by the S3 client. - Default in embedded mode: `us-east-1` +- Related docs: [Object / Blob Storage](../configure/object-blob-storage) ### S3_ENDPOINT @@ -257,6 +292,7 @@ Endpoint URL for S3-compatible storage. - In embedded mode, defaults to `http://:8333` (or detected host) - For AWS S3, usually leave unset - For MinIO/SeaweedFS/R2/B2-style APIs, typically set explicitly +- Related docs: [Object / Blob Storage](../configure/object-blob-storage) ### S3_FORCE_PATH_STYLE @@ -264,12 +300,16 @@ Path-style S3 addressing toggle. - Default in embedded mode: `true` - Set according to provider requirements +- Related docs: [Object / Blob Storage](../configure/object-blob-storage) ### S3_PREFIX Prefix prepended to stored object keys. - Default: `openreader` +- Related docs: [Object / Blob Storage](../configure/object-blob-storage) + +## Migration Controls ### RUN_DRIZZLE_MIGRATIONS @@ -277,6 +317,7 @@ Controls startup migration execution in shared entrypoint. - Default: `true` - Set `false` to skip automatic startup Drizzle schema migrations +- Related docs: [Migrations](../configure/migrations), [Database](../configure/database) ### RUN_FS_MIGRATIONS @@ -285,6 +326,9 @@ Controls startup filesystem-to-object-store migration execution in shared entryp - Default: `true` - Runs `scripts/migrate-fs-v2.mjs` at startup after DB migrations - Set `false` to skip automatic storage migration pass +- Related docs: [Migrations](../configure/migrations), [Database](../configure/database), [Object / Blob Storage](../configure/object-blob-storage) + +## Library Import ### IMPORT_LIBRARY_DIR @@ -292,6 +336,7 @@ Single directory root for server library import. - Used when `IMPORT_LIBRARY_DIRS` is unset - Default fallback root: `docstore/library` +- Related docs: [Server Library Import](../configure/server-library-import) ### IMPORT_LIBRARY_DIRS @@ -299,6 +344,9 @@ Multiple library roots for server library import. - Separator: comma, colon, or semicolon - Takes precedence over `IMPORT_LIBRARY_DIR` +- Related docs: [Server Library Import](../configure/server-library-import) + +## Audio Tooling and Alignment ### WHISPER_CPP_BIN diff --git a/docs-site/docusaurus.config.ts b/docs-site/docusaurus.config.ts index 4abad5d..672331e 100644 --- a/docs-site/docusaurus.config.ts +++ b/docs-site/docusaurus.config.ts @@ -98,7 +98,7 @@ const config: Config = { { title: 'Community', items: [ - { label: 'Support', to: '/project/support-and-contributing' }, + { label: 'Support', to: '/about/support-and-contributing' }, { label: 'GitHub Discussions', href: 'https://github.com/richardr1126/OpenReader-WebUI/discussions' }, { label: 'Issues', href: 'https://github.com/richardr1126/OpenReader-WebUI/issues' }, ], diff --git a/docs-site/sidebars.ts b/docs-site/sidebars.ts index b400937..d15a6f9 100644 --- a/docs-site/sidebars.ts +++ b/docs-site/sidebars.ts @@ -3,10 +3,50 @@ import type { SidebarsConfig } from '@docusaurus/plugin-content-docs'; const sidebars: SidebarsConfig = { tutorialSidebar: [ 'intro', + { + type: 'doc', + id: 'docker-quick-start', + label: '🐳 Docker Quick Start', + }, { type: 'category', - label: 'Start Here', - items: ['start-here/docker-quick-start', 'start-here/vercel-deployment', 'start-here/local-development'], + label: '⚙️ Configure', + items: [ + { + type: 'category', + label: '🔊 TTS Providers', + link: { + type: 'doc', + id: 'configure/tts-providers', + }, + items: [ + 'configure/tts-provider-guides/kokoro-fastapi', + 'configure/tts-provider-guides/orpheus-fastapi', + 'configure/tts-provider-guides/deepinfra', + 'configure/tts-provider-guides/openai', + 'configure/tts-provider-guides/custom-openai', + ], + }, + { + type: 'doc', + id: 'configure/auth', + label: '🔐 Auth', + }, + { + type: 'doc', + id: 'configure/server-library-import', + label: '📥 Server Library Import', + }, + 'configure/tts-rate-limiting', + 'configure/database', + 'configure/object-blob-storage', + 'configure/migrations', + ], + }, + { + type: 'category', + label: '🚀 Deploy', + items: ['deploy/local-development', 'deploy/vercel-deployment'], }, { type: 'category', @@ -18,31 +58,8 @@ const sidebars: SidebarsConfig = { }, { type: 'category', - label: 'Configure', - items: [ - 'configure/tts-providers', - 'configure/auth', - 'configure/tts-rate-limiting', - 'configure/database-and-migrations', - 'configure/object-blob-storage', - 'configure/server-library-import', - ], - }, - { - type: 'category', - label: 'Integrations', - items: [ - 'integrations/kokoro-fastapi', - 'integrations/orpheus-fastapi', - 'integrations/deepinfra', - 'integrations/openai', - 'integrations/custom-openai', - ], - }, - { - type: 'category', - label: 'Project', - items: ['project/support-and-contributing', 'project/acknowledgements', 'project/license'], + label: 'About', + items: ['about/support-and-contributing', 'about/acknowledgements', 'about/license'], }, ], }; diff --git a/src/components/DocumentSettings.tsx b/src/components/DocumentSettings.tsx index 0662f18..a5cabec 100644 --- a/src/components/DocumentSettings.tsx +++ b/src/components/DocumentSettings.tsx @@ -11,9 +11,8 @@ import { useParams } from 'next/navigation'; import type { TTSAudiobookChapter } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; -const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; const canExportAudiobook = process.env.NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT !== 'false'; -const canWordHighlight = isDev || process.env.NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT === 'true'; +const canWordHighlight = process.env.NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT === 'true'; const viewTypeTextMapping = [ { id: 'single', name: 'Single Page' }, diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index cde2375..ba91692 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -99,6 +99,7 @@ interface SetTextOptions { const CONTINUATION_LOOKAHEAD = 600; const SENTENCE_ENDING = /[.?!…]["'”’)\]]*\s*$/; +const wordHighlightFeatureEnabled = process.env.NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT === 'true'; // Tiny silent WAV used to unlock HTML5 audio on iOS/Safari. const SILENT_WAV_DATA_URI = @@ -857,8 +858,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement */ const getAudio = useCallback(async (sentence: string, preload = false): Promise => { const alignmentEnabledForCurrentDoc = - (!isEPUB && pdfHighlightEnabled && pdfWordHighlightEnabled) || - (isEPUB && epubHighlightEnabled && epubWordHighlightEnabled); + wordHighlightFeatureEnabled && + ((!isEPUB && pdfHighlightEnabled && pdfWordHighlightEnabled) || + (isEPUB && epubHighlightEnabled && epubWordHighlightEnabled)); // Helper to ensure we have an alignment for a given // sentence/audio pair, even when the audio itself is // served from the local cache. diff --git a/src/types/config.ts b/src/types/config.ts index 8e07e06..428d97c 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1,6 +1,7 @@ import type { DocumentListState } from '@/types/documents'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; +const wordHighlightEnabledByDefault = process.env.NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT === 'true'; export type ViewType = 'single' | 'dual' | 'scroll'; @@ -53,9 +54,9 @@ export const APP_CONFIG_DEFAULTS: AppConfigValues = { savedVoices: {}, smartSentenceSplitting: true, pdfHighlightEnabled: true, - pdfWordHighlightEnabled: isDev, + pdfWordHighlightEnabled: wordHighlightEnabledByDefault, epubHighlightEnabled: true, - epubWordHighlightEnabled: isDev, + epubWordHighlightEnabled: wordHighlightEnabledByDefault, firstVisit: false, documentListState: { sortBy: 'name', From 82f82a990e6174ea8329b33e0fcabf5586ec50ab Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 11 Feb 2026 13:58:16 -0700 Subject: [PATCH 23/55] feat: add user preferences and document progress syncing - Implemented user preferences management with a new API for GET and PUT requests. - Added user document progress tracking with a new API for retrieving and updating progress. - Introduced database schema changes for user preferences and document progress. - Enhanced EPUB and TTS contexts to support syncing user preferences and document progress. - Added functions to handle transferring user preferences and progress during account linking. - Updated client-side logic to schedule syncing of user preferences and document progress. --- docs-site/docs/configure/auth.md | 17 ++ docs-site/docs/configure/database.md | 10 + docs-site/docs/reference/stack.md | 1 + drizzle/postgres/0001_user_state_minimal.sql | 21 ++ drizzle/postgres/meta/_journal.json | 9 +- drizzle/sqlite/0001_user_state_minimal.sql | 21 ++ drizzle/sqlite/meta/_journal.json | 9 +- src/app/api/user/claim/route.ts | 16 +- src/app/api/user/state/preferences/route.ts | 195 +++++++++++++++++++ src/app/api/user/state/progress/route.ts | 188 ++++++++++++++++++ src/components/auth/ClaimDataModal.tsx | 60 +++++- src/contexts/ConfigContext.tsx | 125 +++++++++++- src/contexts/DocumentContext.tsx | 6 +- src/contexts/EPUBContext.tsx | 12 +- src/contexts/TTSContext.tsx | 120 +++++++++--- src/db/schema.ts | 2 + src/db/schema_postgres.ts | 24 ++- src/db/schema_sqlite.ts | 22 +++ src/lib/client-user-state.ts | 181 +++++++++++++++++ src/lib/server/auth.ts | 29 ++- src/lib/server/claim-data.ts | 122 +++++++++++- src/lib/server/user-state-scope.ts | 30 +++ src/types/user-state.ts | 39 ++++ 23 files changed, 1209 insertions(+), 50 deletions(-) create mode 100644 drizzle/postgres/0001_user_state_minimal.sql create mode 100644 drizzle/sqlite/0001_user_state_minimal.sql create mode 100644 src/app/api/user/state/preferences/route.ts create mode 100644 src/app/api/user/state/progress/route.ts create mode 100644 src/lib/client-user-state.ts create mode 100644 src/lib/server/user-state-scope.ts create mode 100644 src/types/user-state.ts diff --git a/docs-site/docs/configure/auth.md b/docs-site/docs/configure/auth.md index 0214f21..fff001b 100644 --- a/docs-site/docs/configure/auth.md +++ b/docs-site/docs/configure/auth.md @@ -18,3 +18,20 @@ This page covers application-level configuration for provider access and authent - For storage/S3/SeaweedFS behavior: [Object / Blob Storage](./object-blob-storage) - For database mode: [Database](./database) - For migration behavior and commands: [Migrations](./migrations) + +## Sync notes + +### Auth enabled + +- Settings and reading progress are saved to the server. +- Updates are not instant push-based sync; they use normal client polling/refresh behavior. +- If two devices change the same item around the same time, the newest update wins. + +### Auth disabled + +- Settings and reading progress stay local in the browser (Dexie/IndexedDB). +- This avoids no-auth cross-browser conflicts, but there is no cross-device sync. + +## Claim modal note + +- You may still see old anonymous settings/progress available to claim from older deployments. diff --git a/docs-site/docs/configure/database.md b/docs-site/docs/configure/database.md index 6663c8e..c3140be 100644 --- a/docs-site/docs/configure/database.md +++ b/docs-site/docs/configure/database.md @@ -13,6 +13,9 @@ This page covers database mode selection for OpenReader WebUI. - Document and audiobook metadata/state used by server routes. - Auth/session tables when auth is enabled. +- TTS character usage counters (`user_tts_chars`) for daily rate limiting (when enabled). +- User settings preferences (`user_preferences`) when auth is enabled. +- User reading progress (`user_document_progress`) when auth is enabled. ## Related variables @@ -24,3 +27,10 @@ For database variable behavior, see [Environment Variables](../reference/environ - [Migrations](./migrations) - [Object / Blob Storage](./object-blob-storage) +- [Auth](./auth) + +## State sync summary + +- With auth enabled, settings and reading progress are stored in SQL and synced from the app. +- With auth disabled, settings and reading progress remain local in the browser. +- Sync is currently request-based (not realtime push invalidation). diff --git a/docs-site/docs/reference/stack.md b/docs-site/docs/reference/stack.md index 757591d..136b170 100644 --- a/docs-site/docs/reference/stack.md +++ b/docs-site/docs/reference/stack.md @@ -28,6 +28,7 @@ title: Stack ## Next.js server - APIs: Route Handlers for sync, blob/content access, migrations, audiobook export, TTS/Whisper proxying +- State sync: request-based today (not realtime push updates) - Authentication: [Better Auth](https://www.better-auth.com/) server handlers/adapters - Metadata DB: [Drizzle ORM](https://orm.drizzle.team/) with SQLite (`better-sqlite3`) by default and optional Postgres (`pg`) - Blob storage: embedded [SeaweedFS](https://github.com/seaweedfs/seaweedfs) (`weed mini`) by default, or external S3-compatible storage via AWS SDK v3 diff --git a/drizzle/postgres/0001_user_state_minimal.sql b/drizzle/postgres/0001_user_state_minimal.sql new file mode 100644 index 0000000..0f8ef23 --- /dev/null +++ b/drizzle/postgres/0001_user_state_minimal.sql @@ -0,0 +1,21 @@ +CREATE TABLE "user_document_progress" ( + "user_id" text NOT NULL, + "document_id" text NOT NULL, + "reader_type" text NOT NULL, + "location" text NOT NULL, + "progress" real, + "client_updated_at_ms" bigint DEFAULT 0 NOT NULL, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now(), + CONSTRAINT "user_document_progress_user_id_document_id_pk" PRIMARY KEY("user_id","document_id") +); +--> statement-breakpoint +CREATE TABLE "user_preferences" ( + "user_id" text PRIMARY KEY NOT NULL, + "data_json" jsonb DEFAULT '{}'::jsonb NOT NULL, + "client_updated_at_ms" bigint DEFAULT 0 NOT NULL, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() +); +--> statement-breakpoint +CREATE INDEX "idx_user_document_progress_user_id_updated_at" ON "user_document_progress" USING btree ("user_id","updated_at"); diff --git a/drizzle/postgres/meta/_journal.json b/drizzle/postgres/meta/_journal.json index c0cfceb..9fea0ea 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1770191341206, "tag": "0000_perfect_risque", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1770843600000, + "tag": "0001_user_state_minimal", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/drizzle/sqlite/0001_user_state_minimal.sql b/drizzle/sqlite/0001_user_state_minimal.sql new file mode 100644 index 0000000..24514c4 --- /dev/null +++ b/drizzle/sqlite/0001_user_state_minimal.sql @@ -0,0 +1,21 @@ +CREATE TABLE `user_document_progress` ( + `user_id` text NOT NULL, + `document_id` text NOT NULL, + `reader_type` text NOT NULL, + `location` text NOT NULL, + `progress` real, + `client_updated_at_ms` integer DEFAULT 0 NOT NULL, + `created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000), + `updated_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000), + PRIMARY KEY(`user_id`, `document_id`) +); +--> statement-breakpoint +CREATE TABLE `user_preferences` ( + `user_id` text PRIMARY KEY NOT NULL, + `data_json` text DEFAULT '{}' NOT NULL, + `client_updated_at_ms` integer DEFAULT 0 NOT NULL, + `created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000), + `updated_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000) +); +--> statement-breakpoint +CREATE INDEX `idx_user_document_progress_user_id_updated_at` ON `user_document_progress` (`user_id`,`updated_at`); diff --git a/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json index 4cf4829..cc48afa 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1770191340954, "tag": "0000_lively_korvac", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1770843600000, + "tag": "0001_user_state_minimal", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/app/api/user/claim/route.ts b/src/app/api/user/claim/route.ts index c537cd6..9acb8db 100644 --- a/src/app/api/user/claim/route.ts +++ b/src/app/api/user/claim/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { claimAnonymousData } from '@/lib/server/claim-data'; import { auth } from '@/lib/server/auth'; import { db } from '@/db'; -import { audiobooks, documents } from '@/db/schema'; +import { audiobooks, documents, userDocumentProgress, userPreferences } from '@/db/schema'; import { count, eq, ne } from 'drizzle-orm'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; @@ -22,7 +22,9 @@ async function checkClaimMigrationReadiness(): Promise { return null; } -async function getClaimableCounts(unclaimedUserId: string): Promise<{ documents: number; audiobooks: number }> { +async function getClaimableCounts( + unclaimedUserId: string, +): Promise<{ documents: number; audiobooks: number; preferences: number; progress: number }> { const [docCount] = await db .select({ count: count() }) .from(documents) @@ -31,10 +33,20 @@ async function getClaimableCounts(unclaimedUserId: string): Promise<{ documents: .select({ count: count() }) .from(audiobooks) .where(eq(audiobooks.userId, unclaimedUserId)); + const [preferencesCount] = await db + .select({ count: count() }) + .from(userPreferences) + .where(eq(userPreferences.userId, unclaimedUserId)); + const [progressCount] = await db + .select({ count: count() }) + .from(userDocumentProgress) + .where(eq(userDocumentProgress.userId, unclaimedUserId)); return { documents: Number(docCount?.count ?? 0), audiobooks: Number(bookCount?.count ?? 0), + preferences: Number(preferencesCount?.count ?? 0), + progress: Number(progressCount?.count ?? 0), }; } diff --git a/src/app/api/user/state/preferences/route.ts b/src/app/api/user/state/preferences/route.ts new file mode 100644 index 0000000..8ed8f3c --- /dev/null +++ b/src/app/api/user/state/preferences/route.ts @@ -0,0 +1,195 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { eq } from 'drizzle-orm'; +import { db } from '@/db'; +import { userPreferences } from '@/db/schema'; +import { SYNCED_PREFERENCE_KEYS, type SyncedPreferencesPatch } from '@/types/user-state'; +import { resolveUserStateScope } from '@/lib/server/user-state-scope'; + +export const dynamic = 'force-dynamic'; + +function nowForDb(): Date | number { + return process.env.POSTGRES_URL ? new Date() : Date.now(); +} + +function serializePreferencesForDb(patch: SyncedPreferencesPatch): SyncedPreferencesPatch | string { + if (process.env.POSTGRES_URL) return patch; + return JSON.stringify(patch); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function parseStoredPreferences(value: unknown): SyncedPreferencesPatch { + if (!value) return {}; + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value); + return isRecord(parsed) ? sanitizePreferencesPatch(parsed) : {}; + } catch { + return {}; + } + } + return isRecord(value) ? sanitizePreferencesPatch(value) : {}; +} + +function sanitizeSavedVoices(value: unknown): Record { + if (!isRecord(value)) return {}; + const out: Record = {}; + for (const [key, val] of Object.entries(value)) { + if (typeof key !== 'string' || key.length === 0) continue; + if (typeof val !== 'string') continue; + out[key] = val; + } + return out; +} + +function sanitizePreferencesPatch(input: unknown): SyncedPreferencesPatch { + if (!isRecord(input)) return {}; + + const out: SyncedPreferencesPatch = {}; + + for (const key of SYNCED_PREFERENCE_KEYS) { + if (!(key in input)) continue; + const value = input[key]; + + switch (key) { + case 'viewType': + if (value === 'single' || value === 'dual' || value === 'scroll') out[key] = value; + break; + case 'voice': + case 'ttsProvider': + case 'ttsModel': + case 'ttsInstructions': + if (typeof value === 'string') out[key] = value; + break; + case 'voiceSpeed': + case 'audioPlayerSpeed': + case 'headerMargin': + case 'footerMargin': + case 'leftMargin': + case 'rightMargin': + if (Number.isFinite(value)) out[key] = Number(value); + break; + case 'skipBlank': + case 'epubTheme': + case 'smartSentenceSplitting': + case 'pdfHighlightEnabled': + case 'pdfWordHighlightEnabled': + case 'epubHighlightEnabled': + case 'epubWordHighlightEnabled': + if (typeof value === 'boolean') out[key] = value; + break; + case 'savedVoices': + out[key] = sanitizeSavedVoices(value); + break; + default: + break; + } + } + + return out; +} + +function normalizeClientUpdatedAtMs(value: unknown): number { + if (!Number.isFinite(value)) return Date.now(); + const normalized = Number(value); + if (normalized <= 0) return Date.now(); + return Math.floor(normalized); +} + +export async function GET(req: NextRequest) { + try { + const scope = await resolveUserStateScope(req); + if (scope instanceof Response) return scope; + + const rows = await db + .select({ + dataJson: userPreferences.dataJson, + clientUpdatedAtMs: userPreferences.clientUpdatedAtMs, + }) + .from(userPreferences) + .where(eq(userPreferences.userId, scope.ownerUserId)) + .limit(1); + + const row = rows[0]; + const storedPatch = parseStoredPreferences(row?.dataJson); + const clientUpdatedAtMs = Number(row?.clientUpdatedAtMs ?? 0); + + return NextResponse.json({ + preferences: storedPatch, + clientUpdatedAtMs, + hasStoredPreferences: Boolean(row), + }); + } catch (error) { + console.error('Error loading user preferences:', error); + return NextResponse.json({ error: 'Failed to load user preferences' }, { status: 500 }); + } +} + +export async function PUT(req: NextRequest) { + try { + const scope = await resolveUserStateScope(req); + if (scope instanceof Response) return scope; + + const body = (await req.json().catch(() => null)) as + | { patch?: unknown; clientUpdatedAtMs?: unknown } + | null; + const patch = sanitizePreferencesPatch(body?.patch); + const clientUpdatedAtMs = normalizeClientUpdatedAtMs(body?.clientUpdatedAtMs); + + if (Object.keys(patch).length === 0) { + return NextResponse.json({ error: 'No valid preferences provided' }, { status: 400 }); + } + + const existingRows = await db + .select({ + dataJson: userPreferences.dataJson, + clientUpdatedAtMs: userPreferences.clientUpdatedAtMs, + }) + .from(userPreferences) + .where(eq(userPreferences.userId, scope.ownerUserId)) + .limit(1); + const existing = existingRows[0]; + const existingUpdated = Number(existing?.clientUpdatedAtMs ?? 0); + const existingPatch = parseStoredPreferences(existing?.dataJson); + + if (existing && clientUpdatedAtMs < existingUpdated) { + return NextResponse.json({ + preferences: existingPatch, + clientUpdatedAtMs: existingUpdated, + applied: false, + }); + } + + const mergedPatch = { ...existingPatch, ...patch }; + const dataJson = serializePreferencesForDb(mergedPatch); + const updatedAt = nowForDb(); + + await db + .insert(userPreferences) + .values({ + userId: scope.ownerUserId, + dataJson, + clientUpdatedAtMs, + updatedAt, + }) + .onConflictDoUpdate({ + target: [userPreferences.userId], + set: { + dataJson, + clientUpdatedAtMs, + updatedAt, + }, + }); + + return NextResponse.json({ + preferences: mergedPatch, + clientUpdatedAtMs, + applied: true, + }); + } catch (error) { + console.error('Error updating user preferences:', error); + return NextResponse.json({ error: 'Failed to update user preferences' }, { status: 500 }); + } +} diff --git a/src/app/api/user/state/progress/route.ts b/src/app/api/user/state/progress/route.ts new file mode 100644 index 0000000..99666d3 --- /dev/null +++ b/src/app/api/user/state/progress/route.ts @@ -0,0 +1,188 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { and, eq } from 'drizzle-orm'; +import { db } from '@/db'; +import { userDocumentProgress } from '@/db/schema'; +import type { ReaderType } from '@/types/user-state'; +import { isValidDocumentId } from '@/lib/server/documents-blobstore'; +import { resolveUserStateScope } from '@/lib/server/user-state-scope'; + +export const dynamic = 'force-dynamic'; + +function nowForDb(): Date | number { + return process.env.POSTGRES_URL ? new Date() : Date.now(); +} + +function normalizeReaderType(value: unknown): ReaderType | null { + if (value === 'pdf' || value === 'epub' || value === 'html') return value; + return null; +} + +function normalizeClientUpdatedAtMs(value: unknown): number { + if (!Number.isFinite(value)) return Date.now(); + const normalized = Number(value); + if (normalized <= 0) return Date.now(); + return Math.floor(normalized); +} + +function toUpdatedAtMs(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (value instanceof Date) return value.getTime(); + return Date.now(); +} + +export async function GET(req: NextRequest) { + try { + const scope = await resolveUserStateScope(req); + if (scope instanceof Response) return scope; + + const documentId = (new URL(req.url).searchParams.get('documentId') || '').trim().toLowerCase(); + if (!isValidDocumentId(documentId)) { + return NextResponse.json({ error: 'Invalid documentId' }, { status: 400 }); + } + + const rows = await db + .select({ + documentId: userDocumentProgress.documentId, + readerType: userDocumentProgress.readerType, + location: userDocumentProgress.location, + progress: userDocumentProgress.progress, + clientUpdatedAtMs: userDocumentProgress.clientUpdatedAtMs, + updatedAt: userDocumentProgress.updatedAt, + }) + .from(userDocumentProgress) + .where(and( + eq(userDocumentProgress.userId, scope.ownerUserId), + eq(userDocumentProgress.documentId, documentId), + )) + .limit(1); + + const row = rows[0]; + if (!row) { + return NextResponse.json({ progress: null }); + } + + return NextResponse.json({ + progress: { + documentId: row.documentId, + readerType: row.readerType, + location: row.location, + progress: row.progress == null ? null : Number(row.progress), + clientUpdatedAtMs: Number(row.clientUpdatedAtMs ?? 0), + updatedAtMs: toUpdatedAtMs(row.updatedAt), + }, + }); + } catch (error) { + console.error('Error loading user progress:', error); + return NextResponse.json({ error: 'Failed to load user progress' }, { status: 500 }); + } +} + +export async function PUT(req: NextRequest) { + try { + const scope = await resolveUserStateScope(req); + if (scope instanceof Response) return scope; + + const body = (await req.json().catch(() => null)) as + | { + documentId?: unknown; + readerType?: unknown; + location?: unknown; + progress?: unknown; + clientUpdatedAtMs?: unknown; + } + | null; + + const documentId = typeof body?.documentId === 'string' ? body.documentId.trim().toLowerCase() : ''; + if (!isValidDocumentId(documentId)) { + return NextResponse.json({ error: 'Invalid documentId' }, { status: 400 }); + } + + const readerType = normalizeReaderType(body?.readerType); + if (!readerType) { + return NextResponse.json({ error: "Invalid readerType. Expected 'pdf', 'epub', or 'html'." }, { status: 400 }); + } + + const location = typeof body?.location === 'string' ? body.location.trim() : ''; + if (!location) { + return NextResponse.json({ error: 'Invalid location' }, { status: 400 }); + } + + const progress = + body?.progress == null + ? null + : Number.isFinite(body.progress) + ? Math.max(0, Math.min(1, Number(body.progress))) + : null; + const clientUpdatedAtMs = normalizeClientUpdatedAtMs(body?.clientUpdatedAtMs); + + const existingRows = await db + .select({ + clientUpdatedAtMs: userDocumentProgress.clientUpdatedAtMs, + location: userDocumentProgress.location, + readerType: userDocumentProgress.readerType, + progress: userDocumentProgress.progress, + updatedAt: userDocumentProgress.updatedAt, + }) + .from(userDocumentProgress) + .where(and( + eq(userDocumentProgress.userId, scope.ownerUserId), + eq(userDocumentProgress.documentId, documentId), + )) + .limit(1); + const existing = existingRows[0]; + const existingUpdated = Number(existing?.clientUpdatedAtMs ?? 0); + + if (existing && clientUpdatedAtMs < existingUpdated) { + return NextResponse.json({ + progress: { + documentId, + readerType: existing.readerType, + location: existing.location, + progress: existing.progress == null ? null : Number(existing.progress), + clientUpdatedAtMs: existingUpdated, + updatedAtMs: toUpdatedAtMs(existing.updatedAt), + }, + applied: false, + }); + } + + const updatedAt = nowForDb(); + await db + .insert(userDocumentProgress) + .values({ + userId: scope.ownerUserId, + documentId, + readerType, + location, + progress, + clientUpdatedAtMs, + updatedAt, + }) + .onConflictDoUpdate({ + target: [userDocumentProgress.userId, userDocumentProgress.documentId], + set: { + readerType, + location, + progress, + clientUpdatedAtMs, + updatedAt, + }, + }); + + return NextResponse.json({ + progress: { + documentId, + readerType, + location, + progress, + clientUpdatedAtMs, + updatedAtMs: toUpdatedAtMs(updatedAt), + }, + applied: true, + }); + } catch (error) { + console.error('Error updating user progress:', error); + return NextResponse.json({ error: 'Failed to update user progress' }, { status: 500 }); + } +} + diff --git a/src/components/auth/ClaimDataModal.tsx b/src/components/auth/ClaimDataModal.tsx index 84fa66e..1ffbce7 100644 --- a/src/components/auth/ClaimDataModal.tsx +++ b/src/components/auth/ClaimDataModal.tsx @@ -11,6 +11,24 @@ import { } from '@headlessui/react'; import { useAuthSession } from '@/hooks/useAuthSession'; import { useRouter } from 'next/navigation'; +import toast from 'react-hot-toast'; + +type ClaimableCounts = { + documents: number; + audiobooks: number; + preferences: number; + progress: number; +}; + +function toClaimableCounts(value: unknown): ClaimableCounts { + const rec = (value && typeof value === 'object') ? (value as Record) : {}; + return { + documents: Number(rec.documents ?? 0), + audiobooks: Number(rec.audiobooks ?? 0), + preferences: Number(rec.preferences ?? 0), + progress: Number(rec.progress ?? 0), + }; +} export default function ClaimDataModal() { const { data: sessionData } = useAuthSession(); @@ -18,6 +36,12 @@ export default function ClaimDataModal() { const [isOpen, setIsOpen] = useState(false); const [hasChecked, setHasChecked] = useState(false); const [isClaiming, setIsClaiming] = useState(false); + const [claimableCounts, setClaimableCounts] = useState({ + documents: 0, + audiobooks: 0, + preferences: 0, + progress: 0, + }); const user = sessionData?.user; const checkClaimableData = useCallback(async () => { @@ -29,7 +53,10 @@ export default function ClaimDataModal() { }); if (res.ok) { const data = await res.json(); - if (data.documents > 0 || data.audiobooks > 0) { + const counts = toClaimableCounts(data); + setClaimableCounts(counts); + + if (counts.documents + counts.audiobooks + counts.preferences + counts.progress > 0) { setIsOpen(true); } } @@ -53,13 +80,22 @@ export default function ClaimDataModal() { }); if (res.ok) { const data = await res.json(); - alert(`Successfully claimed ${data.claimed.documents} documents and ${data.claimed.audiobooks} audiobooks!`); + const claimed = toClaimableCounts(data?.claimed); + toast.success( + `Successfully claimed ${claimed.documents} documents, ` + + `${claimed.audiobooks} audiobooks, ` + + `${claimed.preferences} preference set(s), and ` + + `${claimed.progress} reading progress record(s)!`, + ); setIsOpen(false); router.refresh(); + return; } + const data = await res.json().catch(() => null) as { error?: string } | null; + toast.error(data?.error || 'Failed to claim data.'); } catch { - alert('Failed to claim data.'); + toast.error('Failed to claim data.'); } finally { setIsClaiming(false); } @@ -106,12 +142,22 @@ export default function ClaimDataModal() {

- We found documents and audiobooks that were created before auth was enabled. - Would you like to claim them and add them to your account? + We found existing anonymous data from before auth was enabled. + Claim it now to attach it to your account.

+
+
Claimable data
+
    +
  • {claimableCounts.documents} document(s)
  • +
  • {claimableCounts.audiobooks} audiobook(s)
  • +
  • {claimableCounts.preferences} preference set(s)
  • +
  • {claimableCounts.progress} reading progress record(s)
  • +
+
+

- ⚠️ First user to claim these files will own them and revoke access for anyone else. + ⚠️ First user to claim this data will own it and revoke access for anyone else.

@@ -137,7 +183,7 @@ export default function ClaimDataModal() { transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background disabled:opacity-50" > - {isClaiming ? 'Claiming...' : 'Claim All'} + {isClaiming ? 'Claiming...' : 'Claim Data'}
diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index 74737b9..bb68f2b 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -1,9 +1,13 @@ 'use client'; -import { createContext, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react'; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react'; import { useLiveQuery } from 'dexie-react-hooks'; import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/dexie'; import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config'; +import { scheduleUserPreferencesSync, getUserPreferences, putUserPreferences } from '@/lib/client-user-state'; +import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state'; +import { useAuthSession } from '@/hooks/useAuthSession'; +import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import toast from 'react-hot-toast'; export type { ViewType } from '@/types/config'; @@ -50,10 +54,51 @@ export function ConfigProvider({ children }: { children: ReactNode }) { const [isLoading, setIsLoading] = useState(true); const [isDBReady, setIsDBReady] = useState(false); const didRunStartupMigrations = useRef(false); + const didAttemptInitialPreferenceSeedForSession = useRef(null); + const syncedPreferenceKeys = useMemo(() => new Set(SYNCED_PREFERENCE_KEYS), []); + const { authEnabled } = useAuthConfig(); + const { data: sessionData, isPending: isSessionPending } = useAuthSession(); + const sessionKey = sessionData?.user?.id ?? 'no-session'; // Helper function to generate provider-model key const getVoiceKey = (provider: string, model: string) => `${provider}:${model}`; + const queueSyncedPreferencePatch = useCallback((patch: Partial) => { + if (!authEnabled) return; + + const syncedPatch: SyncedPreferencesPatch = {}; + for (const key of SYNCED_PREFERENCE_KEYS) { + if (!(key in patch)) continue; + const value = patch[key]; + if (value === undefined) continue; + (syncedPatch as Record)[key] = value; + } + if (Object.keys(syncedPatch).length === 0) return; + scheduleUserPreferencesSync(syncedPatch); + }, [authEnabled]); + + const buildSyncedPreferencePatch = useCallback(( + source: Partial, + options?: { nonDefaultOnly?: boolean }, + ): SyncedPreferencesPatch => { + const out: SyncedPreferencesPatch = {}; + for (const key of SYNCED_PREFERENCE_KEYS) { + if (!(key in source)) continue; + const value = source[key]; + if (value === undefined) continue; + if (options?.nonDefaultOnly) { + const defaultValue = APP_CONFIG_DEFAULTS[key]; + const same = + typeof value === 'object' + ? JSON.stringify(value) === JSON.stringify(defaultValue) + : value === defaultValue; + if (same) continue; + } + (out as Record)[key] = value; + } + return out; + }, []); + useEffect(() => { const handler = (event: Event) => { const detail = (event as CustomEvent<{ status?: string; ms?: number }>).detail; @@ -107,6 +152,38 @@ export function ConfigProvider({ children }: { children: ReactNode }) { void run(); }, [isDBReady]); + const refreshSyncedPreferencesFromServer = useCallback(async () => { + if (!isDBReady || !authEnabled) return; + try { + const remote = await getUserPreferences(); + if (!remote?.hasStoredPreferences) return; + if (!remote.preferences || Object.keys(remote.preferences).length === 0) return; + await updateAppConfig(remote.preferences as Partial); + } catch (error) { + console.warn('Failed to load synced preferences:', error); + } + }, [isDBReady, authEnabled]); + + useEffect(() => { + if (!isDBReady || !authEnabled || isSessionPending) return; + refreshSyncedPreferencesFromServer().catch((error) => { + console.warn('Synced preferences refresh failed:', error); + }); + }, [isDBReady, authEnabled, isSessionPending, sessionKey, refreshSyncedPreferencesFromServer]); + + useEffect(() => { + if (!isDBReady || !authEnabled) return; + const onFocus = () => { + refreshSyncedPreferencesFromServer().catch((error) => { + console.warn('Focus synced preferences refresh failed:', error); + }); + }; + window.addEventListener('focus', onFocus); + return () => { + window.removeEventListener('focus', onFocus); + }; + }, [isDBReady, authEnabled, refreshSyncedPreferencesFromServer]); + const appConfig = useLiveQuery( async () => { if (!isDBReady) return null; @@ -124,6 +201,32 @@ export function ConfigProvider({ children }: { children: ReactNode }) { return { ...APP_CONFIG_DEFAULTS, ...rest }; }, [appConfig]); + useEffect(() => { + if (!isDBReady || !authEnabled || !appConfig || isSessionPending) return; + if (didAttemptInitialPreferenceSeedForSession.current === sessionKey) return; + didAttemptInitialPreferenceSeedForSession.current = sessionKey; + + const run = async () => { + try { + const remote = await getUserPreferences(); + if (remote?.hasStoredPreferences) return; + + // Seed only user-customized (non-default) values. This prevents fresh/default + // profiles from overwriting existing server values during first-run races. + const patch = buildSyncedPreferencePatch(appConfig, { nonDefaultOnly: true }); + if (Object.keys(patch).length === 0) return; + + await putUserPreferences(patch, { clientUpdatedAtMs: Date.now() }); + } catch (error) { + console.warn('Failed to seed initial synced preferences from local Dexie:', error); + } + }; + + run().catch((error) => { + console.warn('Initial synced preferences seed failed:', error); + }); + }, [isDBReady, authEnabled, appConfig, buildSyncedPreferencePatch, isSessionPending, sessionKey]); + // Destructure for convenience and to match context shape const { apiKey, @@ -163,7 +266,11 @@ export function ConfigProvider({ children }: { children: ReactNode }) { if (newConfig.baseUrl !== undefined) { updates.baseUrl = newConfig.baseUrl; } + if (newConfig.viewType !== undefined) { + updates.viewType = newConfig.viewType; + } await updateAppConfig(updates); + queueSyncedPreferencePatch(updates); } catch (error) { console.error('Error updating config:', error); throw error; @@ -189,6 +296,10 @@ export function ConfigProvider({ children }: { children: ReactNode }) { savedVoices: updatedSavedVoices, voice: value as string, }); + queueSyncedPreferencePatch({ + savedVoices: updatedSavedVoices, + voice: value as string, + }); } // Special handling for provider/model changes - restore saved voice if available else if (key === 'ttsProvider' || key === 'ttsModel') { @@ -200,17 +311,29 @@ export function ConfigProvider({ children }: { children: ReactNode }) { [key]: value as AppConfigValues[keyof AppConfigValues], voice: restoredVoice, } as Partial); + queueSyncedPreferencePatch({ + [key]: value as AppConfigValues[keyof AppConfigValues], + voice: restoredVoice, + } as Partial); } else if (key === 'savedVoices') { const newSavedVoices = value as SavedVoices; await updateAppConfig({ savedVoices: newSavedVoices, }); + queueSyncedPreferencePatch({ + savedVoices: newSavedVoices, + }); } else { await updateAppConfig({ [key]: value as AppConfigValues[keyof AppConfigValues], } as Partial); + if (syncedPreferenceKeys.has(String(key))) { + queueSyncedPreferencePatch({ + [key]: value, + } as Partial); + } } } catch (error) { console.error(`Error updating config key ${String(key)}:`, error); diff --git a/src/contexts/DocumentContext.tsx b/src/contexts/DocumentContext.tsx index d9f7390..e4e178e 100644 --- a/src/contexts/DocumentContext.tsx +++ b/src/contexts/DocumentContext.tsx @@ -4,6 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState, R import type { BaseDocument, DocumentType } from '@/types/documents'; import { listDocuments, uploadDocuments, deleteDocuments } from '@/lib/client-documents'; import { putCachedEpub, putCachedHtml, putCachedPdf, evictCachedEpub, evictCachedHtml, evictCachedPdf } from '@/lib/document-cache'; +import { useAuthSession } from '@/hooks/useAuthSession'; interface DocumentContextType { // PDF Documents @@ -36,6 +37,8 @@ const DocumentContext = createContext(undefined export function DocumentProvider({ children }: { children: ReactNode }) { const [docs, setDocs] = useState(null); const isLoading = docs === null; + const { data: sessionData, isPending: isSessionPending } = useAuthSession(); + const sessionKey = sessionData?.user?.id ?? 'no-session'; const refreshDocuments = useCallback(async () => { const serverDocs = await listDocuments(); @@ -44,11 +47,12 @@ export function DocumentProvider({ children }: { children: ReactNode }) { }, []); useEffect(() => { + if (isSessionPending) return; refreshDocuments().catch((err) => { console.error('Failed to load documents from server:', err); setDocs([]); }); - }, [refreshDocuments]); + }, [refreshDocuments, sessionKey, isSessionPending]); useEffect(() => { const handler = () => { diff --git a/src/contexts/EPUBContext.tsx b/src/contexts/EPUBContext.tsx index 9a0d6c0..6eb5dde 100644 --- a/src/contexts/EPUBContext.tsx +++ b/src/contexts/EPUBContext.tsx @@ -17,9 +17,11 @@ import type { SpineItem } from 'epubjs/types/section'; import type { Book, Rendition } from 'epubjs'; import { setLastDocumentLocation } from '@/lib/dexie'; +import { scheduleDocumentProgressSync } from '@/lib/client-user-state'; import { getDocumentMetadata } from '@/lib/client-documents'; import { ensureCachedDocument } from '@/lib/document-cache'; import { useTTS } from '@/contexts/TTSContext'; +import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { createRangeCfi } from '@/lib/epub'; import { useParams } from 'next/navigation'; import { useConfig } from './ConfigContext'; @@ -171,6 +173,7 @@ const collectContinuationFromRange = (range: Range | null | undefined, limit = E */ export function EPUBProvider({ children }: { children: ReactNode }) { const { setText: setTTSText, currDocPage, currDocPages, setCurrDocPages, stop, skipToLocation, setIsEPUB } = useTTS(); + const { authEnabled } = useAuthConfig(); const { id } = useParams(); // Configuration context to get TTS settings const { @@ -694,6 +697,13 @@ export function EPUBProvider({ children }: { children: ReactNode }) { if (id && locationRef.current !== 1) { console.log('Saving location:', location); setLastDocumentLocation(id as string, location.toString()); + if (authEnabled) { + scheduleDocumentProgressSync({ + documentId: id as string, + readerType: 'epub', + location: location.toString(), + }); + } } skipToLocation(location); @@ -703,7 +713,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) { extractPageText(bookRef.current, renditionRef.current, shouldPauseRef.current); shouldPauseRef.current = true; } - }, [id, skipToLocation, extractPageText, setIsEPUB]); + }, [id, skipToLocation, extractPageText, setIsEPUB, authEnabled]); const clearWordHighlights = useCallback(() => { if (!renditionRef.current) return; diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index ba91692..81f50dd 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -27,7 +27,7 @@ import { } from 'react'; import { Howl } from 'howler'; import toast from 'react-hot-toast'; -import { useParams } from 'next/navigation'; +import { useParams, usePathname } from 'next/navigation'; import { useConfig } from '@/contexts/ConfigContext'; import { useAudioCache } from '@/hooks/audio/useAudioCache'; @@ -35,6 +35,7 @@ import { useVoiceManagement } from '@/hooks/audio/useVoiceManagement'; import { useMediaSession } from '@/hooks/audio/useMediaSession'; import { useAudioContext } from '@/hooks/audio/useAudioContext'; import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/dexie'; +import { getDocumentProgress, scheduleDocumentProgressSync } from '@/lib/client-user-state'; import { useBackgroundState } from '@/hooks/audio/useBackgroundState'; import { withRetry, generateTTS, alignAudio } from '@/lib/client'; import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksEPUB } from '@/lib/nlp'; @@ -53,6 +54,7 @@ import type { TTSRequestHeaders, TTSRetryOptions, } from '@/types/client'; +import type { ReaderType } from '@/types/user-state'; // Media globals declare global { @@ -304,7 +306,14 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const audioContext = useAudioContext(); const audioCache = useAudioCache(25); const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel); - const { onTTSStart, onTTSComplete, refresh: refreshRateLimit, triggerRateLimit, isAtLimit } = useAuthRateLimit(); + const { + authEnabled, + onTTSStart, + onTTSComplete, + refresh: refreshRateLimit, + triggerRateLimit, + isAtLimit, + } = useAuthRateLimit(); // Add ref for location change handler const locationChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null); @@ -332,6 +341,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement // Get document ID from URL params const { id } = useParams(); + const pathname = usePathname(); + + const currentReaderType: ReaderType = useMemo(() => { + if (pathname.startsWith('/epub/')) return 'epub'; + if (pathname.startsWith('/html/')) return 'html'; + return 'pdf'; + }, [pathname]); /** * State Management @@ -1716,38 +1732,75 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement skipBackward, }); - // Load last location on mount for both EPUB and PDF + // Load last location on mount for both EPUB and PDF. + // Prefer server-backed progress when available, then fall back to local Dexie. useEffect(() => { - if (id) { - getLastDocumentLocation(id as string).then(lastLocation => { - if (lastLocation) { - console.log('Setting last location:', lastLocation); + if (!id) return; - if (isEPUB && locationChangeHandlerRef.current) { - // For EPUB documents, use the location change handler - locationChangeHandlerRef.current(lastLocation); - } else if (!isEPUB) { - // For PDF documents, parse the location as "page:sentence" - try { - const [pageStr, sentenceIndexStr] = lastLocation.split(':'); - const page = parseInt(pageStr, 10); - const sentenceIndex = parseInt(sentenceIndexStr, 10); + let cancelled = false; + const docId = id as string; - if (!isNaN(page) && !isNaN(sentenceIndex)) { - console.log(`Restoring PDF position: page ${page}, sentence ${sentenceIndex}`); - // Skip to the page first, then the sentence index will be restored when setText is called - setCurrDocPage(page); - // Store the sentence index to be used when text is loaded - setPendingRestoreIndex(sentenceIndex); - } - } catch (error) { - console.warn('Error parsing PDF location:', error); - } + const applyLocation = (lastLocation: string) => { + console.log('Setting last location:', lastLocation); + + if (isEPUB && locationChangeHandlerRef.current) { + // For EPUB documents, use the location change handler + locationChangeHandlerRef.current(lastLocation); + return; + } + + if (!isEPUB) { + // For PDF documents, parse the location as "page:sentence" + try { + const [pageStr, sentenceIndexStr] = lastLocation.split(':'); + const page = parseInt(pageStr, 10); + const sentenceIndex = parseInt(sentenceIndexStr, 10); + + if (!isNaN(page) && !isNaN(sentenceIndex)) { + console.log(`Restoring PDF position: page ${page}, sentence ${sentenceIndex}`); + // Skip to the page first, then the sentence index will be restored when setText is called + setCurrDocPage(page); + // Store the sentence index to be used when text is loaded + setPendingRestoreIndex(sentenceIndex); } + } catch (error) { + console.warn('Error parsing PDF location:', error); } - }); - } - }, [id, isEPUB]); + } + }; + + const load = async () => { + if (authEnabled) { + try { + const remote = await getDocumentProgress(docId); + if (!cancelled && remote?.location) { + await setLastDocumentLocation(docId, remote.location).catch((error) => { + console.warn('Error caching remote location locally:', error); + }); + applyLocation(remote.location); + return; + } + } catch (error) { + console.warn('Error loading remote progress:', error); + } + } + + try { + const local = await getLastDocumentLocation(docId); + if (!cancelled && local) { + applyLocation(local); + } + } catch (error) { + console.warn('Error loading local last location:', error); + } + }; + + load(); + + return () => { + cancelled = true; + }; + }, [id, isEPUB, currentReaderType, authEnabled]); // Save current position periodically for PDFs useEffect(() => { @@ -1758,11 +1811,18 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement setLastDocumentLocation(id as string, location).catch(error => { console.warn('Error saving PDF location:', error); }); + if (authEnabled) { + scheduleDocumentProgressSync({ + documentId: id as string, + readerType: currentReaderType, + location, + }); + } }, 1000); // Debounce saves by 1 second return () => clearTimeout(timeoutId); } - }, [id, isEPUB, currDocPageNumber, currentIndex, sentences.length]); + }, [id, isEPUB, currDocPageNumber, currentIndex, sentences.length, currentReaderType, authEnabled]); /** * Renders the TTS context provider with its children diff --git a/src/db/schema.ts b/src/db/schema.ts index a3b8549..a89f820 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -12,3 +12,5 @@ export const session = usePostgres ? postgresSchema.session : sqliteSchema.sessi export const account = usePostgres ? postgresSchema.account : sqliteSchema.account; export const verification = usePostgres ? postgresSchema.verification : sqliteSchema.verification; export const userTtsChars = usePostgres ? postgresSchema.userTtsChars : sqliteSchema.userTtsChars; +export const userPreferences = usePostgres ? postgresSchema.userPreferences : sqliteSchema.userPreferences; +export const userDocumentProgress = usePostgres ? postgresSchema.userDocumentProgress : sqliteSchema.userDocumentProgress; diff --git a/src/db/schema_postgres.ts b/src/db/schema_postgres.ts index ad979f5..d514176 100644 --- a/src/db/schema_postgres.ts +++ b/src/db/schema_postgres.ts @@ -1,4 +1,4 @@ -import { pgTable, text, integer, real, boolean, timestamp, date, bigint, primaryKey, index } from 'drizzle-orm/pg-core'; +import { pgTable, text, integer, real, boolean, timestamp, date, bigint, primaryKey, index, jsonb } from 'drizzle-orm/pg-core'; export const documents = pgTable('documents', { id: text('id').notNull(), @@ -96,3 +96,25 @@ export const userTtsChars = pgTable("user_tts_chars", { pk: primaryKey({ columns: [table.userId, table.date] }), dateIdx: index('idx_user_tts_chars_date').on(table.date), })); + +export const userPreferences = pgTable('user_preferences', { + userId: text('user_id').primaryKey(), + dataJson: jsonb('data_json').notNull().default({}), + clientUpdatedAtMs: bigint('client_updated_at_ms', { mode: 'number' }).notNull().default(0), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at').defaultNow(), +}); + +export const userDocumentProgress = pgTable('user_document_progress', { + userId: text('user_id').notNull(), + documentId: text('document_id').notNull(), + readerType: text('reader_type').notNull(), // pdf, epub, html + location: text('location').notNull(), + progress: real('progress'), + clientUpdatedAtMs: bigint('client_updated_at_ms', { mode: 'number' }).notNull().default(0), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at').defaultNow(), +}, (table) => ({ + pk: primaryKey({ columns: [table.userId, table.documentId] }), + userUpdatedIdx: index('idx_user_document_progress_user_id_updated_at').on(table.userId, table.updatedAt), +})); diff --git a/src/db/schema_sqlite.ts b/src/db/schema_sqlite.ts index 06a2147..c806f86 100644 --- a/src/db/schema_sqlite.ts +++ b/src/db/schema_sqlite.ts @@ -97,3 +97,25 @@ export const userTtsChars = sqliteTable("user_tts_chars", { pk: primaryKey({ columns: [table.userId, table.date] }), dateIdx: index('idx_user_tts_chars_date').on(table.date), })); + +export const userPreferences = sqliteTable('user_preferences', { + userId: text('user_id').primaryKey(), + dataJson: text('data_json').notNull().default('{}'), + clientUpdatedAtMs: integer('client_updated_at_ms').notNull().default(0), + createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`), + updatedAt: integer('updated_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`), +}); + +export const userDocumentProgress = sqliteTable('user_document_progress', { + userId: text('user_id').notNull(), + documentId: text('document_id').notNull(), + readerType: text('reader_type').notNull(), // pdf, epub, html + location: text('location').notNull(), + progress: real('progress'), + clientUpdatedAtMs: integer('client_updated_at_ms').notNull().default(0), + createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`), + updatedAt: integer('updated_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`), +}, (table) => ({ + pk: primaryKey({ columns: [table.userId, table.documentId] }), + userUpdatedIdx: index('idx_user_document_progress_user_id_updated_at').on(table.userId, table.updatedAt), +})); diff --git a/src/lib/client-user-state.ts b/src/lib/client-user-state.ts new file mode 100644 index 0000000..5bc8823 --- /dev/null +++ b/src/lib/client-user-state.ts @@ -0,0 +1,181 @@ +import { SYNCED_PREFERENCE_KEYS, type DocumentProgressRecord, type ReaderType, type SyncedPreferencesPatch } from '@/types/user-state'; + +type PreferencesResponse = { + preferences: SyncedPreferencesPatch; + clientUpdatedAtMs: number; + hasStoredPreferences?: boolean; +}; + +type ProgressResponse = { + progress: DocumentProgressRecord | null; +}; + +function sanitizePreferencesPatch(input: SyncedPreferencesPatch): SyncedPreferencesPatch { + const patch: SyncedPreferencesPatch = {}; + for (const key of SYNCED_PREFERENCE_KEYS) { + if (!(key in input)) continue; + const value = input[key]; + if (value === undefined) continue; + (patch as Record)[key] = value; + } + return patch; +} + +export async function getUserPreferences(options?: { signal?: AbortSignal }): Promise { + const res = await fetch('/api/user/state/preferences', { signal: options?.signal }); + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || 'Failed to load user preferences'); + } + return (await res.json()) as PreferencesResponse; +} + +export async function putUserPreferences( + patch: SyncedPreferencesPatch, + options?: { signal?: AbortSignal; clientUpdatedAtMs?: number }, +): Promise { + const cleanPatch = sanitizePreferencesPatch(patch); + const res = await fetch('/api/user/state/preferences', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + patch: cleanPatch, + clientUpdatedAtMs: options?.clientUpdatedAtMs ?? Date.now(), + }), + signal: options?.signal, + }); + + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || 'Failed to update user preferences'); + } + + return (await res.json()) as PreferencesResponse & { applied: boolean }; +} + +type PendingPreferenceSync = { + patch: SyncedPreferencesPatch; + timer: ReturnType | null; +}; + +const pendingPreferenceSync: PendingPreferenceSync = { + patch: {}, + timer: null, +}; + +export function scheduleUserPreferencesSync(patch: SyncedPreferencesPatch, debounceMs: number = 600): void { + Object.assign(pendingPreferenceSync.patch, sanitizePreferencesPatch(patch)); + + if (pendingPreferenceSync.timer) { + clearTimeout(pendingPreferenceSync.timer); + } + + pendingPreferenceSync.timer = setTimeout(async () => { + const payload = { ...pendingPreferenceSync.patch }; + pendingPreferenceSync.patch = {}; + pendingPreferenceSync.timer = null; + if (Object.keys(payload).length === 0) return; + + try { + await putUserPreferences(payload); + } catch (error) { + console.warn('Failed to sync user preferences:', error); + } + }, debounceMs); +} + +export async function getDocumentProgress( + documentId: string, + options?: { signal?: AbortSignal }, +): Promise { + const res = await fetch(`/api/user/state/progress?documentId=${encodeURIComponent(documentId)}`, { + signal: options?.signal, + }); + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || 'Failed to load document progress'); + } + const data = (await res.json()) as ProgressResponse; + return data.progress ?? null; +} + +export async function putDocumentProgress(payload: { + documentId: string; + readerType: ReaderType; + location: string; + progress?: number | null; + clientUpdatedAtMs?: number; + signal?: AbortSignal; +}): Promise { + const res = await fetch('/api/user/state/progress', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + documentId: payload.documentId, + readerType: payload.readerType, + location: payload.location, + progress: payload.progress ?? null, + clientUpdatedAtMs: payload.clientUpdatedAtMs ?? Date.now(), + }), + signal: payload.signal, + }); + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || 'Failed to update document progress'); + } + const data = (await res.json()) as ProgressResponse; + return data.progress ?? null; +} + +type PendingProgressSync = { + payload: { + documentId: string; + readerType: ReaderType; + location: string; + progress: number | null; + }; + timer: ReturnType | null; +}; + +const pendingProgressByDoc = new Map(); + +export function scheduleDocumentProgressSync( + payload: { + documentId: string; + readerType: ReaderType; + location: string; + progress?: number | null; + }, + debounceMs: number = 1000, +): void { + const existing = pendingProgressByDoc.get(payload.documentId); + if (existing?.timer) { + clearTimeout(existing.timer); + } + + const next: PendingProgressSync = { + payload: { + documentId: payload.documentId, + readerType: payload.readerType, + location: payload.location, + progress: payload.progress ?? null, + }, + timer: null, + }; + + next.timer = setTimeout(async () => { + pendingProgressByDoc.delete(payload.documentId); + try { + await putDocumentProgress({ + documentId: next.payload.documentId, + readerType: next.payload.readerType, + location: next.payload.location, + progress: next.payload.progress, + }); + } catch (error) { + console.warn('Failed to sync document progress:', error); + } + }, debounceMs); + + pendingProgressByDoc.set(payload.documentId, next); +} diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index f7fe9da..9190ed6 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -7,7 +7,12 @@ import type { NextRequest } from 'next/server'; import { db } from "@/db"; import { rateLimiter } from "@/lib/server/rate-limiter"; import { isAuthEnabled } from "@/lib/server/auth-config"; -import { transferUserAudiobooks, transferUserDocuments } from "@/lib/server/claim-data"; +import { + transferUserAudiobooks, + transferUserDocuments, + transferUserPreferences, + transferUserProgress, +} from "@/lib/server/claim-data"; import * as schema from "@/db/schema"; // Import the dynamic schema @@ -135,6 +140,28 @@ const createAuth = () => betterAuth({ console.error("Error transferring documents during account linking:", error); // Don't throw here to prevent blocking the account linking process } + + // Transfer preferences from anonymous user to new authenticated user + try { + const transferred = await transferUserPreferences(anonymousUser.user.id, newUser.user.id); + if (transferred > 0) { + console.log(`Successfully transferred preferences from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`); + } + } catch (error) { + console.error("Error transferring preferences during account linking:", error); + // Don't throw here to prevent blocking the account linking process + } + + // Transfer reading progress from anonymous user to new authenticated user + try { + const transferred = await transferUserProgress(anonymousUser.user.id, newUser.user.id); + if (transferred > 0) { + console.log(`Successfully transferred ${transferred} progress row(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`); + } + } catch (error) { + console.error("Error transferring reading progress during account linking:", error); + // Don't throw here to prevent blocking the account linking process + } } catch (error) { console.error("Error in onLinkAccount callback:", error); // Don't throw here to prevent blocking the account linking process diff --git a/src/lib/server/claim-data.ts b/src/lib/server/claim-data.ts index 32624f4..3037720 100644 --- a/src/lib/server/claim-data.ts +++ b/src/lib/server/claim-data.ts @@ -1,6 +1,6 @@ import { db } from '@/db'; -import { documents, audiobooks, audiobookChapters } from '@/db/schema'; -import { eq } from 'drizzle-orm'; +import { documents, audiobooks, audiobookChapters, userPreferences, userDocumentProgress } from '@/db/schema'; +import { eq, and, inArray } from 'drizzle-orm'; import { UNCLAIMED_USER_ID } from './docstore'; import { deleteAudiobookObject, @@ -34,6 +34,25 @@ type AudiobookChapterRow = { format: string; }; +type UserPreferenceRow = { + userId: string; + dataJson: unknown; + clientUpdatedAtMs: number; + createdAt: unknown; + updatedAt: unknown; +}; + +type UserDocumentProgressRow = { + userId: string; + documentId: string; + readerType: string; + location: string; + progress: number | null; + clientUpdatedAtMs: number; + createdAt: unknown; + updatedAt: unknown; +}; + function contentTypeForAudiobookObject(fileName: string): string { if (fileName.endsWith('.mp3')) return 'audio/mpeg'; if (fileName.endsWith('.m4b')) return 'audio/mp4'; @@ -70,16 +89,22 @@ async function moveAudiobookBlobScope( } export async function claimAnonymousData(userId: string, unclaimedUserId: string = UNCLAIMED_USER_ID, namespace: string | null = null) { - if (!isAuthEnabled() || !userId) return { documents: 0, audiobooks: 0 }; + if (!isAuthEnabled() || !userId) { + return { documents: 0, audiobooks: 0, preferences: 0, progress: 0 }; + } - const [documentsClaimed, audiobooksClaimed] = await Promise.all([ + const [documentsClaimed, audiobooksClaimed, preferencesClaimed, progressClaimed] = await Promise.all([ transferUserDocuments(unclaimedUserId, userId), transferUserAudiobooks(unclaimedUserId, userId, namespace), + transferUserPreferences(unclaimedUserId, userId), + transferUserProgress(unclaimedUserId, userId), ]); return { documents: documentsClaimed, audiobooks: audiobooksClaimed, + preferences: preferencesClaimed, + progress: progressClaimed, }; } @@ -161,3 +186,92 @@ export async function transferUserAudiobooks( return books.length; } + +export async function transferUserPreferences(fromUserId: string, toUserId: string): Promise { + if (!isAuthEnabled() || !fromUserId || !toUserId) return 0; + if (fromUserId === toUserId) return 0; + + const fromRows = (await db + .select() + .from(userPreferences) + .where(eq(userPreferences.userId, fromUserId))) as UserPreferenceRow[]; + const fromRow = fromRows[0]; + if (!fromRow) return 0; + + const toRows = (await db + .select() + .from(userPreferences) + .where(eq(userPreferences.userId, toUserId))) as UserPreferenceRow[]; + const toRow = toRows[0]; + + if (!toRow || Number(fromRow.clientUpdatedAtMs ?? 0) > Number(toRow.clientUpdatedAtMs ?? 0)) { + await db + .insert(userPreferences) + .values({ + ...fromRow, + userId: toUserId, + }) + .onConflictDoUpdate({ + target: [userPreferences.userId], + set: { + dataJson: fromRow.dataJson, + clientUpdatedAtMs: fromRow.clientUpdatedAtMs, + updatedAt: fromRow.updatedAt, + }, + }); + } + + await db.delete(userPreferences).where(eq(userPreferences.userId, fromUserId)); + return 1; +} + +export async function transferUserProgress(fromUserId: string, toUserId: string): Promise { + if (!isAuthEnabled() || !fromUserId || !toUserId) return 0; + if (fromUserId === toUserId) return 0; + + const fromRows = (await db + .select() + .from(userDocumentProgress) + .where(eq(userDocumentProgress.userId, fromUserId))) as UserDocumentProgressRow[]; + if (fromRows.length === 0) return 0; + + const documentIds = fromRows.map((row) => row.documentId); + const toRows = (await db + .select() + .from(userDocumentProgress) + .where(and( + eq(userDocumentProgress.userId, toUserId), + inArray(userDocumentProgress.documentId, documentIds), + ))) as UserDocumentProgressRow[]; + const toByDocId = new Map(); + for (const row of toRows) { + toByDocId.set(row.documentId, row); + } + + for (const row of fromRows) { + const existing = toByDocId.get(row.documentId); + const fromUpdated = Number(row.clientUpdatedAtMs ?? 0); + const toUpdated = Number(existing?.clientUpdatedAtMs ?? 0); + if (existing && fromUpdated <= toUpdated) continue; + + await db + .insert(userDocumentProgress) + .values({ + ...row, + userId: toUserId, + }) + .onConflictDoUpdate({ + target: [userDocumentProgress.userId, userDocumentProgress.documentId], + set: { + readerType: row.readerType, + location: row.location, + progress: row.progress, + clientUpdatedAtMs: row.clientUpdatedAtMs, + updatedAt: row.updatedAt, + }, + }); + } + + await db.delete(userDocumentProgress).where(eq(userDocumentProgress.userId, fromUserId)); + return fromRows.length; +} diff --git a/src/lib/server/user-state-scope.ts b/src/lib/server/user-state-scope.ts new file mode 100644 index 0000000..dc4fe7a --- /dev/null +++ b/src/lib/server/user-state-scope.ts @@ -0,0 +1,30 @@ +import type { NextRequest } from 'next/server'; +import type { AuthContext } from '@/lib/server/auth'; +import { requireAuthContext } from '@/lib/server/auth'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; + +export type ResolvedUserStateScope = { + auth: AuthContext; + namespace: string | null; + ownerUserId: string; + unclaimedUserId: string; +}; + +export async function resolveUserStateScope( + req: NextRequest, +): Promise { + const auth = await requireAuthContext(req); + if (auth instanceof Response) return auth; + + const namespace = getOpenReaderTestNamespace(req.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(namespace); + const ownerUserId = auth.userId ?? unclaimedUserId; + + return { + auth, + namespace, + ownerUserId, + unclaimedUserId, + }; +} + diff --git a/src/types/user-state.ts b/src/types/user-state.ts new file mode 100644 index 0000000..0a3c83d --- /dev/null +++ b/src/types/user-state.ts @@ -0,0 +1,39 @@ +import type { AppConfigValues } from '@/types/config'; + +export const SYNCED_PREFERENCE_KEYS = [ + 'viewType', + 'voiceSpeed', + 'audioPlayerSpeed', + 'voice', + 'skipBlank', + 'epubTheme', + 'smartSentenceSplitting', + 'headerMargin', + 'footerMargin', + 'leftMargin', + 'rightMargin', + 'ttsProvider', + 'ttsModel', + 'ttsInstructions', + 'savedVoices', + 'pdfHighlightEnabled', + 'pdfWordHighlightEnabled', + 'epubHighlightEnabled', + 'epubWordHighlightEnabled', +] as const; + +export type SyncedPreferenceKey = (typeof SYNCED_PREFERENCE_KEYS)[number]; +export type SyncedPreferences = Pick; +export type SyncedPreferencesPatch = Partial; + +export type ReaderType = 'pdf' | 'epub' | 'html'; + +export interface DocumentProgressRecord { + documentId: string; + readerType: ReaderType; + location: string; + progress: number | null; + clientUpdatedAtMs: number; + updatedAtMs: number; +} + From 9f25e05cce4d3beb0f4bd0f95b7a9b2a124a9b91 Mon Sep 17 00:00:00 2001 From: Richard R Date: Thu, 12 Feb 2026 16:05:03 -0700 Subject: [PATCH 24/55] feat(previews): implement document preview generation and caching - Add document preview caching logic with in-memory and persisted storage. - Implement S3 blobstore functions for managing document previews. - Create rendering functions for PDF and EPUB cover images to JPEG format. - Introduce database schema and functions for managing document preview metadata. - Add unit tests for rendering PDF and EPUB previews. --- docs-site/docs/configure/database.md | 1 + .../docs/configure/object-blob-storage.md | 7 + ...ect_risque.sql => 0000_solid_colossus.sql} | 43 ++ drizzle/postgres/0001_user_state_minimal.sql | 21 - drizzle/postgres/meta/0000_snapshot.json | 298 +++++++++++- drizzle/postgres/meta/_journal.json | 13 +- ...ly_korvac.sql => 0000_cool_gwen_stacy.sql} | 43 ++ drizzle/sqlite/0001_user_state_minimal.sql | 21 - drizzle/sqlite/meta/0000_snapshot.json | 293 +++++++++++- drizzle/sqlite/meta/_journal.json | 13 +- next.config.ts | 4 +- package.json | 6 +- pnpm-lock.yaml | 134 ++++++ .../api/documents/blob/get/fallback/route.ts | 85 ++++ .../api/documents/blob/get/presign/route.ts | 67 +++ .../documents/blob/preview/ensure/route.ts | 105 +++++ .../documents/blob/preview/fallback/route.ts | 143 ++++++ .../documents/blob/preview/presign/route.ts | 103 +++++ src/app/api/documents/blob/route.ts | 19 +- .../api/documents/docx-to-pdf/upload/route.ts | 12 + src/app/api/documents/route.ts | 23 + src/app/epub/[id]/page.tsx | 30 +- src/app/html/[id]/page.tsx | 30 +- src/app/pdf/[id]/page.tsx | 30 +- src/components/SettingsModal.tsx | 7 +- src/components/doclist/DocumentPreview.tsx | 147 ++++-- src/db/schema.ts | 1 + src/db/schema_postgres.ts | 23 + src/db/schema_sqlite.ts | 23 + src/lib/client-documents.ts | 122 ++++- src/lib/dexie.ts | 284 +++++++++++- src/lib/document-preview-cache.ts | 125 +++++ src/lib/server/document-previews-blobstore.ts | 148 ++++++ src/lib/server/document-previews-render.ts | 258 +++++++++++ src/lib/server/document-previews.ts | 427 ++++++++++++++++++ src/lib/server/documents-blobstore.ts | 18 + tests/unit/document-previews-render.spec.ts | 29 ++ 37 files changed, 3005 insertions(+), 151 deletions(-) rename drizzle/postgres/{0000_perfect_risque.sql => 0000_solid_colossus.sql} (64%) delete mode 100644 drizzle/postgres/0001_user_state_minimal.sql rename drizzle/sqlite/{0000_lively_korvac.sql => 0000_cool_gwen_stacy.sql} (63%) delete mode 100644 drizzle/sqlite/0001_user_state_minimal.sql create mode 100644 src/app/api/documents/blob/get/fallback/route.ts create mode 100644 src/app/api/documents/blob/get/presign/route.ts create mode 100644 src/app/api/documents/blob/preview/ensure/route.ts create mode 100644 src/app/api/documents/blob/preview/fallback/route.ts create mode 100644 src/app/api/documents/blob/preview/presign/route.ts create mode 100644 src/lib/document-preview-cache.ts create mode 100644 src/lib/server/document-previews-blobstore.ts create mode 100644 src/lib/server/document-previews-render.ts create mode 100644 src/lib/server/document-previews.ts create mode 100644 tests/unit/document-previews-render.spec.ts diff --git a/docs-site/docs/configure/database.md b/docs-site/docs/configure/database.md index c3140be..88c79f5 100644 --- a/docs-site/docs/configure/database.md +++ b/docs-site/docs/configure/database.md @@ -16,6 +16,7 @@ This page covers database mode selection for OpenReader WebUI. - TTS character usage counters (`user_tts_chars`) for daily rate limiting (when enabled). - User settings preferences (`user_preferences`) when auth is enabled. - User reading progress (`user_document_progress`) when auth is enabled. +- Document preview job/asset metadata (`document_previews`) for server-side PDF/EPUB thumbnails. ## Related variables diff --git a/docs-site/docs/configure/object-blob-storage.md b/docs-site/docs/configure/object-blob-storage.md index 9664ea1..ff46fd1 100644 --- a/docs-site/docs/configure/object-blob-storage.md +++ b/docs-site/docs/configure/object-blob-storage.md @@ -28,6 +28,13 @@ Storage variables are documented in [Environment Variables](../reference/environ - Primary path: browser uploads to presigned URL from `/api/documents/blob/upload/presign`. - Fallback path: `/api/documents/blob/upload/fallback` when direct upload fails/unreachable. - Read/download path: blob/content serving route `/api/documents/blob` (not the upload fallback route). +- Preview path: `/api/documents/blob/preview` (returns `202` while a preview is generating; serves/redirects when ready). + +## Document previews + +- PDF/EPUB previews are generated server-side and stored in object storage under `document_previews_v1`. +- Preview generation is triggered on upload registration and also backfills on first preview request for older docs. +- Preview artifacts are temporary-cache friendly and can be regenerated from the source document blob. ## FS / Volume Mounts diff --git a/drizzle/postgres/0000_perfect_risque.sql b/drizzle/postgres/0000_solid_colossus.sql similarity index 64% rename from drizzle/postgres/0000_perfect_risque.sql rename to drizzle/postgres/0000_solid_colossus.sql index 25c0a97..aff2619 100644 --- a/drizzle/postgres/0000_perfect_risque.sql +++ b/drizzle/postgres/0000_solid_colossus.sql @@ -38,6 +38,27 @@ CREATE TABLE "audiobooks" ( CONSTRAINT "audiobooks_id_user_id_pk" PRIMARY KEY("id","user_id") ); --> statement-breakpoint +CREATE TABLE "document_previews" ( + "document_id" text NOT NULL, + "namespace" text DEFAULT '' NOT NULL, + "variant" text DEFAULT 'card-240-jpeg' NOT NULL, + "status" text DEFAULT 'queued' NOT NULL, + "source_last_modified_ms" bigint NOT NULL, + "object_key" text NOT NULL, + "content_type" text DEFAULT 'image/jpeg' NOT NULL, + "width" integer DEFAULT 240 NOT NULL, + "height" integer, + "byte_size" bigint, + "etag" text, + "lease_owner" text, + "lease_until_ms" bigint DEFAULT 0 NOT NULL, + "attempt_count" integer DEFAULT 0 NOT NULL, + "last_error" text, + "created_at_ms" bigint DEFAULT 0 NOT NULL, + "updated_at_ms" bigint DEFAULT 0 NOT NULL, + CONSTRAINT "document_previews_document_id_namespace_variant_pk" PRIMARY KEY("document_id","namespace","variant") +); +--> statement-breakpoint CREATE TABLE "documents" ( "id" text NOT NULL, "user_id" text NOT NULL, @@ -74,6 +95,26 @@ CREATE TABLE "user" ( CONSTRAINT "user_email_unique" UNIQUE("email") ); --> statement-breakpoint +CREATE TABLE "user_document_progress" ( + "user_id" text NOT NULL, + "document_id" text NOT NULL, + "reader_type" text NOT NULL, + "location" text NOT NULL, + "progress" real, + "client_updated_at_ms" bigint DEFAULT 0 NOT NULL, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now(), + CONSTRAINT "user_document_progress_user_id_document_id_pk" PRIMARY KEY("user_id","document_id") +); +--> statement-breakpoint +CREATE TABLE "user_preferences" ( + "user_id" text PRIMARY KEY NOT NULL, + "data_json" jsonb DEFAULT '{}'::jsonb NOT NULL, + "client_updated_at_ms" bigint DEFAULT 0 NOT NULL, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() +); +--> statement-breakpoint CREATE TABLE "user_tts_chars" ( "user_id" text NOT NULL, "date" date NOT NULL, @@ -92,6 +133,8 @@ CREATE TABLE "verification" ( --> statement-breakpoint ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_document_previews_status_lease" ON "document_previews" USING btree ("status","lease_until_ms");--> statement-breakpoint CREATE INDEX "idx_documents_user_id" ON "documents" USING btree ("user_id");--> statement-breakpoint CREATE INDEX "idx_documents_user_id_last_modified" ON "documents" USING btree ("user_id","last_modified");--> statement-breakpoint +CREATE INDEX "idx_user_document_progress_user_id_updated_at" ON "user_document_progress" USING btree ("user_id","updated_at");--> statement-breakpoint CREATE INDEX "idx_user_tts_chars_date" ON "user_tts_chars" USING btree ("date"); \ No newline at end of file diff --git a/drizzle/postgres/0001_user_state_minimal.sql b/drizzle/postgres/0001_user_state_minimal.sql deleted file mode 100644 index 0f8ef23..0000000 --- a/drizzle/postgres/0001_user_state_minimal.sql +++ /dev/null @@ -1,21 +0,0 @@ -CREATE TABLE "user_document_progress" ( - "user_id" text NOT NULL, - "document_id" text NOT NULL, - "reader_type" text NOT NULL, - "location" text NOT NULL, - "progress" real, - "client_updated_at_ms" bigint DEFAULT 0 NOT NULL, - "created_at" timestamp DEFAULT now(), - "updated_at" timestamp DEFAULT now(), - CONSTRAINT "user_document_progress_user_id_document_id_pk" PRIMARY KEY("user_id","document_id") -); ---> statement-breakpoint -CREATE TABLE "user_preferences" ( - "user_id" text PRIMARY KEY NOT NULL, - "data_json" jsonb DEFAULT '{}'::jsonb NOT NULL, - "client_updated_at_ms" bigint DEFAULT 0 NOT NULL, - "created_at" timestamp DEFAULT now(), - "updated_at" timestamp DEFAULT now() -); ---> statement-breakpoint -CREATE INDEX "idx_user_document_progress_user_id_updated_at" ON "user_document_progress" USING btree ("user_id","updated_at"); diff --git a/drizzle/postgres/meta/0000_snapshot.json b/drizzle/postgres/meta/0000_snapshot.json index 1493370..7ceeb38 100644 --- a/drizzle/postgres/meta/0000_snapshot.json +++ b/drizzle/postgres/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "03395f16-5c34-409a-9dc1-69329d67bcd9", + "id": "161f4dc9-0c06-4d77-87e5-3496ffad1b97", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -250,6 +250,161 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.document_previews": { + "name": "document_previews", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'card-240-jpeg'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "source_last_modified_ms": { + "name": "source_last_modified_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'image/jpeg'" + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 240 + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until_ms": { + "name": "lease_until_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "idx_document_previews_status_lease": { + "name": "idx_document_previews_status_lease", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_until_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "document_previews_document_id_namespace_variant_pk": { + "name": "document_previews_document_id_namespace_variant_pk", + "columns": [ + "document_id", + "namespace", + "variant" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.documents": { "name": "documents", "schema": "", @@ -509,6 +664,147 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.user_document_progress": { + "name": "user_document_progress", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "progress": { + "name": "progress", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_user_document_progress_user_id_updated_at": { + "name": "idx_user_document_progress_user_id_updated_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_document_progress_user_id_document_id_pk": { + "name": "user_document_progress_user_id_document_id_pk", + "columns": [ + "user_id", + "document_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "data_json": { + "name": "data_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.user_tts_chars": { "name": "user_tts_chars", "schema": "", diff --git a/drizzle/postgres/meta/_journal.json b/drizzle/postgres/meta/_journal.json index 9fea0ea..bd88253 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -5,16 +5,9 @@ { "idx": 0, "version": "7", - "when": 1770191341206, - "tag": "0000_perfect_risque", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1770843600000, - "tag": "0001_user_state_minimal", + "when": 1770846273728, + "tag": "0000_solid_colossus", "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/drizzle/sqlite/0000_lively_korvac.sql b/drizzle/sqlite/0000_cool_gwen_stacy.sql similarity index 63% rename from drizzle/sqlite/0000_lively_korvac.sql rename to drizzle/sqlite/0000_cool_gwen_stacy.sql index ac57079..0c37a89 100644 --- a/drizzle/sqlite/0000_lively_korvac.sql +++ b/drizzle/sqlite/0000_cool_gwen_stacy.sql @@ -39,6 +39,28 @@ CREATE TABLE `audiobooks` ( PRIMARY KEY(`id`, `user_id`) ); --> statement-breakpoint +CREATE TABLE `document_previews` ( + `document_id` text NOT NULL, + `namespace` text DEFAULT '' NOT NULL, + `variant` text DEFAULT 'card-240-jpeg' NOT NULL, + `status` text DEFAULT 'queued' NOT NULL, + `source_last_modified_ms` integer NOT NULL, + `object_key` text NOT NULL, + `content_type` text DEFAULT 'image/jpeg' NOT NULL, + `width` integer DEFAULT 240 NOT NULL, + `height` integer, + `byte_size` integer, + `etag` text, + `lease_owner` text, + `lease_until_ms` integer DEFAULT 0 NOT NULL, + `attempt_count` integer DEFAULT 0 NOT NULL, + `last_error` text, + `created_at_ms` integer DEFAULT 0 NOT NULL, + `updated_at_ms` integer DEFAULT 0 NOT NULL, + PRIMARY KEY(`document_id`, `namespace`, `variant`) +); +--> statement-breakpoint +CREATE INDEX `idx_document_previews_status_lease` ON `document_previews` (`status`,`lease_until_ms`);--> statement-breakpoint CREATE TABLE `documents` ( `id` text NOT NULL, `user_id` text NOT NULL, @@ -78,6 +100,27 @@ CREATE TABLE `user` ( ); --> statement-breakpoint CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint +CREATE TABLE `user_document_progress` ( + `user_id` text NOT NULL, + `document_id` text NOT NULL, + `reader_type` text NOT NULL, + `location` text NOT NULL, + `progress` real, + `client_updated_at_ms` integer DEFAULT 0 NOT NULL, + `created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000), + `updated_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000), + PRIMARY KEY(`user_id`, `document_id`) +); +--> statement-breakpoint +CREATE INDEX `idx_user_document_progress_user_id_updated_at` ON `user_document_progress` (`user_id`,`updated_at`);--> statement-breakpoint +CREATE TABLE `user_preferences` ( + `user_id` text PRIMARY KEY NOT NULL, + `data_json` text DEFAULT '{}' NOT NULL, + `client_updated_at_ms` integer DEFAULT 0 NOT NULL, + `created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000), + `updated_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000) +); +--> statement-breakpoint CREATE TABLE `user_tts_chars` ( `user_id` text NOT NULL, `date` text NOT NULL, diff --git a/drizzle/sqlite/0001_user_state_minimal.sql b/drizzle/sqlite/0001_user_state_minimal.sql deleted file mode 100644 index 24514c4..0000000 --- a/drizzle/sqlite/0001_user_state_minimal.sql +++ /dev/null @@ -1,21 +0,0 @@ -CREATE TABLE `user_document_progress` ( - `user_id` text NOT NULL, - `document_id` text NOT NULL, - `reader_type` text NOT NULL, - `location` text NOT NULL, - `progress` real, - `client_updated_at_ms` integer DEFAULT 0 NOT NULL, - `created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000), - `updated_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000), - PRIMARY KEY(`user_id`, `document_id`) -); ---> statement-breakpoint -CREATE TABLE `user_preferences` ( - `user_id` text PRIMARY KEY NOT NULL, - `data_json` text DEFAULT '{}' NOT NULL, - `client_updated_at_ms` integer DEFAULT 0 NOT NULL, - `created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000), - `updated_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000) -); ---> statement-breakpoint -CREATE INDEX `idx_user_document_progress_user_id_updated_at` ON `user_document_progress` (`user_id`,`updated_at`); diff --git a/drizzle/sqlite/meta/0000_snapshot.json b/drizzle/sqlite/meta/0000_snapshot.json index 0a7d123..6ca5cca 100644 --- a/drizzle/sqlite/meta/0000_snapshot.json +++ b/drizzle/sqlite/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "9c9e61fc-1f90-463a-99dc-232397f316e7", + "id": "eb884adf-c9c7-494b-b0a1-3601f63bcbbe", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "account": { @@ -270,6 +270,162 @@ "uniqueConstraints": {}, "checkConstraints": {} }, + "document_previews": { + "name": "document_previews", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'card-240-jpeg'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "source_last_modified_ms": { + "name": "source_last_modified_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'image/jpeg'" + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 240 + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until_ms": { + "name": "lease_until_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_document_previews_status_lease": { + "name": "idx_document_previews_status_lease", + "columns": [ + "status", + "lease_until_ms" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "document_previews_document_id_namespace_variant_pk": { + "columns": [ + "document_id", + "namespace", + "variant" + ], + "name": "document_previews_document_id_namespace_variant_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, "documents": { "name": "documents", "columns": { @@ -523,6 +679,141 @@ "uniqueConstraints": {}, "checkConstraints": {} }, + "user_document_progress": { + "name": "user_document_progress", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "progress": { + "name": "progress", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(strftime('%s','now') as int) * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(strftime('%s','now') as int) * 1000)" + } + }, + "indexes": { + "idx_user_document_progress_user_id_updated_at": { + "name": "idx_user_document_progress_user_id_updated_at", + "columns": [ + "user_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_document_progress_user_id_document_id_pk": { + "columns": [ + "user_id", + "document_id" + ], + "name": "user_document_progress_user_id_document_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data_json": { + "name": "data_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(strftime('%s','now') as int) * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(strftime('%s','now') as int) * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, "user_tts_chars": { "name": "user_tts_chars", "columns": { diff --git a/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json index cc48afa..4edffc4 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -5,16 +5,9 @@ { "idx": 0, "version": "6", - "when": 1770191340954, - "tag": "0000_lively_korvac", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1770843600000, - "tag": "0001_user_state_minimal", + "when": 1770846273460, + "tag": "0000_cool_gwen_stacy", "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index 42eb5d2..f7c6ffb 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,10 +3,10 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { turbopack: { resolveAlias: { - canvas: './empty-module.ts', + canvas: '@napi-rs/canvas', }, }, - serverExternalPackages: ["better-sqlite3", "ffmpeg-static", "ffprobe-static"], + serverExternalPackages: ["@napi-rs/canvas", "better-sqlite3", "ffmpeg-static", "ffprobe-static"], outputFileTracingIncludes: { '/api/audiobook': [ './node_modules/ffmpeg-static/ffmpeg', diff --git a/package.json b/package.json index 52e1678..4d17007 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@aws-sdk/client-s3": "^3.987.0", "@aws-sdk/s3-request-presigner": "^3.987.0", "@headlessui/react": "^2.2.9", + "@napi-rs/canvas": "^0.1.91", "@vercel/analytics": "^1.6.1", "better-auth": "^1.4.18", "better-sqlite3": "^12.6.2", @@ -37,9 +38,11 @@ "drizzle-kit": "^0.31.9", "drizzle-orm": "^0.45.1", "epubjs": "^0.3.93", + "fast-xml-parser": "^5.3.5", "ffmpeg-static": "^5.3.0", "ffprobe-static": "^3.1.0", "howler": "^2.2.4", + "jszip": "^3.10.1", "lru-cache": "^11.2.6", "next": "^15.5.12", "openai": "^6.21.0", @@ -63,11 +66,11 @@ "@tailwindcss/typography": "^0.5.19", "@types/better-sqlite3": "^7.6.13", "@types/ffprobe-static": "^2.0.3", + "@types/howler": "^2.2.12", "@types/node": "^20.19.33", "@types/pg": "^8.16.0", "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", - "@types/howler": "^2.2.12", "@types/uuid": "^10.0.0", "eslint": "^9.39.2", "eslint-config-next": "^15.5.12", @@ -77,6 +80,7 @@ }, "pnpm": { "onlyBuiltDependencies": [ + "@napi-rs/canvas", "better-sqlite3", "ffmpeg-static" ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 972419d..09bc895 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: '@headlessui/react': specifier: ^2.2.9 version: 2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@napi-rs/canvas': + specifier: ^0.1.91 + version: 0.1.91 '@vercel/analytics': specifier: ^1.6.1 version: 1.6.1(next@15.5.12(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) @@ -58,6 +61,9 @@ importers: epubjs: specifier: ^0.3.93 version: 0.3.93 + fast-xml-parser: + specifier: ^5.3.5 + version: 5.3.5 ffmpeg-static: specifier: ^5.3.0 version: 5.3.0 @@ -67,6 +73,9 @@ importers: howler: specifier: ^2.2.4 version: 2.2.4 + jszip: + specifier: ^3.10.1 + version: 3.10.1 lru-cache: specifier: ^11.2.6 version: 11.2.6 @@ -915,6 +924,76 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@napi-rs/canvas-android-arm64@0.1.91': + resolution: {integrity: sha512-SLLzXXgSnfct4zy/BVAfweZQkYkPJsNsJ2e5DOE8DFEHC6PufyUrwb12yqeu2So2IOIDpWJJaDAxKY/xpy6MYQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.91': + resolution: {integrity: sha512-bzdbCjIjw3iRuVFL+uxdSoMra/l09ydGNX9gsBxO/zg+5nlppscIpj6gg+nL6VNG85zwUarDleIrUJ+FWHvmuA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.91': + resolution: {integrity: sha512-q3qpkpw0IsG9fAS/dmcGIhCVoNxj8ojbexZKWwz3HwxlEWsLncEQRl4arnxrwbpLc2nTNTyj4WwDn7QR5NDAaA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.91': + resolution: {integrity: sha512-Io3g8wJZVhK8G+Fpg1363BE90pIPqg+ZbeehYNxPWDSzbgwU3xV0l8r/JBzODwC7XHi1RpFEk+xyUTMa2POj6w==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.91': + resolution: {integrity: sha512-HBnto+0rxx1bQSl8bCWA9PyBKtlk2z/AI32r3cu4kcNO+M/5SD4b0v1MWBWZyqMQyxFjWgy3ECyDjDKMC6tY1A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-arm64-musl@0.1.91': + resolution: {integrity: sha512-/eJtVe2Xw9A86I4kwXpxxoNagdGclu12/NSMsfoL8q05QmeRCbfjhg1PJS7ENAuAvaiUiALGrbVfeY1KU1gztQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.91': + resolution: {integrity: sha512-floNK9wQuRWevUhhXRcuis7h0zirdytVxPgkonWO+kQlbvxV7gEUHGUFQyq4n55UHYFwgck1SAfJ1HuXv/+ppQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@0.1.91': + resolution: {integrity: sha512-c3YDqBdf7KETuZy2AxsHFMsBBX1dWT43yFfWUq+j1IELdgesWtxf/6N7csi3VPf6VA3PmnT9EhMyb+M1wfGtqw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@0.1.91': + resolution: {integrity: sha512-RpZ3RPIwgEcNBHSHSX98adm+4VP8SMT5FN6250s5jQbWpX/XNUX5aLMfAVJS/YnDjS1QlsCgQxFOPU0aCCWgag==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-arm64-msvc@0.1.91': + resolution: {integrity: sha512-gF8MBp4X134AgVurxqlCdDA2qO0WaDdi9o6Sd5rWRVXRhWhYQ6wkdEzXNLIrmmros0Tsp2J0hQzx4ej/9O8trQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@0.1.91': + resolution: {integrity: sha512-++gtW9EV/neKI8TshD8WFxzBYALSPag2kFRahIJV+LYsyt5kBn21b1dBhEUDHf7O+wiZmuFCeUa7QKGHnYRZBA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.91': + resolution: {integrity: sha512-eeIe1GoB74P1B0Nkw6pV8BCQ3hfCfvyYr4BntzlCsnFXzVJiPMDnLeIx3gVB0xQMblHYnjK/0nCLvirEhOjr5g==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -2307,6 +2386,10 @@ packages: resolution: {integrity: sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==} hasBin: true + fast-xml-parser@5.3.5: + resolution: {integrity: sha512-JeaA2Vm9ffQKp9VjvfzObuMCjUYAp5WDYhRYL5LrBPY/jUDlUtOvDfot0vKSkB9tuX885BDHjtw4fZadD95wnA==} + hasBin: true + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -4675,6 +4758,53 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@napi-rs/canvas-android-arm64@0.1.91': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.91': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.91': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.91': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.91': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.91': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.91': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.91': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.91': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@0.1.91': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.91': + optional: true + + '@napi-rs/canvas@0.1.91': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.91 + '@napi-rs/canvas-darwin-arm64': 0.1.91 + '@napi-rs/canvas-darwin-x64': 0.1.91 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.91 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.91 + '@napi-rs/canvas-linux-arm64-musl': 0.1.91 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.91 + '@napi-rs/canvas-linux-x64-gnu': 0.1.91 + '@napi-rs/canvas-linux-x64-musl': 0.1.91 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.91 + '@napi-rs/canvas-win32-x64-msvc': 0.1.91 + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.8.1 @@ -6246,6 +6376,10 @@ snapshots: dependencies: strnum: 2.1.2 + fast-xml-parser@5.3.5: + dependencies: + strnum: 2.1.2 + fastq@1.20.1: dependencies: reusify: 1.1.0 diff --git a/src/app/api/documents/blob/get/fallback/route.ts b/src/app/api/documents/blob/get/fallback/route.ts new file mode 100644 index 0000000..043bfcd --- /dev/null +++ b/src/app/api/documents/blob/get/fallback/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { and, eq, inArray } from 'drizzle-orm'; +import { db } from '@/db'; +import { documents } from '@/db/schema'; +import { requireAuthContext } from '@/lib/server/auth'; +import { contentTypeForName } from '@/lib/server/library'; +import { getDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents-blobstore'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; +import { isS3Configured } from '@/lib/server/s3'; + +export const dynamic = 'force-dynamic'; + +function s3NotConfiguredResponse(): NextResponse { + return NextResponse.json( + { error: 'Documents storage is not configured. Set S3_* environment variables.' }, + { status: 503 }, + ); +} + +function streamBuffer(buffer: Buffer): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(buffer)); + controller.close(); + }, + }); +} + +export async function GET(req: NextRequest) { + try { + if (!isS3Configured()) return s3NotConfiguredResponse(); + + const ctxOrRes = await requireAuthContext(req); + if (ctxOrRes instanceof Response) return ctxOrRes; + + const testNamespace = getOpenReaderTestNamespace(req.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + const storageUserId = ctxOrRes.userId ?? unclaimedUserId; + const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + + const url = new URL(req.url); + const id = (url.searchParams.get('id') || '').trim().toLowerCase(); + if (!isValidDocumentId(id)) { + return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); + } + + const rows = (await db + .select({ id: documents.id, userId: documents.userId, name: documents.name }) + .from(documents) + .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{ + id: string; + userId: string; + name: string; + }>; + + const doc = rows.find((row) => row.userId === storageUserId) ?? rows[0]; + if (!doc) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + const filename = doc.name || `${id}.bin`; + const responseType = contentTypeForName(filename); + + try { + const content = await getDocumentBlob(id, testNamespace); + return new NextResponse(streamBuffer(content), { + headers: { + 'Content-Type': responseType, + 'Cache-Control': 'no-store', + }, + }); + } catch (error) { + if (isMissingBlobError(error)) { + await db + .delete(documents) + .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds))); + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + throw error; + } + } catch (error) { + console.error('Error loading document content fallback:', error); + return NextResponse.json({ error: 'Failed to load document content' }, { status: 500 }); + } +} diff --git a/src/app/api/documents/blob/get/presign/route.ts b/src/app/api/documents/blob/get/presign/route.ts new file mode 100644 index 0000000..4785b4c --- /dev/null +++ b/src/app/api/documents/blob/get/presign/route.ts @@ -0,0 +1,67 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { and, eq, inArray } from 'drizzle-orm'; +import { db } from '@/db'; +import { documents } from '@/db/schema'; +import { requireAuthContext } from '@/lib/server/auth'; +import { isValidDocumentId, presignGet } from '@/lib/server/documents-blobstore'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; +import { isS3Configured } from '@/lib/server/s3'; + +export const dynamic = 'force-dynamic'; + +function s3NotConfiguredResponse(): NextResponse { + return NextResponse.json( + { error: 'Documents storage is not configured. Set S3_* environment variables.' }, + { status: 503 }, + ); +} + +export async function GET(req: NextRequest) { + try { + if (!isS3Configured()) return s3NotConfiguredResponse(); + + const ctxOrRes = await requireAuthContext(req); + if (ctxOrRes instanceof Response) return ctxOrRes; + + const testNamespace = getOpenReaderTestNamespace(req.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + const storageUserId = ctxOrRes.userId ?? unclaimedUserId; + const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + + const url = new URL(req.url); + const id = (url.searchParams.get('id') || '').trim().toLowerCase(); + if (!isValidDocumentId(id)) { + return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); + } + + const rows = (await db + .select({ id: documents.id, userId: documents.userId }) + .from(documents) + .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{ + id: string; + userId: string; + }>; + + const doc = rows.find((row) => row.userId === storageUserId) ?? rows[0]; + if (!doc) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + const fallbackUrl = `/api/documents/blob/get/fallback?id=${encodeURIComponent(doc.id)}`; + const directUrl = await presignGet(doc.id, testNamespace).catch(() => null); + if (!directUrl) { + return NextResponse.redirect(fallbackUrl, { + status: 307, + headers: { 'Cache-Control': 'no-store' }, + }); + } + + return NextResponse.redirect(directUrl, { + status: 307, + headers: { 'Cache-Control': 'no-store' }, + }); + } catch (error) { + console.error('Error creating document download signature:', error); + return NextResponse.json({ error: 'Failed to prepare document download' }, { status: 500 }); + } +} diff --git a/src/app/api/documents/blob/preview/ensure/route.ts b/src/app/api/documents/blob/preview/ensure/route.ts new file mode 100644 index 0000000..8d417ad --- /dev/null +++ b/src/app/api/documents/blob/preview/ensure/route.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { and, eq, inArray } from 'drizzle-orm'; +import { db } from '@/db'; +import { documents } from '@/db/schema'; +import { requireAuthContext } from '@/lib/server/auth'; +import { isValidDocumentId } from '@/lib/server/documents-blobstore'; +import { presignDocumentPreviewGet } from '@/lib/server/document-previews-blobstore'; +import { ensureDocumentPreview, isPreviewableDocumentType } from '@/lib/server/document-previews'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; +import { isS3Configured } from '@/lib/server/s3'; + +export const dynamic = 'force-dynamic'; + +function s3NotConfiguredResponse(): NextResponse { + return NextResponse.json( + { error: 'Documents storage is not configured. Set S3_* environment variables.' }, + { status: 503 }, + ); +} + +export async function GET(req: NextRequest) { + try { + if (!isS3Configured()) return s3NotConfiguredResponse(); + + const ctxOrRes = await requireAuthContext(req); + if (ctxOrRes instanceof Response) return ctxOrRes; + + const testNamespace = getOpenReaderTestNamespace(req.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + const storageUserId = ctxOrRes.userId ?? unclaimedUserId; + const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + + const url = new URL(req.url); + const id = (url.searchParams.get('id') || '').trim().toLowerCase(); + if (!isValidDocumentId(id)) { + return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); + } + + const rows = (await db + .select({ + id: documents.id, + userId: documents.userId, + type: documents.type, + lastModified: documents.lastModified, + }) + .from(documents) + .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{ + id: string; + userId: string; + type: string; + lastModified: number; + }>; + + const doc = rows.find((row) => row.userId === storageUserId) ?? rows[0]; + if (!doc) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (!isPreviewableDocumentType(doc.type)) { + return NextResponse.json({ error: `Preview not supported for type ${doc.type}` }, { status: 415 }); + } + + const presignUrl = `/api/documents/blob/preview/presign?id=${encodeURIComponent(id)}`; + const fallbackUrl = `/api/documents/blob/preview/fallback?id=${encodeURIComponent(id)}`; + const preview = await ensureDocumentPreview( + { + id: doc.id, + type: doc.type, + lastModified: Number(doc.lastModified), + }, + testNamespace, + ); + + if (preview.state !== 'ready') { + return NextResponse.json( + { + status: preview.status, + retryAfterMs: preview.retryAfterMs, + presignUrl, + fallbackUrl, + }, + { + status: 202, + headers: { 'Cache-Control': 'no-store' }, + }, + ); + } + + const directUrl = await presignDocumentPreviewGet(doc.id, testNamespace).catch(() => null); + return NextResponse.json( + { + status: 'ready', + presignUrl, + fallbackUrl, + ...(directUrl ? { directUrl } : {}), + }, + { + headers: { 'Cache-Control': 'no-store' }, + }, + ); + } catch (error) { + console.error('Error ensuring document preview:', error); + return NextResponse.json({ error: 'Failed to ensure document preview' }, { status: 500 }); + } +} diff --git a/src/app/api/documents/blob/preview/fallback/route.ts b/src/app/api/documents/blob/preview/fallback/route.ts new file mode 100644 index 0000000..36cadff --- /dev/null +++ b/src/app/api/documents/blob/preview/fallback/route.ts @@ -0,0 +1,143 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { and, eq, inArray } from 'drizzle-orm'; +import { db } from '@/db'; +import { documents } from '@/db/schema'; +import { requireAuthContext } from '@/lib/server/auth'; +import { isValidDocumentId } from '@/lib/server/documents-blobstore'; +import { + getDocumentPreviewBuffer, + isMissingBlobError, +} from '@/lib/server/document-previews-blobstore'; +import { + ensureDocumentPreview, + enqueueDocumentPreview, + isPreviewableDocumentType, +} from '@/lib/server/document-previews'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; +import { isS3Configured } from '@/lib/server/s3'; + +export const dynamic = 'force-dynamic'; + +function s3NotConfiguredResponse(): NextResponse { + return NextResponse.json( + { error: 'Documents storage is not configured. Set S3_* environment variables.' }, + { status: 503 }, + ); +} + +function streamBuffer(buffer: Buffer): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(buffer)); + controller.close(); + }, + }); +} + +export async function GET(req: NextRequest) { + try { + if (!isS3Configured()) return s3NotConfiguredResponse(); + + const ctxOrRes = await requireAuthContext(req); + if (ctxOrRes instanceof Response) return ctxOrRes; + + const testNamespace = getOpenReaderTestNamespace(req.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + const storageUserId = ctxOrRes.userId ?? unclaimedUserId; + const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + + const url = new URL(req.url); + const id = (url.searchParams.get('id') || '').trim().toLowerCase(); + const presignUrl = `/api/documents/blob/preview/presign?id=${encodeURIComponent(id)}`; + const fallbackUrl = `/api/documents/blob/preview/fallback?id=${encodeURIComponent(id)}`; + if (!isValidDocumentId(id)) { + return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); + } + + const rows = (await db + .select({ + id: documents.id, + userId: documents.userId, + type: documents.type, + lastModified: documents.lastModified, + }) + .from(documents) + .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{ + id: string; + userId: string; + type: string; + lastModified: number; + }>; + + const doc = rows.find((row) => row.userId === storageUserId) ?? rows[0]; + if (!doc) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (!isPreviewableDocumentType(doc.type)) { + return NextResponse.json({ error: `Preview not supported for type ${doc.type}` }, { status: 415 }); + } + + const preview = await ensureDocumentPreview( + { + id: doc.id, + type: doc.type, + lastModified: Number(doc.lastModified), + }, + testNamespace, + ); + + if (preview.state !== 'ready') { + return NextResponse.json( + { + status: preview.status, + retryAfterMs: preview.retryAfterMs, + presignUrl, + fallbackUrl, + }, + { + status: 202, + headers: { 'Cache-Control': 'no-store' }, + }, + ); + } + + try { + const content = await getDocumentPreviewBuffer(doc.id, testNamespace); + return new NextResponse(streamBuffer(content), { + headers: { + 'Content-Type': preview.contentType, + 'Cache-Control': 'no-store', + 'Content-Length': String(content.byteLength), + }, + }); + } catch (error) { + if (isMissingBlobError(error)) { + await enqueueDocumentPreview( + { + id: doc.id, + type: doc.type, + lastModified: Number(doc.lastModified), + }, + testNamespace, + ).catch(() => {}); + return NextResponse.json( + { + status: 'queued', + retryAfterMs: 1500, + presignUrl, + fallbackUrl, + }, + { + status: 202, + headers: { 'Cache-Control': 'no-store' }, + }, + ); + } + throw error; + } + } catch (error) { + console.error('Error loading document preview fallback:', error); + return NextResponse.json({ error: 'Failed to load document preview' }, { status: 500 }); + } +} diff --git a/src/app/api/documents/blob/preview/presign/route.ts b/src/app/api/documents/blob/preview/presign/route.ts new file mode 100644 index 0000000..d52e0f5 --- /dev/null +++ b/src/app/api/documents/blob/preview/presign/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { and, eq, inArray } from 'drizzle-orm'; +import { db } from '@/db'; +import { documents } from '@/db/schema'; +import { requireAuthContext } from '@/lib/server/auth'; +import { isValidDocumentId } from '@/lib/server/documents-blobstore'; +import { presignDocumentPreviewGet } from '@/lib/server/document-previews-blobstore'; +import { ensureDocumentPreview, isPreviewableDocumentType } from '@/lib/server/document-previews'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; +import { isS3Configured } from '@/lib/server/s3'; + +export const dynamic = 'force-dynamic'; + +function s3NotConfiguredResponse(): NextResponse { + return NextResponse.json( + { error: 'Documents storage is not configured. Set S3_* environment variables.' }, + { status: 503 }, + ); +} + +export async function GET(req: NextRequest) { + try { + if (!isS3Configured()) return s3NotConfiguredResponse(); + + const ctxOrRes = await requireAuthContext(req); + if (ctxOrRes instanceof Response) return ctxOrRes; + + const testNamespace = getOpenReaderTestNamespace(req.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + const storageUserId = ctxOrRes.userId ?? unclaimedUserId; + const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + + const url = new URL(req.url); + const id = (url.searchParams.get('id') || '').trim().toLowerCase(); + if (!isValidDocumentId(id)) { + return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); + } + + const rows = (await db + .select({ + id: documents.id, + userId: documents.userId, + type: documents.type, + lastModified: documents.lastModified, + }) + .from(documents) + .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{ + id: string; + userId: string; + type: string; + lastModified: number; + }>; + + const doc = rows.find((row) => row.userId === storageUserId) ?? rows[0]; + if (!doc) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + if (!isPreviewableDocumentType(doc.type)) { + return NextResponse.json({ error: `Preview not supported for type ${doc.type}` }, { status: 415 }); + } + + const fallbackUrl = `/api/documents/blob/preview/fallback?id=${encodeURIComponent(id)}`; + const preview = await ensureDocumentPreview( + { + id: doc.id, + type: doc.type, + lastModified: Number(doc.lastModified), + }, + testNamespace, + ); + + if (preview.state !== 'ready') { + return NextResponse.json( + { + status: preview.status, + retryAfterMs: preview.retryAfterMs, + fallbackUrl, + }, + { + status: 202, + headers: { 'Cache-Control': 'no-store' }, + }, + ); + } + + const directUrl = await presignDocumentPreviewGet(doc.id, testNamespace).catch(() => null); + if (!directUrl) { + return NextResponse.redirect(fallbackUrl, { + status: 307, + headers: { 'Cache-Control': 'no-store' }, + }); + } + + return NextResponse.redirect(directUrl, { + status: 307, + headers: { 'Cache-Control': 'no-store' }, + }); + } catch (error) { + console.error('Error creating document preview signature:', error); + return NextResponse.json({ error: 'Failed to prepare document preview' }, { status: 500 }); + } +} diff --git a/src/app/api/documents/blob/route.ts b/src/app/api/documents/blob/route.ts index 04c183d..11366dc 100644 --- a/src/app/api/documents/blob/route.ts +++ b/src/app/api/documents/blob/route.ts @@ -5,7 +5,13 @@ import { documents } from '@/db/schema'; import { requireAuthContext } from '@/lib/server/auth'; import { contentTypeForName } from '@/lib/server/library'; import { extractRawTextSnippet } from '@/lib/text-snippets'; -import { getDocumentBlob, getDocumentRange, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents-blobstore'; +import { + getDocumentBlob, + getDocumentRange, + isMissingBlobError, + isValidDocumentId, + presignGet, +} from '@/lib/server/documents-blobstore'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; import { isS3Configured } from '@/lib/server/s3'; @@ -38,6 +44,7 @@ export async function GET(req: NextRequest) { const url = new URL(req.url); const id = (url.searchParams.get('id') || '').trim().toLowerCase(); const format = (url.searchParams.get('format') || '').toLowerCase().trim(); + const prefersProxy = (url.searchParams.get('proxy') || '').trim() === '1'; if (!isValidDocumentId(id)) { return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); } @@ -83,6 +90,16 @@ export async function GET(req: NextRequest) { } try { + if (!prefersProxy) { + const directUrl = await presignGet(id, testNamespace).catch(() => null); + if (directUrl) { + return NextResponse.redirect(directUrl, { + status: 307, + headers: { 'Cache-Control': 'no-store' }, + }); + } + } + const content = await getDocumentBlob(id, testNamespace); const stream = new ReadableStream({ start(controller) { diff --git a/src/app/api/documents/docx-to-pdf/upload/route.ts b/src/app/api/documents/docx-to-pdf/upload/route.ts index 0b4a3bd..4e79309 100644 --- a/src/app/api/documents/docx-to-pdf/upload/route.ts +++ b/src/app/api/documents/docx-to-pdf/upload/route.ts @@ -9,6 +9,7 @@ import { requireAuthContext } from '@/lib/server/auth'; import { db } from '@/db'; import { documents } from '@/db/schema'; import { safeDocumentName } from '@/lib/server/documents-utils'; +import { enqueueDocumentPreview } from '@/lib/server/document-previews'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; import { isS3Configured } from '@/lib/server/s3'; import { putDocumentBlob } from '@/lib/server/documents-blobstore'; @@ -147,6 +148,17 @@ export async function POST(req: NextRequest) { }, }); + await enqueueDocumentPreview( + { + id, + type: 'pdf', + lastModified, + }, + testNamespace, + ).catch((error) => { + console.error(`Failed to enqueue preview for converted DOCX ${id}:`, error); + }); + return NextResponse.json({ stored: { id, diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 023afdf..5492b18 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -4,6 +4,11 @@ import { db } from '@/db'; import { documents } from '@/db/schema'; import { requireAuthContext } from '@/lib/server/auth'; import { safeDocumentName, toDocumentTypeFromName } from '@/lib/server/documents-utils'; +import { + cleanupDocumentPreviewArtifacts, + deleteDocumentPreviewRows, + enqueueDocumentPreview, +} from '@/lib/server/document-previews'; import { deleteDocumentBlob, headDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents-blobstore'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; import { isS3Configured } from '@/lib/server/s3'; @@ -124,6 +129,17 @@ export async function POST(req: NextRequest) { lastModified: doc.lastModified, scope: storageUserId === unclaimedUserId ? 'unclaimed' : 'user', }); + + await enqueueDocumentPreview( + { + id: doc.id, + type: doc.type, + lastModified: doc.lastModified, + }, + testNamespace, + ).catch((error) => { + console.error(`Failed to enqueue preview for document ${doc.id}:`, error); + }); } return NextResponse.json({ success: true, stored }); @@ -269,6 +285,13 @@ export async function DELETE(req: NextRequest) { throw error; } } + + await cleanupDocumentPreviewArtifacts(id, testNamespace).catch((error) => { + console.error(`Failed to cleanup preview artifacts for document ${id}:`, error); + }); + await deleteDocumentPreviewRows(id, testNamespace).catch((error) => { + console.error(`Failed to cleanup preview rows for document ${id}:`, error); + }); } return NextResponse.json({ success: true, deleted: deletedRows.length }); diff --git a/src/app/epub/[id]/page.tsx b/src/app/epub/[id]/page.tsx index 51d5d0e..b2b4c97 100644 --- a/src/app/epub/[id]/page.tsx +++ b/src/app/epub/[id]/page.tsx @@ -2,7 +2,7 @@ import { useParams, useRouter } from "next/navigation"; import Link from 'next/link'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useEPUB } from '@/contexts/EPUBContext'; import { DocumentSkeleton } from '@/components/DocumentSkeleton'; import { EPUBViewer } from '@/components/views/EPUBViewer'; @@ -34,11 +34,20 @@ export default function EPUBPage() { const [containerHeight, setContainerHeight] = useState('auto'); const [padPct, setPadPct] = useState(100); // 0..100 (100 = full width, 0 = max padding) const [maxPadPx, setMaxPadPx] = useState(0); + const inFlightDocIdRef = useRef(null); + const loadedDocIdRef = useRef(null); + + useEffect(() => { + setIsLoading(true); + setError(null); + inFlightDocIdRef.current = null; + loadedDocIdRef.current = null; + }, [id]); const loadDocument = useCallback(async () => { console.log('Loading new epub (from page.tsx)'); - stop(); // Reset TTS when loading new document let didRedirect = false; + let startedLoad = false; try { if (!id) { setError('Document not found'); @@ -50,12 +59,27 @@ export default function EPUBPage() { router.replace(`/epub/${resolved}`); return; } + + if (loadedDocIdRef.current === resolved) { + return; + } + if (inFlightDocIdRef.current === resolved) { + return; + } + + startedLoad = true; + inFlightDocIdRef.current = resolved; + stop(); // Reset TTS when loading new document await setCurrentDocument(resolved); + loadedDocIdRef.current = resolved; } catch (err) { console.error('Error loading document:', err); setError('Failed to load document'); } finally { - if (!didRedirect) { + if (startedLoad) { + inFlightDocIdRef.current = null; + } + if (!didRedirect && startedLoad) { setIsLoading(false); } } diff --git a/src/app/html/[id]/page.tsx b/src/app/html/[id]/page.tsx index a3d0811..c9cfba0 100644 --- a/src/app/html/[id]/page.tsx +++ b/src/app/html/[id]/page.tsx @@ -2,7 +2,7 @@ import { useParams, useRouter } from "next/navigation"; import Link from 'next/link'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useHTML } from '@/contexts/HTMLContext'; import { DocumentSkeleton } from '@/components/DocumentSkeleton'; import { HTMLViewer } from '@/components/views/HTMLViewer'; @@ -28,12 +28,21 @@ export default function HTMLPage() { const [containerHeight, setContainerHeight] = useState('auto'); const [padPct, setPadPct] = useState(100); // 0..100 (100 = full width) const [maxPadPx, setMaxPadPx] = useState(0); + const inFlightDocIdRef = useRef(null); + const loadedDocIdRef = useRef(null); + + useEffect(() => { + setIsLoading(true); + setError(null); + inFlightDocIdRef.current = null; + loadedDocIdRef.current = null; + }, [id]); const loadDocument = useCallback(async () => { if (!isLoading) return; console.log('Loading new HTML document (from page.tsx)'); - stop(); let didRedirect = false; + let startedLoad = false; try { if (!id) { setError('Document not found'); @@ -45,12 +54,27 @@ export default function HTMLPage() { router.replace(`/html/${resolved}`); return; } + + if (loadedDocIdRef.current === resolved) { + return; + } + if (inFlightDocIdRef.current === resolved) { + return; + } + + startedLoad = true; + inFlightDocIdRef.current = resolved; + stop(); await setCurrentDocument(resolved); + loadedDocIdRef.current = resolved; } catch (err) { console.error('Error loading document:', err); setError('Failed to load document'); } finally { - if (!didRedirect) { + if (startedLoad) { + inFlightDocIdRef.current = null; + } + if (!didRedirect && startedLoad) { setIsLoading(false); } } diff --git a/src/app/pdf/[id]/page.tsx b/src/app/pdf/[id]/page.tsx index 1c534a1..ff25cb8 100644 --- a/src/app/pdf/[id]/page.tsx +++ b/src/app/pdf/[id]/page.tsx @@ -4,7 +4,7 @@ import dynamic from 'next/dynamic'; import { usePDF } from '@/contexts/PDFContext'; import { useParams, useRouter } from 'next/navigation'; import Link from 'next/link'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { DocumentSkeleton } from '@/components/DocumentSkeleton'; import { useTTS } from '@/contexts/TTSContext'; import { DocumentSettings } from '@/components/DocumentSettings'; @@ -42,12 +42,21 @@ export default function PDFViewerPage() { const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false); const [containerHeight, setContainerHeight] = useState('auto'); + const inFlightDocIdRef = useRef(null); + const loadedDocIdRef = useRef(null); + + useEffect(() => { + setIsLoading(true); + setError(null); + inFlightDocIdRef.current = null; + loadedDocIdRef.current = null; + }, [id]); const loadDocument = useCallback(async () => { if (!isLoading) return; // Prevent calls when not loading new doc console.log('Loading new document (from page.tsx)'); - stop(); // Reset TTS when loading new document let didRedirect = false; + let startedLoad = false; try { if (!id) { setError('Document not found'); @@ -59,12 +68,27 @@ export default function PDFViewerPage() { router.replace(`/pdf/${resolved}`); return; } + + if (loadedDocIdRef.current === resolved) { + return; + } + if (inFlightDocIdRef.current === resolved) { + return; + } + + startedLoad = true; + inFlightDocIdRef.current = resolved; + stop(); // Reset TTS when loading new document await setCurrentDocument(resolved); + loadedDocIdRef.current = resolved; } catch (err) { console.error('Error loading document:', err); setError('Failed to load document'); } finally { - if (!didRedirect) { + if (startedLoad) { + inFlightDocIdRef.current = null; + } + if (!didRedirect && startedLoad) { setIsLoading(false); } } diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 58c9386..15c1609 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -38,6 +38,7 @@ import { useRouter } from 'next/navigation'; import { showPrivacyModal } from '@/components/PrivacyModal'; import { deleteDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client-documents'; import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/document-cache'; +import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/document-preview-cache'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; @@ -188,6 +189,7 @@ export function SettingsModal({ className = '' }: { className?: string }) { const handleRefresh = async () => { try { + clearInMemoryDocumentPreviewCache(); await refreshDocuments(); } catch (error) { console.error('Failed to refresh documents:', error); @@ -196,7 +198,10 @@ export function SettingsModal({ className = '' }: { className?: string }) { const handleClearCache = async () => { try { - await clearDocumentCache(); + await Promise.all([ + clearDocumentCache(), + clearAllDocumentPreviewCaches(), + ]); } catch (error) { console.error('Failed to clear cache:', error); } diff --git a/src/components/doclist/DocumentPreview.tsx b/src/components/doclist/DocumentPreview.tsx index d6bcab9..699adb9 100644 --- a/src/components/doclist/DocumentPreview.tsx +++ b/src/components/doclist/DocumentPreview.tsx @@ -2,11 +2,16 @@ import { DocumentListDocument } from '@/types/documents'; import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; import { useEffect, useMemo, useRef, useState } from 'react'; import { - extractEpubCoverToDataUrl, - renderPdfFirstPageToDataUrl, -} from '@/lib/documentPreview'; -import { getDocumentContentSnippet } from '@/lib/client-documents'; -import { ensureCachedDocument } from '@/lib/document-cache'; + documentPreviewFallbackUrl, + getDocumentContentSnippet, + getDocumentPreviewStatus, +} from '@/lib/client-documents'; +import { + getInMemoryDocumentPreviewUrl, + getPersistedDocumentPreviewUrl, + primeDocumentPreviewCache, + setInMemoryDocumentPreviewUrl, +} from '@/lib/document-preview-cache'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -14,7 +19,6 @@ interface DocumentPreviewProps { doc: DocumentListDocument; } -const imagePreviewCache = new Map(); const textPreviewCache = new Map(); export function DocumentPreview({ doc }: DocumentPreviewProps) { @@ -33,20 +37,11 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) { const containerRef = useRef(null); const [isVisible, setIsVisible] = useState(false); const [imagePreview, setImagePreview] = useState(null); + const [isImageReady, setIsImageReady] = useState(false); const [textPreview, setTextPreview] = useState(null); const [isGenerating, setIsGenerating] = useState(false); const previewKey = useMemo(() => `${doc.type}:${doc.id}`, [doc.id, doc.type]); - const cacheMeta = useMemo( - () => ({ - id: doc.id, - name: doc.name, - type: doc.type, - size: doc.size, - lastModified: doc.lastModified, - }), - [doc.id, doc.lastModified, doc.name, doc.size, doc.type], - ); useEffect(() => { const el = containerRef.current; @@ -69,7 +64,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) { useEffect(() => { if (!isVisible) return; - const cachedImage = imagePreviewCache.get(previewKey); + const cachedImage = getInMemoryDocumentPreviewUrl(previewKey); if (cachedImage) { setImagePreview(cachedImage); setTextPreview(null); @@ -89,32 +84,65 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) { const run = async () => { setIsGenerating(true); try { - const targetWidth = 240; - - if (doc.type === 'pdf') { - const cached = await ensureCachedDocument(cacheMeta, { signal: controller.signal }); - if (cached.type !== 'pdf') return; - const data = cached.data; - if (cancelled) return; - const dataUrl = await renderPdfFirstPageToDataUrl(data, targetWidth); - if (cancelled) return; - imagePreviewCache.set(previewKey, dataUrl); - setImagePreview(dataUrl); - setTextPreview(null); - return; - } - - if (doc.type === 'epub') { - const cached = await ensureCachedDocument(cacheMeta, { signal: controller.signal }); - if (cached.type !== 'epub') return; - const data = cached.data; - if (cancelled) return; - const cover = await extractEpubCoverToDataUrl(data, targetWidth); - if (cancelled) return; - if (cover) { - imagePreviewCache.set(previewKey, cover); - setImagePreview(cover); + if (doc.type === 'pdf' || doc.type === 'epub') { + const persistedUrl = await getPersistedDocumentPreviewUrl( + doc.id, + Number(doc.lastModified), + previewKey, + ); + if (!cancelled && persistedUrl) { + setImagePreview(persistedUrl); setTextPreview(null); + return; + } + + let attempt = 0; + while (!cancelled && attempt < 12) { + const status = await getDocumentPreviewStatus(doc.id, { signal: controller.signal }); + if (cancelled) return; + + if (status.kind === 'ready') { + const primedUrl = await primeDocumentPreviewCache( + doc.id, + Number(doc.lastModified), + previewKey, + { signal: controller.signal }, + ).catch(() => null); + if (cancelled) return; + + if (primedUrl) { + setImagePreview(primedUrl); + setTextPreview(null); + return; + } + + const fallbackUrl = status.fallbackUrl || documentPreviewFallbackUrl(doc.id); + setInMemoryDocumentPreviewUrl(previewKey, fallbackUrl); + setImagePreview(fallbackUrl); + setTextPreview(null); + return; + } + + if (status.status === 'failed') { + return; + } + + const waitMs = Math.max( + 400, + Math.min(6000, Number.isFinite(status.retryAfterMs) ? status.retryAfterMs : 1500), + ); + await new Promise((resolve) => { + const timer = setTimeout(resolve, waitMs); + controller.signal.addEventListener( + 'abort', + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); + attempt += 1; } return; } @@ -145,7 +173,11 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) { cancelled = true; controller.abort(); }; - }, [cacheMeta, doc.id, doc.type, isVisible, previewKey]); + }, [doc.id, doc.lastModified, doc.type, isVisible, previewKey]); + + useEffect(() => { + setIsImageReady(false); + }, [imagePreview]); const gradientClass = isPDF ? 'from-red-500/80 via-red-400/60 to-red-600/80' @@ -176,15 +208,40 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) { > {imagePreview ? ( <> +
+ {!isImageReady ? ( +
+ + + {typeLabel} + +
+ ) : null} {/* eslint-disable-next-line @next/next/no-img-element */} {`${doc.name} { + setIsImageReady(true); + }} + onError={() => { + if (!imagePreview) return; + setIsImageReady(false); + const fallback = documentPreviewFallbackUrl(doc.id); + if (imagePreview === fallback) return; + setInMemoryDocumentPreviewUrl(previewKey, fallback); + setImagePreview(fallback); + void primeDocumentPreviewCache( + doc.id, + Number(doc.lastModified), + previewKey, + ).catch(() => {}); + }} /> -
+ {isImageReady ?
: null} ) : textPreview ? ( <> diff --git a/src/db/schema.ts b/src/db/schema.ts index a89f820..726969b 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -14,3 +14,4 @@ export const verification = usePostgres ? postgresSchema.verification : sqliteSc export const userTtsChars = usePostgres ? postgresSchema.userTtsChars : sqliteSchema.userTtsChars; export const userPreferences = usePostgres ? postgresSchema.userPreferences : sqliteSchema.userPreferences; export const userDocumentProgress = usePostgres ? postgresSchema.userDocumentProgress : sqliteSchema.userDocumentProgress; +export const documentPreviews = usePostgres ? postgresSchema.documentPreviews : sqliteSchema.documentPreviews; diff --git a/src/db/schema_postgres.ts b/src/db/schema_postgres.ts index d514176..2bc955e 100644 --- a/src/db/schema_postgres.ts +++ b/src/db/schema_postgres.ts @@ -118,3 +118,26 @@ export const userDocumentProgress = pgTable('user_document_progress', { pk: primaryKey({ columns: [table.userId, table.documentId] }), userUpdatedIdx: index('idx_user_document_progress_user_id_updated_at').on(table.userId, table.updatedAt), })); + +export const documentPreviews = pgTable('document_previews', { + documentId: text('document_id').notNull(), + namespace: text('namespace').notNull().default(''), + variant: text('variant').notNull().default('card-240-jpeg'), + status: text('status').notNull().default('queued'), + sourceLastModifiedMs: bigint('source_last_modified_ms', { mode: 'number' }).notNull(), + objectKey: text('object_key').notNull(), + contentType: text('content_type').notNull().default('image/jpeg'), + width: integer('width').notNull().default(240), + height: integer('height'), + byteSize: bigint('byte_size', { mode: 'number' }), + eTag: text('etag'), + leaseOwner: text('lease_owner'), + leaseUntilMs: bigint('lease_until_ms', { mode: 'number' }).notNull().default(0), + attemptCount: integer('attempt_count').notNull().default(0), + lastError: text('last_error'), + createdAtMs: bigint('created_at_ms', { mode: 'number' }).notNull().default(0), + updatedAtMs: bigint('updated_at_ms', { mode: 'number' }).notNull().default(0), +}, (table) => ({ + pk: primaryKey({ columns: [table.documentId, table.namespace, table.variant] }), + statusLeaseIdx: index('idx_document_previews_status_lease').on(table.status, table.leaseUntilMs), +})); diff --git a/src/db/schema_sqlite.ts b/src/db/schema_sqlite.ts index c806f86..b82386c 100644 --- a/src/db/schema_sqlite.ts +++ b/src/db/schema_sqlite.ts @@ -119,3 +119,26 @@ export const userDocumentProgress = sqliteTable('user_document_progress', { pk: primaryKey({ columns: [table.userId, table.documentId] }), userUpdatedIdx: index('idx_user_document_progress_user_id_updated_at').on(table.userId, table.updatedAt), })); + +export const documentPreviews = sqliteTable('document_previews', { + documentId: text('document_id').notNull(), + namespace: text('namespace').notNull().default(''), + variant: text('variant').notNull().default('card-240-jpeg'), + status: text('status').notNull().default('queued'), + sourceLastModifiedMs: integer('source_last_modified_ms').notNull(), + objectKey: text('object_key').notNull(), + contentType: text('content_type').notNull().default('image/jpeg'), + width: integer('width').notNull().default(240), + height: integer('height'), + byteSize: integer('byte_size'), + eTag: text('etag'), + leaseOwner: text('lease_owner'), + leaseUntilMs: integer('lease_until_ms').notNull().default(0), + attemptCount: integer('attempt_count').notNull().default(0), + lastError: text('last_error'), + createdAtMs: integer('created_at_ms').notNull().default(0), + updatedAtMs: integer('updated_at_ms').notNull().default(0), +}, (table) => ({ + pk: primaryKey({ columns: [table.documentId, table.namespace, table.variant] }), + statusLeaseIdx: index('idx_document_previews_status_lease').on(table.status, table.leaseUntilMs), +})); diff --git a/src/lib/client-documents.ts b/src/lib/client-documents.ts index 194524b..3bdb954 100644 --- a/src/lib/client-documents.ts +++ b/src/lib/client-documents.ts @@ -202,17 +202,39 @@ export async function deleteDocuments(options?: { ids?: string[]; scope?: 'user' } export async function downloadDocumentContent(id: string, options?: { signal?: AbortSignal }): Promise { - const res = await fetch(`/api/documents/blob?id=${encodeURIComponent(id)}`, { signal: options?.signal }); - if (!res.ok) { - const contentType = res.headers.get('content-type') || ''; - if (contentType.includes('application/json')) { - const data = (await res.json().catch(() => null)) as { error?: string } | null; - throw new Error(data?.error || `Failed to download document (status ${res.status})`); - } - throw new Error(`Failed to download document (status ${res.status})`); - } + const fallbackUrl = `/api/documents/blob/get/fallback?id=${encodeURIComponent(id)}`; - return res.arrayBuffer(); + const fetchFallback = async (): Promise => { + const res = await fetch(fallbackUrl, { signal: options?.signal }); + if (!res.ok) { + const contentType = res.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || `Failed to download document (status ${res.status})`); + } + throw new Error(`Failed to download document (status ${res.status})`); + } + return res.arrayBuffer(); + }; + + try { + const directRes = await fetch(`/api/documents/blob/get/presign?id=${encodeURIComponent(id)}`, { + signal: options?.signal, + cache: 'no-store', + }); + if (!directRes.ok) { + const contentType = directRes.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + const data = (await directRes.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || `Failed to download document (status ${directRes.status})`); + } + throw new Error(`Failed to download document (status ${directRes.status})`); + } + return directRes.arrayBuffer(); + } catch (error) { + if (options?.signal?.aborted) throw error; + return fetchFallback(); + } } export async function getDocumentContentSnippet( @@ -235,6 +257,86 @@ export async function getDocumentContentSnippet( return data?.snippet || ''; } +export type DocumentPreviewPending = { + kind: 'pending'; + status: 'queued' | 'processing' | 'failed'; + retryAfterMs: number; + fallbackUrl: string; + presignUrl: string; + directUrl?: string; +}; + +export type DocumentPreviewReady = { + kind: 'ready'; + fallbackUrl: string; + presignUrl: string; + directUrl?: string; +}; + +export type DocumentPreviewStatus = DocumentPreviewPending | DocumentPreviewReady; + +function documentPreviewEnsureUrl(id: string): string { + return `/api/documents/blob/preview/ensure?id=${encodeURIComponent(id)}`; +} + +export function documentPreviewPresignUrl(id: string): string { + return `/api/documents/blob/preview/presign?id=${encodeURIComponent(id)}`; +} + +export function documentPreviewFallbackUrl(id: string): string { + return `/api/documents/blob/preview/fallback?id=${encodeURIComponent(id)}`; +} + +export async function getDocumentPreviewStatus( + id: string, + options?: { signal?: AbortSignal }, +): Promise { + const res = await fetch(documentPreviewEnsureUrl(id), { + signal: options?.signal, + cache: 'no-store', + }); + + if (res.status === 202) { + const data = (await res.json().catch(() => null)) as { + status?: 'queued' | 'processing' | 'failed'; + retryAfterMs?: number; + fallbackUrl?: string; + presignUrl?: string; + directUrl?: string; + } | null; + return { + kind: 'pending', + status: data?.status ?? 'queued', + retryAfterMs: Number.isFinite(data?.retryAfterMs) ? Number(data?.retryAfterMs) : 1500, + fallbackUrl: data?.fallbackUrl || documentPreviewFallbackUrl(id), + presignUrl: data?.presignUrl || documentPreviewPresignUrl(id), + directUrl: data?.directUrl, + }; + } + + if (res.ok) { + const data = (await res.json().catch(() => null)) as { + fallbackUrl?: string; + presignUrl?: string; + directUrl?: string; + } | null; + return { + kind: 'ready', + fallbackUrl: data?.fallbackUrl || documentPreviewFallbackUrl(id), + presignUrl: data?.presignUrl || documentPreviewPresignUrl(id), + directUrl: data?.directUrl, + }; + } + + const contentType = res.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error || `Failed to load preview status (status ${res.status})`); + } + + throw new Error(`Failed to load preview status (status ${res.status})`); +} + export async function uploadDocxAsPdf(file: File, options?: { signal?: AbortSignal }): Promise { const form = new FormData(); form.append('file', file); diff --git a/src/lib/dexie.ts b/src/lib/dexie.ts index 5392b18..d48c0dd 100644 --- a/src/lib/dexie.ts +++ b/src/lib/dexie.ts @@ -14,7 +14,7 @@ import { cacheStoredDocumentFromBytes } from '@/lib/document-cache'; const DB_NAME = 'openreader-db'; // Managed via Dexie (version bumped from the original manual IndexedDB) -const DB_VERSION = 6; +const DB_VERSION = 8; const PDF_TABLE = 'pdf-documents' as const; const EPUB_TABLE = 'epub-documents' as const; @@ -23,6 +23,20 @@ const CONFIG_TABLE = 'config' as const; const APP_CONFIG_TABLE = 'app-config' as const; const LAST_LOCATION_TABLE = 'last-locations' as const; const DOCUMENT_ID_MAP_TABLE = 'document-id-map' as const; +const PREVIEW_CACHE_TABLE = 'document-preview-cache' as const; +const MIB = 1024 * 1024; +const DOCUMENT_CACHE_MAX_BYTES = 1024 * MIB; // 1 GiB +const PREVIEW_CACHE_MAX_BYTES = 128 * MIB; // 128 MiB + +interface DocumentCacheMeta { + cacheCreatedAt?: number; + cacheAccessedAt?: number; + cacheByteSize?: number; +} + +type PDFCacheRow = PDFDocument & DocumentCacheMeta; +type EPUBCacheRow = EPUBDocument & DocumentCacheMeta; +type HTMLCacheRow = HTMLDocument & DocumentCacheMeta; export interface LastLocationRow { docId: string; @@ -35,19 +49,29 @@ export interface DocumentIdMapRow { createdAt: number; } +export interface DocumentPreviewCacheRow { + docId: string; + lastModified: number; + contentType: string; + data: ArrayBuffer; + cachedAt: number; + byteSize?: number; +} + export interface ConfigRow { key: string; value: string; } type OpenReaderDB = Dexie & { - [PDF_TABLE]: EntityTable; - [EPUB_TABLE]: EntityTable; - [HTML_TABLE]: EntityTable; + [PDF_TABLE]: EntityTable; + [EPUB_TABLE]: EntityTable; + [HTML_TABLE]: EntityTable; [CONFIG_TABLE]: EntityTable; [APP_CONFIG_TABLE]: EntityTable; [LAST_LOCATION_TABLE]: EntityTable; [DOCUMENT_ID_MAP_TABLE]: EntityTable; + [PREVIEW_CACHE_TABLE]: EntityTable; }; export const db = new Dexie(DB_NAME) as OpenReaderDB; @@ -192,15 +216,15 @@ function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow { return config; } -// Version 6: add document-id-map table; keep v5 upgrade to migrate scattered config keys -// and drop the legacy config table in a single upgrade step. +// Version 8: add local cache metadata/indexes so document + preview caches can be bounded via LRU pruning. db.version(DB_VERSION).stores({ - [PDF_TABLE]: 'id, type, name, lastModified, size, folderId', - [EPUB_TABLE]: 'id, type, name, lastModified, size, folderId', - [HTML_TABLE]: 'id, type, name, lastModified, size, folderId', + [PDF_TABLE]: 'id, type, name, lastModified, size, folderId, cacheAccessedAt', + [EPUB_TABLE]: 'id, type, name, lastModified, size, folderId, cacheAccessedAt', + [HTML_TABLE]: 'id, type, name, lastModified, size, folderId, cacheAccessedAt', [APP_CONFIG_TABLE]: 'id', [LAST_LOCATION_TABLE]: 'docId', [DOCUMENT_ID_MAP_TABLE]: 'oldId, id, createdAt', + [PREVIEW_CACHE_TABLE]: 'docId, lastModified, cachedAt, byteSize', // `null` here means: drop the old 'config' table after upgrade runs, // but Dexie still lets us read it inside the upgrade transaction. [CONFIG_TABLE]: null, @@ -231,6 +255,7 @@ db.version(DB_VERSION).stores({ }); let dbOpenPromise: Promise | null = null; +const cacheTextEncoder = new TextEncoder(); export async function initDB(): Promise { if (dbOpenPromise) { @@ -246,6 +271,9 @@ export async function initDB(): Promise { emitDexieStatus('stalled', { ms: Date.now() - startedAt }); }, 4000); await db.open(); + await Promise.all([pruneDocumentCacheIfNeededInternal(), prunePreviewCacheIfNeededInternal()]).catch((error) => { + console.warn('Dexie cache prune on open failed:', error); + }); clearTimeout(stallTimer); console.log('Dexie database opened successfully'); emitDexieStatus('opened'); @@ -265,6 +293,139 @@ async function withDB(operation: () => Promise): Promise { return operation(); } +type DocumentCacheTableName = typeof PDF_TABLE | typeof EPUB_TABLE | typeof HTML_TABLE; + +type DocumentCacheEntryRef = { + table: DocumentCacheTableName; + id: string; + byteSize: number; + accessedAt: number; +}; + +function toPositiveInt(value: unknown, fallback: number = 0): number { + const n = Number(value); + if (!Number.isFinite(n) || n < 0) return fallback; + return Math.max(0, Math.floor(n)); +} + +function byteSizeForPdfRow(row: PDFCacheRow): number { + const cached = toPositiveInt(row.cacheByteSize, 0); + if (cached > 0) return cached; + return row.data instanceof ArrayBuffer ? row.data.byteLength : toPositiveInt(row.size, 0); +} + +function byteSizeForEpubRow(row: EPUBCacheRow): number { + const cached = toPositiveInt(row.cacheByteSize, 0); + if (cached > 0) return cached; + return row.data instanceof ArrayBuffer ? row.data.byteLength : toPositiveInt(row.size, 0); +} + +function byteSizeForHtmlRow(row: HTMLCacheRow): number { + const cached = toPositiveInt(row.cacheByteSize, 0); + if (cached > 0) return cached; + if (typeof row.data === 'string') return cacheTextEncoder.encode(row.data).byteLength; + return toPositiveInt(row.size, 0); +} + +function accessedAtForRow(row: DocumentCacheMeta & { lastModified?: number }): number { + return toPositiveInt(row.cacheAccessedAt, toPositiveInt(row.lastModified, Date.now())); +} + +async function deleteDocumentCacheEntryInternal(table: DocumentCacheTableName, id: string): Promise { + if (table === PDF_TABLE) { + await db[PDF_TABLE].delete(id); + return; + } + if (table === EPUB_TABLE) { + await db[EPUB_TABLE].delete(id); + return; + } + await db[HTML_TABLE].delete(id); +} + +async function pruneDocumentCacheIfNeededInternal(): Promise { + const budget = DOCUMENT_CACHE_MAX_BYTES; + if (!Number.isFinite(budget) || budget <= 0) return; + + const [pdfRows, epubRows, htmlRows] = await Promise.all([ + db[PDF_TABLE].toArray(), + db[EPUB_TABLE].toArray(), + db[HTML_TABLE].toArray(), + ]); + + const entries: DocumentCacheEntryRef[] = []; + let totalBytes = 0; + + for (const row of pdfRows) { + const byteSize = byteSizeForPdfRow(row); + totalBytes += byteSize; + entries.push({ + table: PDF_TABLE, + id: row.id, + byteSize, + accessedAt: accessedAtForRow(row), + }); + } + for (const row of epubRows) { + const byteSize = byteSizeForEpubRow(row); + totalBytes += byteSize; + entries.push({ + table: EPUB_TABLE, + id: row.id, + byteSize, + accessedAt: accessedAtForRow(row), + }); + } + for (const row of htmlRows) { + const byteSize = byteSizeForHtmlRow(row); + totalBytes += byteSize; + entries.push({ + table: HTML_TABLE, + id: row.id, + byteSize, + accessedAt: accessedAtForRow(row), + }); + } + + if (totalBytes <= budget) return; + + entries.sort((a, b) => a.accessedAt - b.accessedAt); + for (const entry of entries) { + if (totalBytes <= budget) break; + await deleteDocumentCacheEntryInternal(entry.table, entry.id); + totalBytes -= entry.byteSize; + } +} + +async function prunePreviewCacheIfNeededInternal(): Promise { + const budget = PREVIEW_CACHE_MAX_BYTES; + if (!Number.isFinite(budget) || budget <= 0) return; + + const rows = await db[PREVIEW_CACHE_TABLE].toArray(); + let totalBytes = 0; + const entries = rows.map((row) => { + const byteSize = toPositiveInt( + row.byteSize, + row.data instanceof ArrayBuffer ? row.data.byteLength : 0, + ); + totalBytes += byteSize; + return { + docId: row.docId, + byteSize, + accessedAt: toPositiveInt(row.cachedAt, 0), + }; + }); + + if (totalBytes <= budget) return; + + entries.sort((a, b) => a.accessedAt - b.accessedAt); + for (const entry of entries) { + if (totalBytes <= budget) break; + await db[PREVIEW_CACHE_TABLE].delete(entry.docId); + totalBytes -= entry.byteSize; + } +} + function isSha256HexId(value: string): boolean { return /^[a-f0-9]{64}$/i.test(value); } @@ -325,6 +486,7 @@ async function applyDocumentIdMapping(oldId: string, newId: string): Promise { await recordDocumentIdMapping(oldId, nextId); @@ -395,6 +557,15 @@ async function applyDocumentIdMapping(oldId: string, newId: string): Promise { await withDB(async () => { console.log('Adding PDF document via Dexie:', document.name); - await db[PDF_TABLE].put(document); + const now = Date.now(); + await db[PDF_TABLE].put({ + ...document, + cacheCreatedAt: now, + cacheAccessedAt: now, + cacheByteSize: document.data.byteLength, + }); + await pruneDocumentCacheIfNeededInternal(); }); } @@ -465,7 +643,11 @@ export async function getPdfDocument(id: string): Promise { console.log('Fetching PDF document via Dexie:', id); const resolved = await getMappedDocumentId(id); - return db[PDF_TABLE].get(resolved); + const row = await db[PDF_TABLE].get(resolved); + if (row) { + await db[PDF_TABLE].update(resolved, { cacheAccessedAt: Date.now() }); + } + return row; }); } @@ -480,9 +662,10 @@ export async function removePdfDocument(id: string): Promise { await withDB(async () => { console.log('Removing PDF document via Dexie:', id); const resolved = await getMappedDocumentId(id); - await db.transaction('readwrite', db[PDF_TABLE], db[LAST_LOCATION_TABLE], async () => { + await db.transaction('readwrite', db[PDF_TABLE], db[LAST_LOCATION_TABLE], db[PREVIEW_CACHE_TABLE], async () => { await db[PDF_TABLE].delete(resolved); await db[LAST_LOCATION_TABLE].delete(resolved); + await db[PREVIEW_CACHE_TABLE].delete(resolved); }); }); } @@ -508,7 +691,14 @@ export async function addEpubDocument(document: EPUBDocument): Promise { actualSize: document.data.byteLength, }); - await db[EPUB_TABLE].put(document); + const now = Date.now(); + await db[EPUB_TABLE].put({ + ...document, + cacheCreatedAt: now, + cacheAccessedAt: now, + cacheByteSize: document.data.byteLength, + }); + await pruneDocumentCacheIfNeededInternal(); }); } @@ -516,7 +706,11 @@ export async function getEpubDocument(id: string): Promise { console.log('Fetching EPUB document via Dexie:', id); const resolved = await getMappedDocumentId(id); - return db[EPUB_TABLE].get(resolved); + const row = await db[EPUB_TABLE].get(resolved); + if (row) { + await db[EPUB_TABLE].update(resolved, { cacheAccessedAt: Date.now() }); + } + return row; }); } @@ -531,9 +725,10 @@ export async function removeEpubDocument(id: string): Promise { await withDB(async () => { console.log('Removing EPUB document via Dexie:', id); const resolved = await getMappedDocumentId(id); - await db.transaction('readwrite', db[EPUB_TABLE], db[LAST_LOCATION_TABLE], async () => { + await db.transaction('readwrite', db[EPUB_TABLE], db[LAST_LOCATION_TABLE], db[PREVIEW_CACHE_TABLE], async () => { await db[EPUB_TABLE].delete(resolved); await db[LAST_LOCATION_TABLE].delete(resolved); + await db[PREVIEW_CACHE_TABLE].delete(resolved); }); }); } @@ -550,7 +745,14 @@ export async function clearEpubDocuments(): Promise { export async function addHtmlDocument(document: HTMLDocument): Promise { await withDB(async () => { console.log('Adding HTML document via Dexie:', document.name); - await db[HTML_TABLE].put(document); + const now = Date.now(); + await db[HTML_TABLE].put({ + ...document, + cacheCreatedAt: now, + cacheAccessedAt: now, + cacheByteSize: cacheTextEncoder.encode(document.data).byteLength, + }); + await pruneDocumentCacheIfNeededInternal(); }); } @@ -558,7 +760,11 @@ export async function getHtmlDocument(id: string): Promise { console.log('Fetching HTML document via Dexie:', id); const resolved = await getMappedDocumentId(id); - return db[HTML_TABLE].get(resolved); + const row = await db[HTML_TABLE].get(resolved); + if (row) { + await db[HTML_TABLE].update(resolved, { cacheAccessedAt: Date.now() }); + } + return row; }); } @@ -573,7 +779,10 @@ export async function removeHtmlDocument(id: string): Promise { await withDB(async () => { console.log('Removing HTML document via Dexie:', id); const resolved = await getMappedDocumentId(id); - await db[HTML_TABLE].delete(resolved); + await db.transaction('readwrite', db[HTML_TABLE], db[PREVIEW_CACHE_TABLE], async () => { + await db[HTML_TABLE].delete(resolved); + await db[PREVIEW_CACHE_TABLE].delete(resolved); + }); }); } @@ -648,6 +857,45 @@ export async function setFirstVisit(value: boolean): Promise { await updateAppConfig({ firstVisit: value }); } +// Document preview cache helpers + +export async function getDocumentPreviewCache(docId: string): Promise { + return withDB(async () => { + const resolved = await getMappedDocumentId(docId); + const row = await db[PREVIEW_CACHE_TABLE].get(resolved); + if (row) { + await db[PREVIEW_CACHE_TABLE].update(resolved, { cachedAt: Date.now() }); + } + return row; + }); +} + +export async function putDocumentPreviewCache(row: DocumentPreviewCacheRow): Promise { + await withDB(async () => { + const resolved = await getMappedDocumentId(row.docId); + await db[PREVIEW_CACHE_TABLE].put({ + ...row, + docId: resolved, + cachedAt: Date.now(), + byteSize: toPositiveInt(row.byteSize, row.data.byteLength), + }); + await prunePreviewCacheIfNeededInternal(); + }); +} + +export async function removeDocumentPreviewCache(docId: string): Promise { + await withDB(async () => { + const resolved = await getMappedDocumentId(docId); + await db[PREVIEW_CACHE_TABLE].delete(resolved); + }); +} + +export async function clearDocumentPreviewCache(): Promise { + await withDB(async () => { + await db[PREVIEW_CACHE_TABLE].clear(); + }); +} + // Sync helpers (server round-trip) export async function syncDocumentsToServer( diff --git a/src/lib/document-preview-cache.ts b/src/lib/document-preview-cache.ts new file mode 100644 index 0000000..ead268d --- /dev/null +++ b/src/lib/document-preview-cache.ts @@ -0,0 +1,125 @@ +import { + clearDocumentPreviewCache as clearPersistedDocumentPreviewCache, + getDocumentPreviewCache, + putDocumentPreviewCache, + removeDocumentPreviewCache, +} from '@/lib/dexie'; +import { documentPreviewFallbackUrl, documentPreviewPresignUrl } from '@/lib/client-documents'; + +const inMemoryPreviewUrlCache = new Map(); +const inFlightPreviewPrime = new Map>(); + +function revokeIfBlobUrl(url: string | null | undefined): void { + if (!url) return; + if (!url.startsWith('blob:')) return; + try { + URL.revokeObjectURL(url); + } catch { + // ignore + } +} + +export function getInMemoryDocumentPreviewUrl(cacheKey: string): string | null { + return inMemoryPreviewUrlCache.get(cacheKey) || null; +} + +export function setInMemoryDocumentPreviewUrl(cacheKey: string, url: string): void { + const prev = inMemoryPreviewUrlCache.get(cacheKey); + if (prev && prev !== url) { + revokeIfBlobUrl(prev); + } + inMemoryPreviewUrlCache.set(cacheKey, url); +} + +export function clearInMemoryDocumentPreviewCache(): void { + for (const value of inMemoryPreviewUrlCache.values()) { + revokeIfBlobUrl(value); + } + inMemoryPreviewUrlCache.clear(); +} + +export async function getPersistedDocumentPreviewUrl( + docId: string, + lastModified: number, + cacheKey: string, +): Promise { + const row = await getDocumentPreviewCache(docId); + if (!row) return null; + + if (Number(row.lastModified) !== Number(lastModified)) { + await removeDocumentPreviewCache(docId).catch(() => {}); + return null; + } + + const contentType = row.contentType || 'image/jpeg'; + const bytes = row.data; + if (!(bytes instanceof ArrayBuffer) || bytes.byteLength === 0) { + await removeDocumentPreviewCache(docId).catch(() => {}); + return null; + } + + const url = URL.createObjectURL(new Blob([bytes], { type: contentType })); + setInMemoryDocumentPreviewUrl(cacheKey, url); + return url; +} + +export async function primeDocumentPreviewCache( + docId: string, + lastModified: number, + cacheKey: string, + options?: { signal?: AbortSignal }, +): Promise { + const primeKey = `${cacheKey}:${Number(lastModified)}`; + const existingPrime = inFlightPreviewPrime.get(primeKey); + if (existingPrime) { + return existingPrime; + } + + const promise = (async (): Promise => { + const existing = await getPersistedDocumentPreviewUrl(docId, lastModified, cacheKey); + if (existing) return existing; + + const fetchOptions = { + signal: options?.signal, + cache: 'no-store' as const, + }; + + // Prefer presign path for priming so healthy direct object access avoids proxy load. + let res = await fetch(documentPreviewPresignUrl(docId), fetchOptions).catch(() => null); + if (!res || !res.ok) { + res = await fetch(documentPreviewFallbackUrl(docId), fetchOptions).catch(() => null); + } + if (!res || !res.ok) return null; + + const blob = await res.blob(); + const bytes = await blob.arrayBuffer(); + if (bytes.byteLength === 0) return null; + + const contentType = blob.type || 'image/jpeg'; + await putDocumentPreviewCache({ + docId, + lastModified: Number(lastModified), + contentType, + data: bytes, + cachedAt: Date.now(), + }); + + const url = URL.createObjectURL(new Blob([bytes], { type: contentType })); + setInMemoryDocumentPreviewUrl(cacheKey, url); + return url; + })(); + + inFlightPreviewPrime.set(primeKey, promise); + try { + return await promise; + } finally { + if (inFlightPreviewPrime.get(primeKey) === promise) { + inFlightPreviewPrime.delete(primeKey); + } + } +} + +export async function clearAllDocumentPreviewCaches(): Promise { + clearInMemoryDocumentPreviewCache(); + await clearPersistedDocumentPreviewCache(); +} diff --git a/src/lib/server/document-previews-blobstore.ts b/src/lib/server/document-previews-blobstore.ts new file mode 100644 index 0000000..8fce80c --- /dev/null +++ b/src/lib/server/document-previews-blobstore.ts @@ -0,0 +1,148 @@ +import { + GetObjectCommand, + HeadObjectCommand, + PutObjectCommand, +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { deleteDocumentPrefix, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents-blobstore'; +import { getS3Client, getS3Config } from '@/lib/server/s3'; + +const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; +const DEFAULT_NAMESPACE_SEGMENT = '_default'; + +export const DOCUMENT_PREVIEW_VARIANT = 'card-240-jpeg'; +export const DOCUMENT_PREVIEW_FILE_NAME = 'card-240.jpg'; +export const DOCUMENT_PREVIEW_CONTENT_TYPE = 'image/jpeg'; +export const DOCUMENT_PREVIEW_WIDTH = 240; + +function sanitizeNamespace(namespace: string | null): string | null { + if (!namespace) return null; + if (!SAFE_NAMESPACE_REGEX.test(namespace)) return null; + return namespace; +} + +function namespaceSegment(namespace: string | null): string { + const safe = sanitizeNamespace(namespace); + return safe ?? DEFAULT_NAMESPACE_SEGMENT; +} + +function isNodeReadableStream(value: unknown): value is NodeJS.ReadableStream { + return !!value && typeof value === 'object' && 'on' in value && typeof (value as NodeJS.ReadableStream).on === 'function'; +} + +async function streamToBuffer(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + if (Buffer.isBuffer(chunk)) { + chunks.push(chunk); + } else if (typeof chunk === 'string') { + chunks.push(Buffer.from(chunk)); + } else { + chunks.push(Buffer.from(chunk as Uint8Array)); + } + } + return Buffer.concat(chunks); +} + +async function bodyToBuffer(body: unknown): Promise { + if (!body) return Buffer.alloc(0); + + if (body instanceof Uint8Array) return Buffer.from(body); + if (ArrayBuffer.isView(body)) return Buffer.from(body.buffer, body.byteOffset, body.byteLength); + if (body instanceof ArrayBuffer) return Buffer.from(body); + + if (typeof body === 'object' && body !== null && 'transformToByteArray' in body) { + const maybe = body as { transformToByteArray?: () => Promise }; + if (typeof maybe.transformToByteArray === 'function') { + return Buffer.from(await maybe.transformToByteArray()); + } + } + + if (isNodeReadableStream(body)) { + return streamToBuffer(body); + } + + throw new Error('Unsupported S3 response body type'); +} + +export function documentPreviewPrefix(documentId: string, namespace: string | null): string { + if (!isValidDocumentId(documentId)) { + throw new Error(`Invalid document id: ${documentId}`); + } + + const cfg = getS3Config(); + const ns = namespaceSegment(namespace); + return `${cfg.prefix}/document_previews_v1/ns/${ns}/${documentId}/`; +} + +export function documentPreviewKey(documentId: string, namespace: string | null): string { + return `${documentPreviewPrefix(documentId, namespace)}${DOCUMENT_PREVIEW_FILE_NAME}`; +} + +export async function headDocumentPreview( + documentId: string, + namespace: string | null, +): Promise<{ contentLength: number; contentType: string | null; eTag: string | null }> { + const cfg = getS3Config(); + const client = getS3Client(); + const key = documentPreviewKey(documentId, namespace); + const res = await client.send(new HeadObjectCommand({ Bucket: cfg.bucket, Key: key })); + return { + contentLength: Number(res.ContentLength ?? 0), + contentType: res.ContentType ?? null, + eTag: res.ETag ?? null, + }; +} + +export async function getDocumentPreviewBuffer(documentId: string, namespace: string | null): Promise { + const cfg = getS3Config(); + const client = getS3Client(); + const key = documentPreviewKey(documentId, namespace); + const res = await client.send(new GetObjectCommand({ Bucket: cfg.bucket, Key: key })); + return bodyToBuffer(res.Body); +} + +export async function putDocumentPreviewBuffer( + documentId: string, + bytes: Buffer, + namespace: string | null, + options?: { ifNoneMatch?: boolean }, +): Promise { + const cfg = getS3Config(); + const client = getS3Client(); + const key = documentPreviewKey(documentId, namespace); + await client.send( + new PutObjectCommand({ + Bucket: cfg.bucket, + Key: key, + Body: bytes, + ContentType: DOCUMENT_PREVIEW_CONTENT_TYPE, + ...(options?.ifNoneMatch ? { IfNoneMatch: '*' } : {}), + }), + ); +} + +export async function presignDocumentPreviewGet( + documentId: string, + namespace: string | null, + options?: { expiresInSeconds?: number }, +): Promise { + const cfg = getS3Config(); + const client = getS3Client(); + const key = documentPreviewKey(documentId, namespace); + return getSignedUrl( + client, + new GetObjectCommand({ + Bucket: cfg.bucket, + Key: key, + ResponseContentType: DOCUMENT_PREVIEW_CONTENT_TYPE, + }), + { expiresIn: Math.max(30, Math.min(options?.expiresInSeconds ?? 300, 3600)) }, + ); +} + +export async function deleteDocumentPreviewArtifacts(documentId: string, namespace: string | null): Promise { + return deleteDocumentPrefix(documentPreviewPrefix(documentId, namespace)); +} + +export { isMissingBlobError }; diff --git a/src/lib/server/document-previews-render.ts b/src/lib/server/document-previews-render.ts new file mode 100644 index 0000000..87415c5 --- /dev/null +++ b/src/lib/server/document-previews-render.ts @@ -0,0 +1,258 @@ +import path from 'path'; +import { DOMMatrix, Path2D, createCanvas, loadImage } from '@napi-rs/canvas'; +import JSZip from 'jszip'; +import { XMLParser } from 'fast-xml-parser'; + +export type RenderedDocumentPreview = { + bytes: Buffer; + width: number; + height: number; +}; + +type CanvasAndContext = { + canvas: unknown; + context: unknown; +}; + +type NodeCanvasFactory = { + create: (width: number, height: number) => CanvasAndContext; + reset: (target: CanvasAndContext, width: number, height: number) => void; + destroy: (target: CanvasAndContext) => void; +}; + +function ensureNodeCanvasGlobals(): void { + const g = globalThis as Record; + if (typeof g.DOMMatrix === 'undefined') { + g.DOMMatrix = DOMMatrix as unknown; + } + if (typeof g.Path2D === 'undefined') { + g.Path2D = Path2D as unknown; + } +} + +function normalizeTargetWidth(targetWidth: number): number { + if (!Number.isFinite(targetWidth) || targetWidth <= 0) return 240; + return Math.max(64, Math.min(2048, Math.round(targetWidth))); +} + +function asArray(value: T | T[] | undefined): T[] { + if (!value) return []; + return Array.isArray(value) ? value : [value]; +} + +function findBestEpubCoverPath(opfPath: string, opfXml: string): string | null { + const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + removeNSPrefix: true, + }); + const parsed = parser.parse(opfXml) as Record; + const pkg = (parsed.package ?? parsed['opf:package']) as Record | undefined; + if (!pkg) return null; + + const metadata = pkg.metadata as Record | undefined; + const manifest = pkg.manifest as Record | undefined; + const items = asArray((manifest?.item as Record | Array> | undefined)) + .filter((item) => typeof item === 'object' && item !== null); + + const coverMetaId = asArray((metadata?.meta as Record | Array> | undefined)) + .find((meta) => { + const name = String(meta?.['@_name'] ?? '').trim().toLowerCase(); + return name === 'cover'; + })?.['@_content']; + + const byCoverProperty = items.find((item) => + String(item['@_properties'] ?? '') + .split(/\s+/) + .map((value) => value.trim().toLowerCase()) + .includes('cover-image'), + ); + const byMetaRef = coverMetaId + ? items.find((item) => String(item['@_id'] ?? '') === String(coverMetaId)) + : null; + const byNameHint = items.find((item) => String(item['@_id'] ?? '').toLowerCase().includes('cover')); + const byImageType = items.find((item) => String(item['@_media-type'] ?? '').toLowerCase().startsWith('image/')); + + const selected = byCoverProperty ?? byMetaRef ?? byNameHint ?? byImageType; + if (!selected) return null; + + const href = String(selected['@_href'] ?? '').trim(); + if (!href) return null; + + const opfDir = path.posix.dirname(opfPath); + return path.posix.normalize(path.posix.join(opfDir, href)); +} + +async function renderImageBytesToJpeg(imageBytes: Buffer, targetWidth: number): Promise { + const bitmap = await loadImage(imageBytes); + const width = bitmap.width; + const height = bitmap.height; + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + throw new Error('Invalid source image dimensions'); + } + + const target = normalizeTargetWidth(targetWidth); + const scale = target / width; + const outWidth = Math.max(1, Math.round(width * scale)); + const outHeight = Math.max(1, Math.round(height * scale)); + const canvas = createCanvas(outWidth, outHeight); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, outWidth, outHeight); + ctx.drawImage(bitmap, 0, 0, outWidth, outHeight); + return { + bytes: canvas.toBuffer('image/jpeg', 82), + width: outWidth, + height: outHeight, + }; +} + +export async function renderEpubCoverToJpeg(sourceBytes: Buffer, targetWidth: number): Promise { + const zip = await JSZip.loadAsync(sourceBytes); + const containerFile = zip.file('META-INF/container.xml'); + if (!containerFile) { + throw new Error('EPUB container.xml not found'); + } + + const containerXml = await containerFile.async('string'); + const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + removeNSPrefix: true, + }); + const containerParsed = parser.parse(containerXml) as Record; + const rootfilesNode = (containerParsed.container as Record | undefined)?.rootfiles as + | Record + | undefined; + const rootfiles = asArray(rootfilesNode?.rootfile as Record | Array> | undefined); + const rootfilePath = String(rootfiles[0]?.['@_full-path'] ?? '').trim(); + if (!rootfilePath) { + throw new Error('EPUB OPF rootfile path missing'); + } + + const opfFile = zip.file(rootfilePath) ?? zip.file(decodeURI(rootfilePath)); + if (!opfFile) { + throw new Error(`EPUB OPF not found at ${rootfilePath}`); + } + const opfXml = await opfFile.async('string'); + const coverPath = findBestEpubCoverPath(rootfilePath, opfXml); + if (!coverPath) { + throw new Error('EPUB cover image not found'); + } + + const coverFile = zip.file(coverPath) ?? zip.file(decodeURI(coverPath)); + if (!coverFile) { + throw new Error(`EPUB cover file missing at ${coverPath}`); + } + + const coverBytes = await coverFile.async('nodebuffer'); + return renderImageBytesToJpeg(coverBytes, targetWidth); +} + +export async function renderPdfFirstPageToJpeg(sourceBytes: Buffer, targetWidth: number): Promise { + ensureNodeCanvasGlobals(); + + type PdfViewport = { + width: number; + height: number; + }; + type PdfPage = { + getViewport: (params: { scale: number }) => PdfViewport; + render: (params: { + canvasContext: unknown; + viewport: PdfViewport; + intent: 'display'; + canvasFactory?: NodeCanvasFactory; + }) => { promise: Promise }; + }; + + // pdfjs-dist legacy build works in Node and avoids relying on DOM workers. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - pdfjs-dist legacy build path has no dedicated TypeScript declaration. + const pdfjs = (await import('pdfjs-dist/legacy/build/pdf.mjs')) as { + getDocument: (options: Record) => { + promise: Promise<{ getPage: (n: number) => Promise; destroy: () => Promise }>; + destroy: () => Promise; + }; + GlobalWorkerOptions?: { workerSrc?: string; workerPort?: unknown }; + }; + + if (pdfjs.GlobalWorkerOptions) { + pdfjs.GlobalWorkerOptions.workerSrc = 'pdfjs-dist/legacy/build/pdf.worker.mjs'; + pdfjs.GlobalWorkerOptions.workerPort = null; + } + + const standardFontDir = path.join(process.cwd(), 'node_modules', 'pdfjs-dist', 'standard_fonts'); + const standardFontDataUrl = `${standardFontDir.replace(/\/?$/, '/')}`; + + const nodeCanvasFactory: NodeCanvasFactory = { + create: (width, height) => { + const canvas = createCanvas(width, height); + const context = canvas.getContext('2d'); + return { canvas, context }; + }, + reset: (target, width, height) => { + const canvas = target.canvas as { width: number; height: number }; + canvas.width = width; + canvas.height = height; + }, + destroy: (target) => { + const canvas = target.canvas as { width: number; height: number }; + canvas.width = 0; + canvas.height = 0; + }, + }; + + class PdfNodeCanvasFactory { + create(width: number, height: number): CanvasAndContext { + return nodeCanvasFactory.create(width, height); + } + reset(target: CanvasAndContext, width: number, height: number): void { + nodeCanvasFactory.reset(target, width, height); + } + destroy(target: CanvasAndContext): void { + nodeCanvasFactory.destroy(target); + } + } + + const loadingTask = pdfjs.getDocument({ + data: new Uint8Array(sourceBytes), + useWorkerFetch: false, + standardFontDataUrl, + CanvasFactory: PdfNodeCanvasFactory, + isEvalSupported: false, + }); + const pdf = await loadingTask.promise; + + try { + const page = await pdf.getPage(1); + const viewport = page.getViewport({ scale: 1 }); + const target = normalizeTargetWidth(targetWidth); + const scale = target / viewport.width; + const scaledViewport = page.getViewport({ scale }); + + const outWidth = Math.max(1, Math.floor(scaledViewport.width)); + const outHeight = Math.max(1, Math.floor(scaledViewport.height)); + const canvas = createCanvas(outWidth, outHeight); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, outWidth, outHeight); + + const renderTask = page.render({ + canvasContext: ctx, + viewport: scaledViewport, + intent: 'display', + canvasFactory: nodeCanvasFactory, + }); + await renderTask.promise; + + return { + bytes: canvas.toBuffer('image/jpeg', 82), + width: outWidth, + height: outHeight, + }; + } finally { + await pdf.destroy().catch(() => undefined); + await loadingTask.destroy().catch(() => undefined); + } +} diff --git a/src/lib/server/document-previews.ts b/src/lib/server/document-previews.ts new file mode 100644 index 0000000..3089de2 --- /dev/null +++ b/src/lib/server/document-previews.ts @@ -0,0 +1,427 @@ +import { randomUUID } from 'crypto'; +import { mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { and, eq, inArray, lt, or, sql } from 'drizzle-orm'; +import { db } from '@/db'; +import { documentPreviews } from '@/db/schema'; +import { + DOCUMENT_PREVIEW_CONTENT_TYPE, + DOCUMENT_PREVIEW_VARIANT, + DOCUMENT_PREVIEW_WIDTH, + deleteDocumentPreviewArtifacts, + documentPreviewKey, + headDocumentPreview, + isMissingBlobError, + putDocumentPreviewBuffer, +} from '@/lib/server/document-previews-blobstore'; +import { getDocumentBlob } from '@/lib/server/documents-blobstore'; +import { renderEpubCoverToJpeg, renderPdfFirstPageToJpeg } from '@/lib/server/document-previews-render'; + +const LEASE_MS = 45_000; +const RETRY_AFTER_MS = 1_500; +const FAILED_RETRY_AFTER_MS = 15_000; + +type PreviewStatus = 'queued' | 'processing' | 'ready' | 'failed'; + +type PreviewRow = { + documentId: string; + namespace: string; + variant: string; + status: PreviewStatus; + sourceLastModifiedMs: number; + objectKey: string; + contentType: string; + width: number; + height: number | null; + byteSize: number | null; + eTag: string | null; + leaseOwner: string | null; + leaseUntilMs: number; + attemptCount: number; + lastError: string | null; + createdAtMs: number; + updatedAtMs: number; +}; + +export type PreviewableDocumentType = 'pdf' | 'epub'; + +export type PreviewSourceDocument = { + id: string; + type: string; + lastModified: number; +}; + +export type EnsureDocumentPreviewResult = + | { + state: 'ready'; + status: 'ready'; + contentType: string; + width: number; + height: number | null; + byteSize: number | null; + eTag: string | null; + } + | { + state: 'pending'; + status: Exclude; + retryAfterMs: number; + lastError: string | null; + }; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function safeDb(): any { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return db as any; +} + +function nowMs(): number { + return Date.now(); +} + +function rowsAffected(result: unknown): number { + if (!result || typeof result !== 'object') return 0; + const rec = result as Record; + if (typeof rec.rowCount === 'number') return rec.rowCount; + if (typeof rec.changes === 'number') return rec.changes; + return 0; +} + +function toNamespaceKey(namespace: string | null): string { + return namespace?.trim() || ''; +} + +function previewObjectKey(documentId: string, namespace: string | null): string { + return documentPreviewKey(documentId, namespace); +} + +function asPreviewStatus(status: string | null | undefined): PreviewStatus { + if (status === 'processing' || status === 'ready' || status === 'failed') return status; + return 'queued'; +} + +function toPreviewRow(raw: Record): PreviewRow { + return { + documentId: String(raw.documentId ?? ''), + namespace: String(raw.namespace ?? ''), + variant: String(raw.variant ?? ''), + status: asPreviewStatus(String(raw.status ?? 'queued')), + sourceLastModifiedMs: Number(raw.sourceLastModifiedMs ?? 0), + objectKey: String(raw.objectKey ?? ''), + contentType: String(raw.contentType ?? DOCUMENT_PREVIEW_CONTENT_TYPE), + width: Number(raw.width ?? DOCUMENT_PREVIEW_WIDTH), + height: raw.height == null ? null : Number(raw.height), + byteSize: raw.byteSize == null ? null : Number(raw.byteSize), + eTag: raw.eTag == null ? null : String(raw.eTag), + leaseOwner: raw.leaseOwner == null ? null : String(raw.leaseOwner), + leaseUntilMs: Number(raw.leaseUntilMs ?? 0), + attemptCount: Number(raw.attemptCount ?? 0), + lastError: raw.lastError == null ? null : String(raw.lastError), + createdAtMs: Number(raw.createdAtMs ?? 0), + updatedAtMs: Number(raw.updatedAtMs ?? 0), + }; +} + +export function isPreviewableDocumentType(type: string): type is PreviewableDocumentType { + return type === 'pdf' || type === 'epub'; +} + +async function getPreviewRow(documentId: string, namespaceKey: string): Promise { + const rows = (await safeDb() + .select() + .from(documentPreviews) + .where( + and( + eq(documentPreviews.documentId, documentId), + eq(documentPreviews.namespace, namespaceKey), + eq(documentPreviews.variant, DOCUMENT_PREVIEW_VARIANT), + ), + )) as Array>; + const row = rows[0]; + return row ? toPreviewRow(row) : null; +} + +async function ensurePreviewRowExists(doc: PreviewSourceDocument, namespaceKey: string, namespace: string | null): Promise { + const now = nowMs(); + await safeDb() + .insert(documentPreviews) + .values({ + documentId: doc.id, + namespace: namespaceKey, + variant: DOCUMENT_PREVIEW_VARIANT, + status: 'queued', + sourceLastModifiedMs: doc.lastModified, + objectKey: previewObjectKey(doc.id, namespace), + contentType: DOCUMENT_PREVIEW_CONTENT_TYPE, + width: DOCUMENT_PREVIEW_WIDTH, + leaseUntilMs: 0, + attemptCount: 0, + createdAtMs: now, + updatedAtMs: now, + }) + .onConflictDoNothing(); +} + +async function markPreviewRowQueued( + doc: PreviewSourceDocument, + namespaceKey: string, + namespace: string | null, +): Promise { + const now = nowMs(); + await safeDb() + .update(documentPreviews) + .set({ + status: 'queued', + sourceLastModifiedMs: doc.lastModified, + objectKey: previewObjectKey(doc.id, namespace), + contentType: DOCUMENT_PREVIEW_CONTENT_TYPE, + width: DOCUMENT_PREVIEW_WIDTH, + height: null, + byteSize: null, + eTag: null, + leaseOwner: null, + leaseUntilMs: 0, + lastError: null, + updatedAtMs: now, + }) + .where( + and( + eq(documentPreviews.documentId, doc.id), + eq(documentPreviews.namespace, namespaceKey), + eq(documentPreviews.variant, DOCUMENT_PREVIEW_VARIANT), + ), + ); +} + +function needsRequeue(row: PreviewRow, doc: PreviewSourceDocument, namespace: string | null): boolean { + if (row.sourceLastModifiedMs !== doc.lastModified) return true; + if (row.objectKey !== previewObjectKey(doc.id, namespace)) return true; + return false; +} + +async function isReadyBlobMissing(docId: string, namespace: string | null): Promise { + try { + await headDocumentPreview(docId, namespace); + return false; + } catch (error) { + if (isMissingBlobError(error)) return true; + throw error; + } +} + +async function tryClaimPreviewLease(docId: string, namespaceKey: string, owner: string): Promise { + const now = nowMs(); + const result = await safeDb() + .update(documentPreviews) + .set({ + status: 'processing', + leaseOwner: owner, + leaseUntilMs: now + LEASE_MS, + attemptCount: sql`${documentPreviews.attemptCount} + 1`, + lastError: null, + updatedAtMs: now, + }) + .where( + and( + eq(documentPreviews.documentId, docId), + eq(documentPreviews.namespace, namespaceKey), + eq(documentPreviews.variant, DOCUMENT_PREVIEW_VARIANT), + or( + inArray(documentPreviews.status, ['queued', 'failed']), + and( + eq(documentPreviews.status, 'processing'), + lt(documentPreviews.leaseUntilMs, now), + ), + ), + ), + ); + return rowsAffected(result) > 0; +} + +async function markPreviewReady( + doc: PreviewSourceDocument, + namespaceKey: string, + content: { width: number; height: number | null; byteSize: number; eTag: string | null }, +): Promise { + const now = nowMs(); + await safeDb() + .update(documentPreviews) + .set({ + status: 'ready', + sourceLastModifiedMs: doc.lastModified, + contentType: DOCUMENT_PREVIEW_CONTENT_TYPE, + width: content.width, + height: content.height, + byteSize: content.byteSize, + eTag: content.eTag, + leaseOwner: null, + leaseUntilMs: 0, + lastError: null, + updatedAtMs: now, + }) + .where( + and( + eq(documentPreviews.documentId, doc.id), + eq(documentPreviews.namespace, namespaceKey), + eq(documentPreviews.variant, DOCUMENT_PREVIEW_VARIANT), + ), + ); +} + +async function markPreviewFailed(docId: string, namespaceKey: string, error: unknown): Promise { + const now = nowMs(); + const message = error instanceof Error ? error.message : String(error ?? 'Preview generation failed'); + await safeDb() + .update(documentPreviews) + .set({ + status: 'failed', + leaseOwner: null, + leaseUntilMs: 0, + lastError: message.slice(0, 1000), + updatedAtMs: now, + }) + .where( + and( + eq(documentPreviews.documentId, docId), + eq(documentPreviews.namespace, namespaceKey), + eq(documentPreviews.variant, DOCUMENT_PREVIEW_VARIANT), + ), + ); +} + +async function generateAndStorePreview(doc: PreviewSourceDocument, namespace: string | null): Promise { + let workDir: string | null = null; + try { + const sourceBytes = await getDocumentBlob(doc.id, namespace); + workDir = await mkdtemp(join(tmpdir(), 'openreader-preview-')); + const sourcePath = join(workDir, 'source'); + await writeFile(sourcePath, sourceBytes); + + let rendered; + if (doc.type === 'pdf') { + rendered = await renderPdfFirstPageToJpeg(sourceBytes, DOCUMENT_PREVIEW_WIDTH); + } else if (doc.type === 'epub') { + rendered = await renderEpubCoverToJpeg(sourceBytes, DOCUMENT_PREVIEW_WIDTH); + } else { + throw new Error(`Unsupported preview type: ${doc.type}`); + } + + await putDocumentPreviewBuffer(doc.id, rendered.bytes, namespace); + } finally { + if (workDir) { + await rm(workDir, { recursive: true, force: true }).catch(() => {}); + } + } +} + +function pendingResult(status: PreviewStatus, lastError: string | null): EnsureDocumentPreviewResult { + return { + state: 'pending', + status: status === 'ready' ? 'processing' : status, + retryAfterMs: status === 'failed' ? FAILED_RETRY_AFTER_MS : RETRY_AFTER_MS, + lastError, + }; +} + +export async function enqueueDocumentPreview(doc: PreviewSourceDocument, namespace: string | null): Promise { + if (!isPreviewableDocumentType(doc.type)) return; + const namespaceKey = toNamespaceKey(namespace); + await ensurePreviewRowExists(doc, namespaceKey, namespace); + const row = await getPreviewRow(doc.id, namespaceKey); + if (!row || needsRequeue(row, doc, namespace) || row.status === 'failed') { + await markPreviewRowQueued(doc, namespaceKey, namespace); + } +} + +export async function ensureDocumentPreview(doc: PreviewSourceDocument, namespace: string | null): Promise { + if (!isPreviewableDocumentType(doc.type)) { + return pendingResult('failed', `Unsupported preview type: ${doc.type}`); + } + + const namespaceKey = toNamespaceKey(namespace); + await ensurePreviewRowExists(doc, namespaceKey, namespace); + + let row = await getPreviewRow(doc.id, namespaceKey); + if (!row) { + return pendingResult('queued', null); + } + + if (needsRequeue(row, doc, namespace)) { + await markPreviewRowQueued(doc, namespaceKey, namespace); + row = await getPreviewRow(doc.id, namespaceKey); + if (!row) return pendingResult('queued', null); + } + + if (row.status === 'ready') { + const missing = await isReadyBlobMissing(doc.id, namespace); + if (!missing) { + return { + state: 'ready', + status: 'ready', + contentType: row.contentType || DOCUMENT_PREVIEW_CONTENT_TYPE, + width: row.width || DOCUMENT_PREVIEW_WIDTH, + height: row.height, + byteSize: row.byteSize, + eTag: row.eTag, + }; + } + await markPreviewRowQueued(doc, namespaceKey, namespace); + row = await getPreviewRow(doc.id, namespaceKey); + if (!row) return pendingResult('queued', null); + } + + const now = nowMs(); + if (row.status === 'processing' && row.leaseUntilMs > now) { + return pendingResult('processing', row.lastError); + } + + const owner = `req-${randomUUID()}`; + const claimed = await tryClaimPreviewLease(doc.id, namespaceKey, owner); + if (claimed) { + try { + await generateAndStorePreview(doc, namespace); + const head = await headDocumentPreview(doc.id, namespace); + await markPreviewReady(doc, namespaceKey, { + width: DOCUMENT_PREVIEW_WIDTH, + height: null, + byteSize: head.contentLength, + eTag: head.eTag, + }); + } catch (error) { + await markPreviewFailed(doc.id, namespaceKey, error); + } + } + + row = await getPreviewRow(doc.id, namespaceKey); + if (!row) return pendingResult('queued', null); + + if (row.status === 'ready') { + const missing = await isReadyBlobMissing(doc.id, namespace); + if (!missing) { + return { + state: 'ready', + status: 'ready', + contentType: row.contentType || DOCUMENT_PREVIEW_CONTENT_TYPE, + width: row.width || DOCUMENT_PREVIEW_WIDTH, + height: row.height, + byteSize: row.byteSize, + eTag: row.eTag, + }; + } + await markPreviewRowQueued(doc, namespaceKey, namespace); + return pendingResult('queued', null); + } + + return pendingResult(row.status, row.lastError); +} + +export async function deleteDocumentPreviewRows(documentId: string, namespace: string | null): Promise { + const namespaceKey = toNamespaceKey(namespace); + await safeDb() + .delete(documentPreviews) + .where(and(eq(documentPreviews.documentId, documentId), eq(documentPreviews.namespace, namespaceKey))); +} + +export async function cleanupDocumentPreviewArtifacts(documentId: string, namespace: string | null): Promise { + await deleteDocumentPreviewArtifacts(documentId, namespace); +} diff --git a/src/lib/server/documents-blobstore.ts b/src/lib/server/documents-blobstore.ts index dad8092..dedfe29 100644 --- a/src/lib/server/documents-blobstore.ts +++ b/src/lib/server/documents-blobstore.ts @@ -146,6 +146,24 @@ export async function getDocumentBlob(id: string, namespace: string | null): Pro return bodyToBuffer(res.Body); } +export async function presignGet( + id: string, + namespace: string | null, + options?: { expiresInSeconds?: number }, +): Promise { + const cfg = getS3Config(); + const client = getS3Client(); + const key = documentKey(id, namespace); + return getSignedUrl( + client, + new GetObjectCommand({ + Bucket: cfg.bucket, + Key: key, + }), + { expiresIn: Math.max(30, Math.min(options?.expiresInSeconds ?? 300, 3600)) }, + ); +} + export async function putDocumentBlob( id: string, body: Buffer, diff --git a/tests/unit/document-previews-render.spec.ts b/tests/unit/document-previews-render.spec.ts new file mode 100644 index 0000000..4e815d8 --- /dev/null +++ b/tests/unit/document-previews-render.spec.ts @@ -0,0 +1,29 @@ +import { test, expect } from '@playwright/test'; +import path from 'path'; +import { readFile } from 'fs/promises'; +import { + renderEpubCoverToJpeg, + renderPdfFirstPageToJpeg, +} from '../../src/lib/server/document-previews-render'; + +test.describe('document-previews-render', () => { + test('renders first PDF page to JPEG preview', async () => { + const pdfPath = path.join(process.cwd(), 'tests/files/sample.pdf'); + const bytes = await readFile(pdfPath); + const rendered = await renderPdfFirstPageToJpeg(bytes, 240); + + expect(rendered.bytes.byteLength).toBeGreaterThan(1024); + expect(rendered.width).toBe(240); + expect(rendered.height).toBeGreaterThan(0); + }); + + test('extracts EPUB cover and renders to JPEG preview', async () => { + const epubPath = path.join(process.cwd(), 'tests/files/sample.epub'); + const bytes = await readFile(epubPath); + const rendered = await renderEpubCoverToJpeg(bytes, 240); + + expect(rendered.bytes.byteLength).toBeGreaterThan(1024); + expect(rendered.width).toBe(240); + expect(rendered.height).toBeGreaterThan(0); + }); +}); From 1f4794a706f06cdb5bf8c4eec29af043e5688960 Mon Sep 17 00:00:00 2001 From: Richard R Date: Sun, 15 Feb 2026 11:12:42 -0700 Subject: [PATCH 25/55] feat(auth): implement session gatekeeping and architectural reorganization - Introduce `USE_ANONYMOUS_AUTH_SESSIONS` to make guest access an opt-in feature. - Transition root-level pages into `(app)` and `(public)` route groups for improved access control. - Adopt Figtree as the primary typeface and implement a pre-render theme initialization script. - Decouple audiobook chapter processing into a standalone API route and harden resource ownership logic. - Replace the legacy footer with a unified layout and add skeleton loading states for document lists. - Update environment documentation and test suites to align with the revised routing and auth flows. BREAKING CHANGE: The audiobook generation API has been restructured, moving specific chapter logic to `/api/audiobook/chapter`. --- .env.example | 2 + .gitignore | 3 + docs-site/docs/configure/auth.md | 9 + .../docs/reference/environment-variables.md | 10 + playwright.config.ts | 2 +- src/app/{ => (app)/app}/page.tsx | 0 src/app/{ => (app)}/epub/[id]/page.tsx | 4 +- src/app/{ => (app)}/html/[id]/page.tsx | 4 +- src/app/(app)/layout.tsx | 62 +++ src/app/{ => (app)}/pdf/[id]/page.tsx | 4 +- src/app/{ => (app)}/signin/page.tsx | 28 +- src/app/{ => (app)}/signup/page.tsx | 4 +- src/app/(public)/layout.tsx | 327 ++++++++++++ src/app/(public)/page.tsx | 479 ++++++++++++++++++ src/app/(public)/privacy/page.tsx | 200 ++++++++ src/app/api/audiobook/chapter/route.ts | 437 +++++++++++++++- src/app/api/audiobook/route.ts | 271 ++-------- src/app/api/audiobook/status/route.ts | 26 +- src/app/globals.css | 6 +- src/app/layout.tsx | 113 ++--- src/app/providers.tsx | 34 +- src/components/AudiobookExportModal.tsx | 8 +- src/components/Footer.tsx | 56 -- src/components/HomeContent.tsx | 11 +- src/components/PrivacyModal.tsx | 43 +- src/components/SettingsModal.tsx | 24 +- src/components/auth/AuthLoader.tsx | 67 ++- src/components/auth/UserMenu.tsx | 5 +- src/components/doclist/DocumentList.tsx | 3 +- .../doclist/DocumentListSkeleton.tsx | 49 ++ src/contexts/AuthRateLimitContext.tsx | 14 +- src/lib/client.ts | 2 +- src/lib/server/audiobook-scope.ts | 25 + src/lib/server/auth-config.ts | 19 + src/lib/server/auth.ts | 134 ++--- tests/export.spec.ts | 10 +- tests/helpers.ts | 61 ++- tests/landing-routing.spec.ts | 53 ++ tests/unit/audiobook-scope.spec.ts | 36 ++ tests/unit/auth-config.spec.ts | 64 +++ 40 files changed, 2146 insertions(+), 563 deletions(-) rename src/app/{ => (app)/app}/page.tsx (100%) rename src/app/{ => (app)}/epub/[id]/page.tsx (99%) rename src/app/{ => (app)}/html/[id]/page.tsx (99%) create mode 100644 src/app/(app)/layout.tsx rename src/app/{ => (app)}/pdf/[id]/page.tsx (99%) rename src/app/{ => (app)}/signin/page.tsx (93%) rename src/app/{ => (app)}/signup/page.tsx (99%) create mode 100644 src/app/(public)/layout.tsx create mode 100644 src/app/(public)/page.tsx create mode 100644 src/app/(public)/privacy/page.tsx delete mode 100644 src/components/Footer.tsx create mode 100644 src/components/doclist/DocumentListSkeleton.tsx create mode 100644 src/lib/server/audiobook-scope.ts create mode 100644 tests/landing-routing.spec.ts create mode 100644 tests/unit/audiobook-scope.spec.ts create mode 100644 tests/unit/auth-config.spec.ts diff --git a/.env.example b/.env.example index 4e1a1ad..706aaf5 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,8 @@ API_KEY=api_key_optional BASE_URL=http://localhost:3003 # Externally facing URL for this app (set to LAN IP for access from other devices on the network) AUTH_SECRET=some_random_secret_key # Generate with `openssl rand -base64 32` AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 # Additional trusted origins (BASE_URL is always trusted) +# (Optional) Allow anonymous auth sessions when auth is enabled (default: `false`) +USE_ANONYMOUS_AUTH_SESSIONS= # (Optional) Sign in w/ GitHub Configuration GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= diff --git a/.gitignore b/.gitignore index 38f5d2c..6d5cc68 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,6 @@ node_modules/ # vscode .vscode + +# .agents +.agents diff --git a/docs-site/docs/configure/auth.md b/docs-site/docs/configure/auth.md index fff001b..c89400c 100644 --- a/docs-site/docs/configure/auth.md +++ b/docs-site/docs/configure/auth.md @@ -9,6 +9,15 @@ This page covers application-level configuration for provider access and authent - Auth is enabled only when both `BASE_URL` and `AUTH_SECRET` are set. - Remove either value to disable auth. - Keep `AUTH_TRUSTED_ORIGINS` empty to trust only `BASE_URL`. +- Anonymous auth sessions are disabled by default. +- Set `USE_ANONYMOUS_AUTH_SESSIONS=true` to enable anonymous session flows. + +## Route behavior + +- `/` is a public landing/onboarding page and remains indexable. +- `/app` is the protected app home (document list and uploader UI). +- If auth is enabled and a valid session exists (including anonymous), visiting `/` redirects to `/app`. +- Protected app routes continue to require auth; when anonymous sessions are disabled and no session exists, users are redirected to `/signin`. ## Related docs diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index 85fa157..b97350a 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -28,6 +28,7 @@ This is the single reference page for OpenReader WebUI environment variables. | `BASE_URL` | Auth | unset | Required (with `AUTH_SECRET`) to enable auth | | `AUTH_SECRET` | Auth | unset | Required (with `BASE_URL`) to enable auth | | `AUTH_TRUSTED_ORIGINS` | Auth | empty | Add extra allowed origins | +| `USE_ANONYMOUS_AUTH_SESSIONS` | Auth | `false` | Set `true` to enable anonymous auth sessions | | `GITHUB_CLIENT_ID` | Auth/OAuth | unset | Set with `GITHUB_CLIENT_SECRET` to enable GitHub sign-in | | `GITHUB_CLIENT_SECRET` | Auth/OAuth | unset | Set with `GITHUB_CLIENT_ID` to enable GitHub sign-in | | `DISABLE_AUTH_RATE_LIMIT` | Rate limiting | `false` | Set `true` to disable auth-layer rate limiting | @@ -201,6 +202,15 @@ Additional allowed origins for auth requests. - `BASE_URL` origin is always trusted automatically - Related docs: [Auth](../configure/auth) +### USE_ANONYMOUS_AUTH_SESSIONS + +Controls whether auth-enabled deployments can create/use anonymous sessions. + +- Default: `false` (anonymous sessions disabled) +- Set `true` to allow anonymous sessions and guest-style flows +- When `false`, users must sign in or sign up with an account +- Related docs: [Auth](../configure/auth) + ### GITHUB_CLIENT_ID GitHub OAuth client ID. diff --git a/playwright.config.ts b/playwright.config.ts index c7aa815..02c4d55 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -28,7 +28,7 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { // Disable auth rate limiting for tests to support parallel workers creating sessions - command: 'npm run build && DISABLE_AUTH_RATE_LIMIT=true npm run start', + command: `npm run build && DISABLE_AUTH_RATE_LIMIT=true npm run start`, url: 'http://localhost:3003', reuseExistingServer: !process.env.CI, timeout: 120 * 1000, diff --git a/src/app/page.tsx b/src/app/(app)/app/page.tsx similarity index 100% rename from src/app/page.tsx rename to src/app/(app)/app/page.tsx diff --git a/src/app/epub/[id]/page.tsx b/src/app/(app)/epub/[id]/page.tsx similarity index 99% rename from src/app/epub/[id]/page.tsx rename to src/app/(app)/epub/[id]/page.tsx index b2b4c97..f80e05f 100644 --- a/src/app/epub/[id]/page.tsx +++ b/src/app/(app)/epub/[id]/page.tsx @@ -144,7 +144,7 @@ export default function EPUBPage() {

{error}

clearCurrDoc()} className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" > @@ -162,7 +162,7 @@ export default function EPUBPage() {
clearCurrDoc()} className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" aria-label="Back to documents" diff --git a/src/app/html/[id]/page.tsx b/src/app/(app)/html/[id]/page.tsx similarity index 99% rename from src/app/html/[id]/page.tsx rename to src/app/(app)/html/[id]/page.tsx index c9cfba0..f7922fd 100644 --- a/src/app/html/[id]/page.tsx +++ b/src/app/(app)/html/[id]/page.tsx @@ -113,7 +113,7 @@ export default function HTMLPage() {

{error}

{ clearCurrDoc(); }} className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" > @@ -131,7 +131,7 @@ export default function HTMLPage() {
clearCurrDoc()} className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" aria-label="Back to documents" diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx new file mode 100644 index 0000000..b07c0c4 --- /dev/null +++ b/src/app/(app)/layout.tsx @@ -0,0 +1,62 @@ +import type { Metadata } from 'next'; +import type { ReactNode } from 'react'; +import { Toaster } from 'react-hot-toast'; + +import { Providers } from '@/app/providers'; +import ClaimDataPopup from '@/components/auth/ClaimDataModal'; +import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isAuthEnabled } from '@/lib/server/auth-config'; + +export const dynamic = 'force-dynamic'; + +export const metadata: Metadata = { + robots: { + index: false, + follow: false, + googleBot: { + index: false, + follow: false, + noimageindex: true, + 'max-snippet': 0, + 'max-video-preview': 0, + }, + }, +}; + +export default function AppLayout({ children }: { children: ReactNode }) { + const authEnabled = isAuthEnabled(); + const authBaseUrl = getAuthBaseUrl(); + const allowAnonymousAuthSessions = isAnonymousAuthSessionsEnabled(); + + return ( + +
+ {authEnabled && } +
{children}
+
+ +
+ ); +} diff --git a/src/app/pdf/[id]/page.tsx b/src/app/(app)/pdf/[id]/page.tsx similarity index 99% rename from src/app/pdf/[id]/page.tsx rename to src/app/(app)/pdf/[id]/page.tsx index ff25cb8..45ca251 100644 --- a/src/app/pdf/[id]/page.tsx +++ b/src/app/(app)/pdf/[id]/page.tsx @@ -140,7 +140,7 @@ export default function PDFViewerPage() {

{error}

{ clearCurrDoc(); }} className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" > @@ -158,7 +158,7 @@ export default function PDFViewerPage() {
clearCurrDoc()} className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" aria-label="Back to documents" diff --git a/src/app/signin/page.tsx b/src/app/(app)/signin/page.tsx similarity index 93% rename from src/app/signin/page.tsx rename to src/app/(app)/signin/page.tsx index 5348213..c1a9962 100644 --- a/src/app/signin/page.tsx +++ b/src/app/(app)/signin/page.tsx @@ -29,7 +29,7 @@ function SignInContent() { const [rememberMe, setRememberMe] = useState(true); const [sessionExpired, setSessionExpired] = useState(false); const [error, setError] = useState(null); - const { authEnabled, baseUrl } = useAuthConfig(); + const { authEnabled, baseUrl, allowAnonymousAuthSessions } = useAuthConfig(); const { refresh: refreshRateLimit } = useAuthRateLimit(); const isAnyLoading = loadingEmail || loadingGithub || loadingAnonymous; @@ -37,7 +37,7 @@ function SignInContent() { // Check if auth is enabled, redirect home if not useEffect(() => { if (!authEnabled) { - router.push('/'); + router.push('/app'); } }, [router, authEnabled]); @@ -79,7 +79,7 @@ function SignInContent() { // Immediately refresh rate-limit status so the banner clears without a full reload. // This is especially important when an anonymous user upgrades to an account. await refreshRateLimit(); - router.push('/'); + router.push('/app'); } } catch (err) { console.error('Sign in error:', err); @@ -95,7 +95,7 @@ function SignInContent() { const client = getAuthClient(baseUrl); await client.signIn.social({ provider: 'github', - callbackURL: '/' + callbackURL: '/app' }); } finally { setLoadingGithub(false); @@ -109,7 +109,7 @@ function SignInContent() { const client = getAuthClient(baseUrl); await client.signIn.anonymous(); await refreshRateLimit(); - router.push('/'); + router.push('/app'); } catch (e) { console.error('Anonymous sign-in failed:', e); setError('Unable to continue anonymously. Please try again.'); @@ -226,17 +226,19 @@ function SignInContent() { {/* Anonymous */} - + > + {loadingAnonymous ? : 'Continue anonymously'} + + )}
{/* Footer */} diff --git a/src/app/signup/page.tsx b/src/app/(app)/signup/page.tsx similarity index 99% rename from src/app/signup/page.tsx rename to src/app/(app)/signup/page.tsx index d18b0c2..6bc4efe 100644 --- a/src/app/signup/page.tsx +++ b/src/app/(app)/signup/page.tsx @@ -24,7 +24,7 @@ export default function SignUpPage() { // Check if auth is enabled, redirect home if not useEffect(() => { if (!authEnabled) { - router.push('/'); + router.push('/app'); } }, [router, authEnabled]); @@ -87,7 +87,7 @@ export default function SignUpPage() { } else { await refreshRateLimit(); toast.success('Account created successfully!'); - router.push('/'); + router.push('/app'); } } } catch (err) { diff --git a/src/app/(public)/layout.tsx b/src/app/(public)/layout.tsx new file mode 100644 index 0000000..b998741 --- /dev/null +++ b/src/app/(public)/layout.tsx @@ -0,0 +1,327 @@ +import type { ReactNode } from 'react'; +import Link from 'next/link'; + +export default function PublicLayout({ children }: { children: ReactNode }) { + return ( + <> + + +
+ {/* ── Ambient orbs ────────────── */} +