diff --git a/.github/instructions/*.instructions.md b/.github/instructions/*.instructions.md index 93e73ea1..0876c657 100644 --- a/.github/instructions/*.instructions.md +++ b/.github/instructions/*.instructions.md @@ -1,4 +1,4 @@ -# Copilot Instructions — Tracker Tracker +# Copilot PR Review Instructions ## Project @@ -8,12 +8,6 @@ Self-hosted single-user dashboard for monitoring private tracker stats. Next.js pnpm only. Never use npm or yarn. Node.js >= 22 required. -## Linting and Formatting - -- Biome for linting (`pnpm lint` runs `pnpm tsc && biome check .`). No ESLint. -- Prettier for formatting (`pnpm format`). Biome's formatter is disabled. -- TypeScript strict mode. Check with `pnpm tsc`. - ## File Conventions - First line of every JS/TS file: `// relative/path/to/file.tsx` @@ -79,10 +73,11 @@ Single-user app with master password auth. No third-party providers. - **Public routes:** defined in `proxy.ts` as `PUBLIC_EXACT` and `PUBLIC_PREFIX` arrays. All other routes require the cookie. Route handler pattern: + ```ts const auth = await authenticate() -if (auth instanceof NextResponse) return auth // 401 -const key = decodeKey(auth) // Buffer +if (auth instanceof NextResponse) return auth // 401 +const key = decodeKey(auth) // Buffer // use key to decrypt DB fields ``` @@ -92,26 +87,8 @@ const key = decodeKey(auth) // Buffer - Suppress findings with `// security-audit-ignore: ` on the line above. A bare suppression without a reason is itself a critical failure. - URL inputs are validated against SSRF via `isUnsafeNetworkHost()` in `src/lib/network.ts`. -## Design System - -- Dark neumorphic aesthetic. No hex values in component files — use Tailwind semantic tokens. -- Depth: `nm-raised`, `nm-raised-sm` for raised elements. `nm-inset`, `nm-inset-sm` for recessed elements. -- Colors: `text-upload` (cyan), `text-download` (amber), `text-positive` (lime), `text-negative` (red). -- Per-tracker colors are the only allowed inline `style={{ backgroundColor }}` values. -- Chart colors come from `CHART_THEME` in `src/components/charts/lib/theme.ts`. Never hardcode hex in chart files. -- Typography: `font-mono` (JetBrains Mono) for data values. `font-sans` (Archivo) for UI text. Use `tabular-nums` on numeric data for column alignment. -- Chart axis labels use `textTertiary` (not `textMuted`). Chart tooltip HTML must escape user data with `escHtml()`. -- Border radius tokens: `rounded-nm-sm` (8px), `rounded-nm-md` (12px), `rounded-nm-lg` (20px), `rounded-nm-pill` (9999px). -- No shadcn/ui. This project uses a fully custom component library with `cva` (class-variance-authority) for variants and `clsx` for class merging. - ## Component Patterns -- `StatCard` has 3 variants (basic, stacked, ring) — they render different JSX structures, not just different classes. -- `ProgressBar` in `src/components/ui/ProgressBar.tsx` — reusable neumorphic progress bar matching the Toggle track/knob depth pattern. -- `MarqueeText` for long torrent/tracker names instead of CSS `truncate`. -- `Tooltip` accepts `ReactNode` content, not just strings. Use JSX with `flex-col` for multi-line tooltips. -- Never use native `` — use the `` component. -- Never use native `` component. - Component anatomy: internal sub-components are unexported functions within the file. Single public export at the bottom. Types exported with `export type { ... }`. - Use React 19's ref-as-prop pattern (direct `ref` prop), not `forwardRef`. - SVG path calculations with floating-point geometry should round to 2 decimal places for SSR hydration safety. @@ -125,13 +102,6 @@ All module-level singletons (cron tasks, DB client, qBT SID cache) must be store - Framework: Vitest. - Test mocks must match real API response shapes. Do not add fields (i.e., `isPrivate: true`) that the real API does not return — this masks bugs. - Do not add test-only methods to production classes. Put test utilities in test files. -- qBittorrent's API returns snake_case fields (`is_private`, `added_on`). The `QbtTorrent` TypeScript type uses camelCase for some fields but there is no automatic mapping — raw API responses have snake_case. - -## Commit Messages - -- Never mention AI, Claude, or LLMs in commit messages. -- Use conventional commits: `feat(scope):`, `fix(scope):`, `style(scope):`, `chore(scope):`. -- One-line commit messages only. ## Code Organization @@ -142,23 +112,11 @@ All module-level singletons (cron tasks, DB client, qBT SID cache) must be store ## SSR / Hydration - NEVER read `localStorage` in `useState` initializers — causes hydration mismatch. Initialize with a server-safe default, then hydrate in `useEffect`. -- Page components (`page.tsx`) should be thin wrappers. Business logic, UI sections, and data fetching belong in extracted components and hooks. - -## Writing Style - -- Always use "i.e." never "e.g." in comments, documentation, and UI text. -- Do not use em dashes, en dashes, curly quotes, or other non-ASCII punctuation in comments, strings, or error messages. Stick to plain ASCII: commas, periods, hyphens, straight quotes. IDEs and terminals choke on fancy Unicode punctuation. ## Adapter Pattern - Tracker platforms use an adapter pattern: `TrackerAdapter` interface with `getAdapter()` factory. UNIT3D, Gazelle, and GGn are implemented. Adding a new platform means adding a new adapter file in `src/lib/adapters/`, not modifying existing ones. -## qBittorrent Integration - -- The qBT Web API returns snake_case field names (`is_private`, `added_on`). The `QbtTorrent` TypeScript interface uses camelCase for some fields but `getTorrents()` returns raw JSON with NO field mapping. Do not assume camelCase fields exist on raw API responses. -- Tag matching must be case-insensitive. The aggregator lowercases map keys. `parseTorrentTags()` lowercases by default. -- The heartbeat loop (5s) must NOT update `lastPolledAt` — only the deep poll (5min) writes it. Otherwise the deep poll's overdue check never triggers. - ## Precision Concerns - Daily upload/download deltas are computed as BigInt subtraction. Converting daily deltas to Number for percentage calculations is safe — `Number.MAX_SAFE_INTEGER` (~9 PiB) vastly exceeds any realistic daily transfer. Do not over-engineer BigInt arithmetic for percentage computations on daily deltas. @@ -191,7 +149,6 @@ All module-level singletons (cron tasks, DB client, qBT SID cache) must be store ## Things NOT to Do -- Do not use the terms "Production-Ready" or "Enterprise" anywhere. - Do not use ESLint. It has been deprecated for this project. - Do not use `npm` or `yarn` — `pnpm` only. - Do not create raw SQL migration files. Use `drizzle-kit push`. @@ -204,4 +161,3 @@ All module-level singletons (cron tasks, DB client, qBT SID cache) must be store - Do not use `JSON.parse()` without a try/catch wrapper. - Do not use `console.log` in API routes — use the `log` instance from `@/lib/logger`. - Do not use `db.execute()` in API routes (raw SQL is banned except for the health check `SELECT 1`). -- Do not use function names like "enhanced", "wrapper", or "util" — be specific and descriptive. diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index f7d71812..18098c8e 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -16,7 +16,6 @@ jobs: - uses: actions/checkout@v6 - name: Review dependencies - continue-on-error: true uses: actions/dependency-review-action@v4 with: fail-on-severity: high diff --git a/.github/workflows/dev-image.yml b/.github/workflows/dev-image.yml index 71e322b0..e13eb7fe 100644 --- a/.github/workflows/dev-image.yml +++ b/.github/workflows/dev-image.yml @@ -45,6 +45,7 @@ jobs: context: . push: true platforms: linux/amd64,linux/arm64 + build-args: NEXT_PUBLIC_RELEASE_CHANNEL=development tags: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:development docker.io/jordyjordy/tracker-tracker:development diff --git a/.gitignore b/.gitignore index 4603b9f3..125986df 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ docs/audit/ docs/superpowers/* docs/superpowers/specs/ .claude/ +CLAUDE.md .history/ .vscode/ .serena/ diff --git a/.prettierignore b/.prettierignore index e4741063..2ccff735 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,5 @@ -# docs/**/*.md +docs/**/*.md +CHANGELOG.md +LICENSE +README.md +pnpm-lock.yaml \ No newline at end of file diff --git a/.versionrc.json b/.versionrc.json index c30eab22..6631fcce 100644 --- a/.versionrc.json +++ b/.versionrc.json @@ -1,10 +1,7 @@ { "header": "# Changelog\n", - "skip": { - "tag": true - }, "writerOpts": { - "commitPartial": "- {{#if scope}}**{{scope}}:** {{/if}}{{#if subject}}{{{subject}}}{{else}}{{{header}}}{{/if}}{{~#if hash}} ({{#if @root.linkReferences}}[{{shortHash}}]({{@root.host}}/{{@root.owner}}/{{@root.repository}}/commit/{{hash}}){{else}}{{shortHash}}{{/if}}){{/if}}{{~#each references}}, closes {{#if @root.linkReferences}}[{{prefix}}{{issue}}]({{@root.host}}/{{@root.owner}}/{{@root.repository}}/issues/{{issue}}){{else}}{{prefix}}{{issue}}{{/if}}{{/each}}\n" + "commitPartial": "- {{#if scope}}**{{scope}}:** {{/if}}{{#if subject}}{{{subject}}}{{else}}{{{header}}}{{/if}}\n" }, "types": [ { "type": "feat", "section": "Features" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index e947f16b..0712cbd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,869 +1,565 @@ # Changelog -## [2.6.0](https://github.com/jordanlambrecht/tracker-tracker/compare/v2.4.1...v2.6.0) (2026-03-27) +## [2.8.7](https://github.com/jordanlambrecht/tracker-tracker/compare/v2.8.6...v2.8.7) (2026-04-10) ### Features -- **dashboard:** add Today At A Glance server logic, checkpoints, and deep poll fixes ([d3826a1](https://github.com/jordanlambrecht/tracker-tracker/commit/d3826a181c017c6b2ac8bbc6fa9d77f6dfd0edc2)) -- **dashboard:** add Today At A Glance UI ([84c5227](https://github.com/jordanlambrecht/tracker-tracker/commit/84c522750f517c95653b73e1298aa72bd204e27f)) -- **mam:** add bonus cap, VIP expiry, unsatisfied limit, and active HnR notifications ([2b210d7](https://github.com/jordanlambrecht/tracker-tracker/commit/2b210d76542db6a7377c13d7e9a9abf51cc48752)) -- **mam:** add Mousehole integration ([0e28443](https://github.com/jordanlambrecht/tracker-tracker/commit/0e28443875c1de70b1dea0f3662ae9625000da0f)) -- **mam:** add MyAnonaMouse adapter ([692d312](https://github.com/jordanlambrecht/tracker-tracker/commit/692d3124cd648a3f1278b38881df5083f7febdc1)) -- **mam:** add platform UI with health overview, badges, and FL Wedges chart ([3bb280e](https://github.com/jordanlambrecht/tracker-tracker/commit/3bb280e302ae4f4afeab667b912f5cea0e6f501c)) -- **schema:** add daily checkpoint tables and TodayAtAGlance types ([636d227](https://github.com/jordanlambrecht/tracker-tracker/commit/636d22701cd43d8827f9afe49a5105820578f892)) -- **security:** enhance security audit checks and improve vulnerability reporting ([23b4cae](https://github.com/jordanlambrecht/tracker-tracker/commit/23b4cae6420d17a1e1f86e9b925841726e28ccfe)) -- **settings:** display database size ([67ff496](https://github.com/jordanlambrecht/tracker-tracker/commit/67ff4961e5d6ba50655ed0a9229ba807a26e1bbe)) +- **trackers:** detects truncated cookies ### Bug Fixes -- **api:** improve session expiration error message ([5a95cd0](https://github.com/jordanlambrecht/tracker-tracker/commit/5a95cd039daf5aebb128f547acf70ae749132221)) -- **auth:** decouple cookie secure flag from node_env for self-hosted http deployments. Closes [#101](https://github.com/jordanlambrecht/tracker-tracker/issues/101) ([b2a7902](https://github.com/jordanlambrecht/tracker-tracker/commit/b2a790245ca76f1ec3ef8220c273a4ab9ca508fd)) -- **auth:** return 401 on stale session instead of misleading credential errors ([cf54c7f](https://github.com/jordanlambrecht/tracker-tracker/commit/cf54c7fbd6c2d9171b5d11b621e9a1f68abc381a)) -- **backups:** enforce maximum length for backup password to 128 characters ([5e6d58e](https://github.com/jordanlambrecht/tracker-tracker/commit/5e6d58e361fbcfa6323e414b3702768d895d85d7)) -- **Dockerfile:** update package.json for drizzle-kit with esbuild overrides ([bce0854](https://github.com/jordanlambrecht/tracker-tracker/commit/bce0854d8ea18cf4a99488ed6d9243b25fc71658)) -- ensure backfill flag is set after successful checkpoint backfill ([60f5786](https://github.com/jordanlambrecht/tracker-tracker/commit/60f5786134f346e4ba07684d210f3806bd384468)) -- error logging for BigInt conversion failures ([e91b30a](https://github.com/jordanlambrecht/tracker-tracker/commit/e91b30a79605ac73c34c39e372ad2aaca4c09c44)) -- error logging for BigInt conversion failures in computeTodayAtAGlance ([15eb043](https://github.com/jordanlambrecht/tracker-tracker/commit/15eb043989fa2611acc846032aae1e049fef7000)) -- **errors:** improve error handling and logging for backup and tracker operations ([7f7b202](https://github.com/jordanlambrecht/tracker-tracker/commit/7f7b202e2557d25cd011c8bd96e14332f3565bb1)) -- **Icons:** update DownloadArrowIcon stroke width ([d2cd450](https://github.com/jordanlambrecht/tracker-tracker/commit/d2cd450af0b6cf55bc5fc1c94ea01452c55252fb)) -- improve error handling for decryption failures in fetchAndMergeTorrents ([0b07d40](https://github.com/jordanlambrecht/tracker-tracker/commit/0b07d40ddaca93f188eeb166e53431764cb1bb8e)) -- normalize tracker tags to lowercase ([762988f](https://github.com/jordanlambrecht/tracker-tracker/commit/762988f2228218cf27b2d12c138bb0e2dfa1c5b1)) -- optimize torrent checkpoint insertion by batching database writes ([90285d6](https://github.com/jordanlambrecht/tracker-tracker/commit/90285d69b1dd709d0f77682ecb797a20fe3fea1c)) -- resolve lint warnings, Copilot review issues, remove dead code, and harden error handling ([815b479](https://github.com/jordanlambrecht/tracker-tracker/commit/815b479047fc956b965556cdcc01d39bc1ce4a33)) -- **ui:** prevent StatCard DOM prop leak ([2d0b22a](https://github.com/jordanlambrecht/tracker-tracker/commit/2d0b22aa51b1badeb8916ace9505fdf32f526dc9)) -- update drizzle-kit, drizzle-orm, and postgres to specific versions in Dockerfile ([303c6f5](https://github.com/jordanlambrecht/tracker-tracker/commit/303c6f5b21195a9a15a66f227dab4c022eca36b9)) -- update VALID_PLATFORMS to use VALID_PLATFORM_TYPES constant ([8cff4ee](https://github.com/jordanlambrecht/tracker-tracker/commit/8cff4ee6fabc274ca644aac65433a03fedbc0d53)) -- use localDateStr for cutoff date in pruneOldCheckpoints function ([0b465b6](https://github.com/jordanlambrecht/tracker-tracker/commit/0b465b6c139333ebcb8650fcf8224d5a70076bee)) - - -### Refactoring - -- **Dockerfile:** cleaned up build stages ([3b96ff9](https://github.com/jordanlambrecht/tracker-tracker/commit/3b96ff964058f33d3fe8fd65bf7a6fcde9dbcd3b)) -- reuse ProgressBar component and extract slot-label utility ([c3d9031](https://github.com/jordanlambrecht/tracker-tracker/commit/c3d90315d4ca8717f983a3d270d922cc0355de18)) - -## [2.5.0](https://github.com/jordanlambrecht/tracker-tracker/compare/v2.3.0...v2.5.0) (2026-03-26) +- **events:** update event categories and improve error logging +- **trackers:** missing profile parsing items for AnimeZ -### Features +## [2.8.6](https://github.com/jordanlambrecht/tracker-tracker/compare/v2.8.5...v2.8.6) (2026-04-09) -- add development image to docker hub ([8785094](https://github.com/jordanlambrecht/tracker-tracker/commit/87850942f118ccfd23c0a04d537928cd3db82976)) -- add fetchTrackerStats for future live transit paper data ([ed6662f](https://github.com/jordanlambrecht/tracker-tracker/commit/ed6662f0ac0c9990b6c96e8ae5616452385b6c26)) -- add GitHub Actions workflow for building and pushing development Docker image ([dca0af0](https://github.com/jordanlambrecht/tracker-tracker/commit/dca0af0a75a4ce21fbe14414d86d4ecbe4d459d3)) -- add per-tracker pause polling ([14a6c43](https://github.com/jordanlambrecht/tracker-tracker/commit/14a6c43cd6764924aba6e2fa592ef13d208b528a)) -- add system events viewer and log management ([b01ed22](https://github.com/jordanlambrecht/tracker-tracker/commit/b01ed22ec2ecfefe7f12bb448998d2726cb6b7f9)) -- **dashboard:** add Today At A Glance server logic, checkpoints, and deep poll fixes ([d3826a1](https://github.com/jordanlambrecht/tracker-tracker/commit/d3826a181c017c6b2ac8bbc6fa9d77f6dfd0edc2)) -- **dashboard:** add Today At A Glance UI ([84c5227](https://github.com/jordanlambrecht/tracker-tracker/commit/84c522750f517c95653b73e1298aa72bd204e27f)) -- remote image upload ([a480c34](https://github.com/jordanlambrecht/tracker-tracker/commit/a480c3470b7e97e44764c1d9c6d1bee356d22728)) -- **schema:** add daily checkpoint tables and TodayAtAGlance types ([636d227](https://github.com/jordanlambrecht/tracker-tracker/commit/636d22701cd43d8827f9afe49a5105820578f892)) -- **ui:** add pause/resume button ([c89299e](https://github.com/jordanlambrecht/tracker-tracker/commit/c89299ee80b9ce5ab22743039b6abd40be5a27b6)) -- **ui:** lazy-load chart sections, prefetch sidebar links, and fix scroll-to-top on navigation ([ba8f59e](https://github.com/jordanlambrecht/tracker-tracker/commit/ba8f59ee33a21be72034f296f5da1ae795e03c70)) ### Bug Fixes -- **api:** improve session expiration error message ([5a95cd0](https://github.com/jordanlambrecht/tracker-tracker/commit/5a95cd039daf5aebb128f547acf70ae749132221)) -- **api:** orpheus was not matching seeding/leeching to response ([4569238](https://github.com/jordanlambrecht/tracker-tracker/commit/456923879b2970c46432bc9a0b604da2685bc31d)) -- **auth:** decouple cookie secure flag from node_env for self-hosted http deployments. Closes [#101](https://github.com/jordanlambrecht/tracker-tracker/issues/101) ([b2a7902](https://github.com/jordanlambrecht/tracker-tracker/commit/b2a790245ca76f1ec3ef8220c273a4ab9ca508fd)) -- **auth:** return 401 on stale session instead of misleading credential errors ([cf54c7f](https://github.com/jordanlambrecht/tracker-tracker/commit/cf54c7fbd6c2d9171b5d11b621e9a1f68abc381a)) -- better regex for splitting comparison values in timing safe check ([8c67a50](https://github.com/jordanlambrecht/tracker-tracker/commit/8c67a50cb64196831d7b021249eb76e84766e009)) -- convert bold numbered rules to markdown list items ([6e96454](https://github.com/jordanlambrecht/tracker-tracker/commit/6e964541330cca791818aca5d703e03ac2165694)) -- deploy issues ([cf45ea1](https://github.com/jordanlambrecht/tracker-tracker/commit/cf45ea19726b8f31db5080ebe93909dd0825e995)) -- **Dockerfile:** update package.json for drizzle-kit with esbuild overrides ([bce0854](https://github.com/jordanlambrecht/tracker-tracker/commit/bce0854d8ea18cf4a99488ed6d9243b25fc71658)) -- **Icons:** update DownloadArrowIcon stroke width ([d2cd450](https://github.com/jordanlambrecht/tracker-tracker/commit/d2cd450af0b6cf55bc5fc1c94ea01452c55252fb)) -- preload fleet dashboard tab ([5f08951](https://github.com/jordanlambrecht/tracker-tracker/commit/5f0895192f39a740927594ab6617f2c5c04b5708)) -- resolve biome lint warnings ([af8807d](https://github.com/jordanlambrecht/tracker-tracker/commit/af8807d72847e139be396778a096a7695fc49123)) -- **trackers:** markdown rendering ([a5fbdde](https://github.com/jordanlambrecht/tracker-tracker/commit/a5fbdde7056848bb58fdbe6f1e77a765a543842d)) -- **ui:** prevent StatCard DOM prop leak ([2d0b22a](https://github.com/jordanlambrecht/tracker-tracker/commit/2d0b22aa51b1badeb8916ace9505fdf32f526dc9)) -- update type imports for CollapsibleCard ([23979d1](https://github.com/jordanlambrecht/tracker-tracker/commit/23979d1f6323bd3fa209c9ed19172dbd7d05b6db)) -- update workflow triggers to include development branch for pull requests ([d159775](https://github.com/jordanlambrecht/tracker-tracker/commit/d15977566a9dbd341c0db6f3b67e0ace9bb70f16)) -- wrong postgres setup in docker-compose (closes [#78](https://github.com/jordanlambrecht/tracker-tracker/issues/78)) ([a0a3e0e](https://github.com/jordanlambrecht/tracker-tracker/commit/a0a3e0e16fe4e3b97dea9c7ebc5616cb54e22332)) - -### Performance - -- add 5s per-client fetch deadline ([558c4be](https://github.com/jordanlambrecht/tracker-tracker/commit/558c4be0f9b05b198fd09ca5df4aad0dc6cde637)) -- **settings:** settings page optimizations ([63aabab](https://github.com/jordanlambrecht/tracker-tracker/commit/63aabab9afdfde3d492b81b86f581c4c035269d1)) - -### Refactoring - -- **charts:** consolidate duplicate Fleet/Torrent chart pairs and normalize upstream data flow ([ca051a8](https://github.com/jordanlambrecht/tracker-tracker/commit/ca051a8f4284565f6b0c09e4c9f0522a70ff7e2c)) -- **charts:** migrate time-series charts to time axis with shared helpers and quality fixes ([596396e](https://github.com/jordanlambrecht/tracker-tracker/commit/596396e205fe387799986af7f1ff8386e8f77d13)) -- **charts:** reorganize chart support files into lib/ subfolder ([98a26d4](https://github.com/jordanlambrecht/tracker-tracker/commit/98a26d4fb4bf2fb7c3b02c4d38151a6b07fbb887)) -- **Dockerfile:** cleaned up build stages ([3b96ff9](https://github.com/jordanlambrecht/tracker-tracker/commit/3b96ff964058f33d3fe8fd65bf7a6fcde9dbcd3b)) -- **settings:** extract CollapsibleCard ([5487d19](https://github.com/jordanlambrecht/tracker-tracker/commit/5487d19d1ac2dd56c6bf2136f072edbcb7868fe5)) -- **settings:** extract SettingsSection wrapper ([ea0572c](https://github.com/jordanlambrecht/tracker-tracker/commit/ea0572c603ef191494a37cd9fe7ca64447bce1d4)) - -## [2.4.2](https://github.com/jordanlambrecht/tracker-tracker/compare/v2.3.0...v2.4.2) (2026-03-25) +- **trackers:** profile parsing -### Features +## [2.8.5](https://github.com/jordanlambrecht/tracker-tracker/compare/v2.8.4...v2.8.5) (2026-04-09) -- add development image to docker hub ([8785094](https://github.com/jordanlambrecht/tracker-tracker/commit/87850942f118ccfd23c0a04d537928cd3db82976)) -- add fetchTrackerStats for future live transit paper data ([ed6662f](https://github.com/jordanlambrecht/tracker-tracker/commit/ed6662f0ac0c9990b6c96e8ae5616452385b6c26)) -- add GitHub Actions workflow for building and pushing development Docker image ([dca0af0](https://github.com/jordanlambrecht/tracker-tracker/commit/dca0af0a75a4ce21fbe14414d86d4ecbe4d459d3)) -- add per-tracker pause polling ([14a6c43](https://github.com/jordanlambrecht/tracker-tracker/commit/14a6c43cd6764924aba6e2fa592ef13d208b528a)) -- add system events viewer and log management ([b01ed22](https://github.com/jordanlambrecht/tracker-tracker/commit/b01ed22ec2ecfefe7f12bb448998d2726cb6b7f9)) -- remote image upload ([a480c34](https://github.com/jordanlambrecht/tracker-tracker/commit/a480c3470b7e97e44764c1d9c6d1bee356d22728)) -- **ui:** add pause/resume button ([c89299e](https://github.com/jordanlambrecht/tracker-tracker/commit/c89299ee80b9ce5ab22743039b6abd40be5a27b6)) -- **ui:** lazy-load chart sections, prefetch sidebar links, and fix scroll-to-top on navigation ([ba8f59e](https://github.com/jordanlambrecht/tracker-tracker/commit/ba8f59ee33a21be72034f296f5da1ae795e03c70)) ### Bug Fixes -- **api:** orpheus was not matching seeding/leeching to response ([4569238](https://github.com/jordanlambrecht/tracker-tracker/commit/456923879b2970c46432bc9a0b604da2685bc31d)) -- **auth:** decouple cookie secure flag from node_env for self-hosted http deployments. Closes [#101](https://github.com/jordanlambrecht/tracker-tracker/issues/101) ([b2a7902](https://github.com/jordanlambrecht/tracker-tracker/commit/b2a790245ca76f1ec3ef8220c273a4ab9ca508fd)) -- better regex for splitting comparison values in timing safe check ([8c67a50](https://github.com/jordanlambrecht/tracker-tracker/commit/8c67a50cb64196831d7b021249eb76e84766e009)) -- convert bold numbered rules to markdown list items ([6e96454](https://github.com/jordanlambrecht/tracker-tracker/commit/6e964541330cca791818aca5d703e03ac2165694)) -- deploy issues ([cf45ea1](https://github.com/jordanlambrecht/tracker-tracker/commit/cf45ea19726b8f31db5080ebe93909dd0825e995)) -- **Dockerfile:** update package.json for drizzle-kit with esbuild overrides ([bce0854](https://github.com/jordanlambrecht/tracker-tracker/commit/bce0854d8ea18cf4a99488ed6d9243b25fc71658)) -- preload fleet dashboard tab ([5f08951](https://github.com/jordanlambrecht/tracker-tracker/commit/5f0895192f39a740927594ab6617f2c5c04b5708)) -- resolve biome lint warnings ([af8807d](https://github.com/jordanlambrecht/tracker-tracker/commit/af8807d72847e139be396778a096a7695fc49123)) -- **trackers:** markdown rendering ([a5fbdde](https://github.com/jordanlambrecht/tracker-tracker/commit/a5fbdde7056848bb58fdbe6f1e77a765a543842d)) -- update type imports for CollapsibleCard ([23979d1](https://github.com/jordanlambrecht/tracker-tracker/commit/23979d1f6323bd3fa209c9ed19172dbd7d05b6db)) -- update workflow triggers to include development branch for pull requests ([d159775](https://github.com/jordanlambrecht/tracker-tracker/commit/d15977566a9dbd341c0db6f3b67e0ace9bb70f16)) -- wrong postgres setup in docker-compose (closes [#78](https://github.com/jordanlambrecht/tracker-tracker/issues/78)) ([a0a3e0e](https://github.com/jordanlambrecht/tracker-tracker/commit/a0a3e0e16fe4e3b97dea9c7ebc5616cb54e22332)) +- changelog now shows all missed releases not just latest +- **trackers:** add batch tolerance for overdue checks to prevent drift -### Performance +## [2.8.4](https://github.com/jordanlambrecht/tracker-tracker/compare/v2.8.3...v2.8.4) (2026-04-09) -- add 5s per-client fetch deadline ([558c4be](https://github.com/jordanlambrecht/tracker-tracker/commit/558c4be0f9b05b198fd09ca5df4aad0dc6cde637)) -- **settings:** settings page optimizations ([63aabab](https://github.com/jordanlambrecht/tracker-tracker/commit/63aabab9afdfde3d492b81b86f581c4c035269d1)) -### Refactoring +### Features -- **charts:** consolidate duplicate Fleet/Torrent chart pairs and normalize upstream data flow ([ca051a8](https://github.com/jordanlambrecht/tracker-tracker/commit/ca051a8f4284565f6b0c09e4c9f0522a70ff7e2c)) -- **charts:** migrate time-series charts to time axis with shared helpers and quality fixes ([596396e](https://github.com/jordanlambrecht/tracker-tracker/commit/596396e205fe387799986af7f1ff8386e8f77d13)) -- **charts:** reorganize chart support files into lib/ subfolder ([98a26d4](https://github.com/jordanlambrecht/tracker-tracker/commit/98a26d4fb4bf2fb7c3b02c4d38151a6b07fbb887)) -- **Dockerfile:** cleaned up build stages ([3b96ff9](https://github.com/jordanlambrecht/tracker-tracker/commit/3b96ff964058f33d3fe8fd65bf7a6fcde9dbcd3b)) -- **settings:** extract CollapsibleCard ([5487d19](https://github.com/jordanlambrecht/tracker-tracker/commit/5487d19d1ac2dd56c6bf2136f072edbcb7868fe5)) -- **settings:** extract SettingsSection wrapper ([ea0572c](https://github.com/jordanlambrecht/tracker-tracker/commit/ea0572c603ef191494a37cd9fe7ca64447bce1d4)) +- new "What's New" dialog +- **settings:** log files now batch in the events tab -## [2.4.1](https://github.com/jordanlambrecht/tracker-tracker/compare/v2.3.0...v2.4.1) (2026-03-23) +## [2.8.3](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v2.8.3) (2026-04-09) ### Features -- add development image to docker hub ([8785094](https://github.com/jordanlambrecht/tracker-tracker/commit/87850942f118ccfd23c0a04d537928cd3db82976)) -- add fetchTrackerStats for future live transit paper data ([ed6662f](https://github.com/jordanlambrecht/tracker-tracker/commit/ed6662f0ac0c9990b6c96e8ae5616452385b6c26)) -- add GitHub Actions workflow for building and pushing development Docker image ([dca0af0](https://github.com/jordanlambrecht/tracker-tracker/commit/dca0af0a75a4ce21fbe14414d86d4ecbe4d459d3)) -- add per-tracker pause polling ([14a6c43](https://github.com/jordanlambrecht/tracker-tracker/commit/14a6c43cd6764924aba6e2fa592ef13d208b528a)) -- add system events viewer and log management ([b01ed22](https://github.com/jordanlambrecht/tracker-tracker/commit/b01ed22ec2ecfefe7f12bb448998d2726cb6b7f9)) -- remote image upload ([a480c34](https://github.com/jordanlambrecht/tracker-tracker/commit/a480c3470b7e97e44764c1d9c6d1bee356d22728)) -- **ui:** add pause/resume button ([c89299e](https://github.com/jordanlambrecht/tracker-tracker/commit/c89299ee80b9ce5ab22743039b6abd40be5a27b6)) -- **ui:** lazy-load chart sections, prefetch sidebar links, and fix scroll-to-top on navigation ([ba8f59e](https://github.com/jordanlambrecht/tracker-tracker/commit/ba8f59ee33a21be72034f296f5da1ae795e03c70)) +* **logging:** added classifyFetchError for better error messages ### Bug Fixes -- **api:** orpheus was not matching seeding/leeching to response ([4569238](https://github.com/jordanlambrecht/tracker-tracker/commit/456923879b2970c46432bc9a0b604da2685bc31d)) -- better regex for splitting comparison values in timing safe check ([8c67a50](https://github.com/jordanlambrecht/tracker-tracker/commit/8c67a50cb64196831d7b021249eb76e84766e009)) -- convert bold numbered rules to markdown list items ([6e96454](https://github.com/jordanlambrecht/tracker-tracker/commit/6e964541330cca791818aca5d703e03ac2165694)) -- deploy issues ([cf45ea1](https://github.com/jordanlambrecht/tracker-tracker/commit/cf45ea19726b8f31db5080ebe93909dd0825e995)) -- preload fleet dashboard tab ([5f08951](https://github.com/jordanlambrecht/tracker-tracker/commit/5f0895192f39a740927594ab6617f2c5c04b5708)) -- resolve biome lint warnings ([af8807d](https://github.com/jordanlambrecht/tracker-tracker/commit/af8807d72847e139be396778a096a7695fc49123)) -- **trackers:** markdown rendering ([a5fbdde](https://github.com/jordanlambrecht/tracker-tracker/commit/a5fbdde7056848bb58fdbe6f1e77a765a543842d)) -- update type imports for CollapsibleCard ([23979d1](https://github.com/jordanlambrecht/tracker-tracker/commit/23979d1f6323bd3fa209c9ed19172dbd7d05b6db)) -- update workflow triggers to include development branch for pull requests ([d159775](https://github.com/jordanlambrecht/tracker-tracker/commit/d15977566a9dbd341c0db6f3b67e0ace9bb70f16)) -- wrong postgres setup in docker-compose (closes [#78](https://github.com/jordanlambrecht/tracker-tracker/issues/78)) ([a0a3e0e](https://github.com/jordanlambrecht/tracker-tracker/commit/a0a3e0e16fe4e3b97dea9c7ebc5616cb54e22332)) +* **circuit-breaker:** reset consecutiveFailures on resume, add lastErrorAt + isManual tracking +* **proxy:** proxy now works with test connection endpoint +* **sidebar:** filter and sort dropdowns were clipping +* **trackers:** tooltip extraction in parseAvistazProfile -### Performance +## [2.8.2](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v2.8.2) (2026-04-09) -- add 5s per-client fetch deadline ([558c4be](https://github.com/jordanlambrecht/tracker-tracker/commit/558c4be0f9b05b198fd09ca5df4aad0dc6cde637)) -- **settings:** settings page optimizations ([63aabab](https://github.com/jordanlambrecht/tracker-tracker/commit/63aabab9afdfde3d492b81b86f581c4c035269d1)) +## [2.8.1](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v2.8.1) (2026-04-09) -### Refactoring +### Features -- **charts:** consolidate duplicate Fleet/Torrent chart pairs and normalize upstream data flow ([ca051a8](https://github.com/jordanlambrecht/tracker-tracker/commit/ca051a8f4284565f6b0c09e4c9f0522a70ff7e2c)) -- **charts:** migrate time-series charts to time axis with shared helpers and quality fixes ([596396e](https://github.com/jordanlambrecht/tracker-tracker/commit/596396e205fe387799986af7f1ff8386e8f77d13)) -- **charts:** reorganize chart support files into lib/ subfolder ([98a26d4](https://github.com/jordanlambrecht/tracker-tracker/commit/98a26d4fb4bf2fb7c3b02c4d38151a6b07fbb887)) -- **settings:** extract CollapsibleCard ([5487d19](https://github.com/jordanlambrecht/tracker-tracker/commit/5487d19d1ac2dd56c6bf2136f072edbcb7868fe5)) -- **settings:** extract SettingsSection wrapper ([ea0572c](https://github.com/jordanlambrecht/tracker-tracker/commit/ea0572c603ef191494a37cd9fe7ca64447bce1d4)) +* **charts:** chart components now use useMemo +* **fleet:** added bucketed queries -## [2.4.0](https://github.com/jordanlambrecht/tracker-tracker/compare/v2.3.0...v2.4.0) (2026-03-23) +## [2.8.0](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v2.8.0) (2026-04-08) ### Features -- add fetchTrackerStats for future live transit paper data ([ed6662f](https://github.com/jordanlambrecht/tracker-tracker/commit/ed6662f0ac0c9990b6c96e8ae5616452385b6c26)) -- add GitHub Actions workflow for building and pushing development Docker image ([dca0af0](https://github.com/jordanlambrecht/tracker-tracker/commit/dca0af0a75a4ce21fbe14414d86d4ecbe4d459d3)) -- add per-tracker pause polling ([14a6c43](https://github.com/jordanlambrecht/tracker-tracker/commit/14a6c43cd6764924aba6e2fa592ef13d208b528a)) -- add system events viewer and log management ([b01ed22](https://github.com/jordanlambrecht/tracker-tracker/commit/b01ed22ec2ecfefe7f12bb448998d2726cb6b7f9)) -- remote image upload ([a480c34](https://github.com/jordanlambrecht/tracker-tracker/commit/a480c3470b7e97e44764c1d9c6d1bee356d22728)) -- **ui:** add pause/resume button ([c89299e](https://github.com/jordanlambrecht/tracker-tracker/commit/c89299ee80b9ce5ab22743039b6abd40be5a27b6)) -- **ui:** lazy-load chart sections, prefetch sidebar links, and fix scroll-to-top on navigation ([ba8f59e](https://github.com/jordanlambrecht/tracker-tracker/commit/ba8f59ee33a21be72034f296f5da1ae795e03c70)) +* add `getFilteredTorrents` function +* **backups:** security hardening +* better API limits for snapshots +* better logging, less silent failures, more try/catches +* retention notice! dashboard alert when unconfigured, setup wizard toggle +* **scheduler:** add SIGTERM handler +* **security:** add checks for adapter cookie injection and credential logging +* **tracker platforms:** add `metaFor` function and `PlatformMetaMap` interface +* **trackers:** added support for DigitalCore ### Bug Fixes -- **api:** orpheus was not matching seeding/leeching to response ([4569238](https://github.com/jordanlambrecht/tracker-tracker/commit/456923879b2970c46432bc9a0b604da2685bc31d)) -- better regex for splitting comparison values in timing safe check ([8c67a50](https://github.com/jordanlambrecht/tracker-tracker/commit/8c67a50cb64196831d7b021249eb76e84766e009)) -- convert bold numbered rules to markdown list items ([6e96454](https://github.com/jordanlambrecht/tracker-tracker/commit/6e964541330cca791818aca5d703e03ac2165694)) -- deploy issues ([cf45ea1](https://github.com/jordanlambrecht/tracker-tracker/commit/cf45ea19726b8f31db5080ebe93909dd0825e995)) -- resolve biome lint warnings ([af8807d](https://github.com/jordanlambrecht/tracker-tracker/commit/af8807d72847e139be396778a096a7695fc49123)) -- **trackers:** markdown rendering ([a5fbdde](https://github.com/jordanlambrecht/tracker-tracker/commit/a5fbdde7056848bb58fdbe6f1e77a765a543842d)) -- update type imports for CollapsibleCard ([23979d1](https://github.com/jordanlambrecht/tracker-tracker/commit/23979d1f6323bd3fa209c9ed19172dbd7d05b6db)) -- update workflow triggers to include development branch for pull requests ([d159775](https://github.com/jordanlambrecht/tracker-tracker/commit/d15977566a9dbd341c0db6f3b67e0ace9bb70f16)) -- wrong postgres setup in docker-compose (closes [#78](https://github.com/jordanlambrecht/tracker-tracker/issues/78)) ([a0a3e0e](https://github.com/jordanlambrecht/tracker-tracker/commit/a0a3e0e16fe4e3b97dea9c7ebc5616cb54e22332)) - -### Performance - -- add 5s per-client fetch deadline ([558c4be](https://github.com/jordanlambrecht/tracker-tracker/commit/558c4be0f9b05b198fd09ca5df4aad0dc6cde637)) -- **settings:** settings page optimizations ([63aabab](https://github.com/jordanlambrecht/tracker-tracker/commit/63aabab9afdfde3d492b81b86f581c4c035269d1)) - -### Refactoring - -- **charts:** consolidate duplicate Fleet/Torrent chart pairs and normalize upstream data flow ([ca051a8](https://github.com/jordanlambrecht/tracker-tracker/commit/ca051a8f4284565f6b0c09e4c9f0522a70ff7e2c)) -- **charts:** migrate time-series charts to time axis with shared helpers and quality fixes ([596396e](https://github.com/jordanlambrecht/tracker-tracker/commit/596396e205fe387799986af7f1ff8386e8f77d13)) -- **charts:** reorganize chart support files into lib/ subfolder ([98a26d4](https://github.com/jordanlambrecht/tracker-tracker/commit/98a26d4fb4bf2fb7c3b02c4d38151a6b07fbb887)) -- **settings:** extract CollapsibleCard ([5487d19](https://github.com/jordanlambrecht/tracker-tracker/commit/5487d19d1ac2dd56c6bf2136f072edbcb7868fe5)) -- **settings:** extract SettingsSection wrapper ([ea0572c](https://github.com/jordanlambrecht/tracker-tracker/commit/ea0572c603ef191494a37cd9fe7ca64447bce1d4)) +* **alerts:** reject unknown alert types +* **auth:** reject pending/setup tokens in getSession function +* **backup:** better validation for tracker baseUrl +* emoji enum leak +* **login:** bug where submit button would reset styling +* **nuke:** reset backfill status after scrub and delete +* **security-audit:** upper-bound pw length check +* ssr issue +* tag group batch validation +* **trackers:** force unique ids and boost validation in PATCH handler -## [2.3.0](https://github.com/jordanlambrecht/tracker-tracker/compare/v2.1.1...v2.3.0) (2026-03-21) +## [2.7.3](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v2.7.3) (2026-03-31) ### Features -- add alertSlideIn keyframe animation ([f59ab88](https://github.com/jordanlambrecht/tracker-tracker/commit/f59ab8854669d8592422da27f8e74a9c7d643216)) -- add notification delivery pipeline with circuit breaker and cooldowns ([14d3982](https://github.com/jordanlambrecht/tracker-tracker/commit/14d3982c19e017b5d4b932953bcc8ae6fcfbd839)) -- add notification target CRUD API routes ([a367039](https://github.com/jordanlambrecht/tracker-tracker/commit/a367039735950988f612d7fe986cf491f90a3dd9)) -- add notificationTargets and notificationDeliveryState schema tables ([785a641](https://github.com/jordanlambrecht/tracker-tracker/commit/785a641fd8dc69cc9e9e51bad202110f70b12fde)) -- add scoped error boundary for tracker detail page ([e4ad230](https://github.com/jordanlambrecht/tracker-tracker/commit/e4ad2302d34bfea1954cd1485bdc56e985267e69)) -- add server-data module with secure column projections ([49fc00e](https://github.com/jordanlambrecht/tracker-tracker/commit/49fc00e4c21c37ae8d20b9630a12ddd917a68ebf)) -- add shared event detection functions and notification type definitions ([4c217bb](https://github.com/jordanlambrecht/tracker-tracker/commit/4c217bb1fe794bcba87a21c255028ee112c0732d)) -- added Dialog and CopyButton components ([a732148](https://github.com/jordanlambrecht/tracker-tracker/commit/a732148512ef3cc444d6828b0d17d4c7906f444e)) -- docs support for tooltips ([406528f](https://github.com/jordanlambrecht/tracker-tracker/commit/406528f40466e52ef153056c209ff40aa7a7456d)) -- **docs:** brand spankin' new documentation site and integration ([2c644d4](https://github.com/jordanlambrecht/tracker-tracker/commit/2c644d412bcf91d78564df596df01e1032843848)) -- expand TrackerLatestStats with bufferBytes, hitAndRuns, seedbonus, shareScore ([a818e1a](https://github.com/jordanlambrecht/tracker-tracker/commit/a818e1ad1995d157577e454a1de89d227826304b)) -- integrate notification targets with backup, restore, and nuke ([655393e](https://github.com/jordanlambrecht/tracker-tracker/commit/655393ebc32887548e7bcea5a08d2db3cb62410f)) -- replace manual polling with TanStack Query ([0129020](https://github.com/jordanlambrecht/tracker-tracker/commit/0129020d3caba64431485bd4d7fb7b3647853fab)) -- wire notification dispatch into tracker polling scheduler ([1d8f4fc](https://github.com/jordanlambrecht/tracker-tracker/commit/1d8f4fc622d79400f0bf52b7fc7678fd123dea42)) +* **trackers:** update user classes requirements for animez tracker -### Bug Fixes - -- added size props to dialog comp ([46a8160](https://github.com/jordanlambrecht/tracker-tracker/commit/46a816081276ab068310b36a040ee6ada8fd4441)) -- round dashOffset to 2 decimal places ([5f23be2](https://github.com/jordanlambrecht/tracker-tracker/commit/5f23be24545c5544bd96813288b4bd065bc2d246)) -- update notificationDeliveryState schema to add foreign key constraint for targetId ([1319b8d](https://github.com/jordanlambrecht/tracker-tracker/commit/1319b8d168cd192627ab9c874120174317034738)) -- update timestamp format ([2d01aba](https://github.com/jordanlambrecht/tracker-tracker/commit/2d01abaf76a46e7f93353ce1e17461d3d112e22a)) - -### Refactoring - -- add getProxyTrackers function to fetch proxy-enabled trackers ([17cf490](https://github.com/jordanlambrecht/tracker-tracker/commit/17cf4903c1047a00dc62ea3d2e8614cdc74f06ec)) -- adopt React 19 ref-as-prop pattern ([a936c8f](https://github.com/jordanlambrecht/tracker-tracker/commit/a936c8fa8fec9f00a0952680a419f3a2bc5e04ca)) -- better joinedAt logic ([24c2e1d](https://github.com/jordanlambrecht/tracker-tracker/commit/24c2e1d0c6da41f237963635466170d3066f5e97)) -- changed snapshot retrieval logic ([75613b1](https://github.com/jordanlambrecht/tracker-tracker/commit/75613b11a40e7114988037fb255e8e087038e49e)) -- consolidate API GET handlers to use server-data functions ([3d6b686](https://github.com/jordanlambrecht/tracker-tracker/commit/3d6b68656f43edfbd7b548e4fafc9fb0c57e149b)) -- migrate API route params to async props.params for Next.js 16 ([6a4efba](https://github.com/jordanlambrecht/tracker-tracker/commit/6a4efba993f1e990bddf388cb7a8921de1a33e32)) -- move login and setup redirect logic server-side ([168ae57](https://github.com/jordanlambrecht/tracker-tracker/commit/168ae579e694d253987cc5f2ffe4d4042bf7c268)) -- replace tracker list with proxy trackers in settings page ([c7d0c47](https://github.com/jordanlambrecht/tracker-tracker/commit/c7d0c4792067515bd004211fd7850d50150e78ac)) -- settingspage to use server-side data fetching ([ff27517](https://github.com/jordanlambrecht/tracker-tracker/commit/ff27517304598d9ebab0bb2ed7bc1053e75dd0c7)) -- simplify database query ([44de4aa](https://github.com/jordanlambrecht/tracker-tracker/commit/44de4aaed114048bfd82374e8fa800d4eeb6e311)) -- split authenticated pages into server and client components ([246287a](https://github.com/jordanlambrecht/tracker-tracker/commit/246287a16a0180c23efefebef7ea6d27a03b5f74)) -- update dismissAllAlerts logic to use persistDismiss ([411e98e](https://github.com/jordanlambrecht/tracker-tracker/commit/411e98e4fd4a791fd4355e64d1dcf86917597a0f)) -- update fetch calls in useDashboardData to support signal for aborting requests ([0ae0dbc](https://github.com/jordanlambrecht/tracker-tracker/commit/0ae0dbcbd765de08457449f0e3b2a324c426518f)) -- use shared detection functions in dashboard alerts and tracker status ([04aca35](https://github.com/jordanlambrecht/tracker-tracker/commit/04aca35e6d8c37e20f47f49d3604911c047b281e)) - -## [2.2.0](https://github.com/jordanlambrecht/tracker-tracker/compare/v2.1.1...v2.2.0) (2026-03-20) +## [2.7.2](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v2.7.2) (2026-03-31) ### Features -- add notification delivery pipeline with circuit breaker and cooldowns ([14d3982](https://github.com/jordanlambrecht/tracker-tracker/commit/14d3982c19e017b5d4b932953bcc8ae6fcfbd839)) -- add notification target CRUD API routes ([a367039](https://github.com/jordanlambrecht/tracker-tracker/commit/a367039735950988f612d7fe986cf491f90a3dd9)) -- add notificationTargets and notificationDeliveryState schema tables ([785a641](https://github.com/jordanlambrecht/tracker-tracker/commit/785a641fd8dc69cc9e9e51bad202110f70b12fde)) -- add scoped error boundary for tracker detail page ([e4ad230](https://github.com/jordanlambrecht/tracker-tracker/commit/e4ad2302d34bfea1954cd1485bdc56e985267e69)) -- add server-data module with secure column projections ([49fc00e](https://github.com/jordanlambrecht/tracker-tracker/commit/49fc00e4c21c37ae8d20b9630a12ddd917a68ebf)) -- add shared event detection functions and notification type definitions ([4c217bb](https://github.com/jordanlambrecht/tracker-tracker/commit/4c217bb1fe794bcba87a21c255028ee112c0732d)) -- docs support for tooltips ([406528f](https://github.com/jordanlambrecht/tracker-tracker/commit/406528f40466e52ef153056c209ff40aa7a7456d)) -- **docs:** brand spankin' new documentation site and integration ([2c644d4](https://github.com/jordanlambrecht/tracker-tracker/commit/2c644d412bcf91d78564df596df01e1032843848)) -- expand TrackerLatestStats with bufferBytes, hitAndRuns, seedbonus, shareScore ([a818e1a](https://github.com/jordanlambrecht/tracker-tracker/commit/a818e1ad1995d157577e454a1de89d227826304b)) -- integrate notification targets with backup, restore, and nuke ([655393e](https://github.com/jordanlambrecht/tracker-tracker/commit/655393ebc32887548e7bcea5a08d2db3cb62410f)) -- replace manual polling with TanStack Query ([0129020](https://github.com/jordanlambrecht/tracker-tracker/commit/0129020d3caba64431485bd4d7fb7b3647853fab)) -- wire notification dispatch into tracker polling scheduler ([1d8f4fc](https://github.com/jordanlambrecht/tracker-tracker/commit/1d8f4fc622d79400f0bf52b7fc7678fd123dea42)) - -### Refactoring - -- add getProxyTrackers function to fetch proxy-enabled trackers ([17cf490](https://github.com/jordanlambrecht/tracker-tracker/commit/17cf4903c1047a00dc62ea3d2e8614cdc74f06ec)) -- adopt React 19 ref-as-prop pattern ([a936c8f](https://github.com/jordanlambrecht/tracker-tracker/commit/a936c8fa8fec9f00a0952680a419f3a2bc5e04ca)) -- better joinedAt logic ([24c2e1d](https://github.com/jordanlambrecht/tracker-tracker/commit/24c2e1d0c6da41f237963635466170d3066f5e97)) -- changed snapshot retrieval logic ([75613b1](https://github.com/jordanlambrecht/tracker-tracker/commit/75613b11a40e7114988037fb255e8e087038e49e)) -- consolidate API GET handlers to use server-data functions ([3d6b686](https://github.com/jordanlambrecht/tracker-tracker/commit/3d6b68656f43edfbd7b548e4fafc9fb0c57e149b)) -- migrate API route params to async props.params for Next.js 16 ([6a4efba](https://github.com/jordanlambrecht/tracker-tracker/commit/6a4efba993f1e990bddf388cb7a8921de1a33e32)) -- move login and setup redirect logic server-side ([168ae57](https://github.com/jordanlambrecht/tracker-tracker/commit/168ae579e694d253987cc5f2ffe4d4042bf7c268)) -- replace tracker list with proxy trackers in settings page ([c7d0c47](https://github.com/jordanlambrecht/tracker-tracker/commit/c7d0c4792067515bd004211fd7850d50150e78ac)) -- settingspage to use server-side data fetching ([ff27517](https://github.com/jordanlambrecht/tracker-tracker/commit/ff27517304598d9ebab0bb2ed7bc1053e75dd0c7)) -- simplify database query ([44de4aa](https://github.com/jordanlambrecht/tracker-tracker/commit/44de4aaed114048bfd82374e8fa800d4eeb6e311)) -- split authenticated pages into server and client components ([246287a](https://github.com/jordanlambrecht/tracker-tracker/commit/246287a16a0180c23efefebef7ea6d27a03b5f74)) -- update dismissAllAlerts logic to use persistDismiss ([411e98e](https://github.com/jordanlambrecht/tracker-tracker/commit/411e98e4fd4a791fd4355e64d1dcf86917597a0f)) -- use shared detection functions in dashboard alerts and tracker status ([04aca35](https://github.com/jordanlambrecht/tracker-tracker/commit/04aca35e6d8c37e20f47f49d3604911c047b281e)) - -## [2.1.1](https://github.com/jordanlambrecht/tracker-tracker/compare/v2.1.0...v2.1.1) (2026-03-18) - -## [2.1.0](https://github.com/jordanlambrecht/tracker-tracker/compare/v2.0.2...v2.1.0) (2026-03-18) - -## [2.0.2](https://github.com/jordanlambrecht/tracker-tracker/compare/v1.11.3...v2.0.2) (2026-03-18) - -### Features +* **trackers:** details for digitalcore and luminarr (thanks @DGeyzer) -- add boot-time scheduler recovery ([b1b1499](https://github.com/jordanlambrecht/tracker-tracker/commit/b1b1499d4cce5cfd3a621c9023ffd1f47c04322f)) -- add client IP logging for auth routes ([9442b62](https://github.com/jordanlambrecht/tracker-tracker/commit/9442b62d50a43f584f6d733ec7a0731d2bbf44ff)) -- add HKDF wrapping key and scheduler key store ([6e579c1](https://github.com/jordanlambrecht/tracker-tracker/commit/6e579c136efab3811e3a074f4a463b46588f9be7)) -- add optional BASE_URL env var with startup validation ([df1ff90](https://github.com/jordanlambrecht/tracker-tracker/commit/df1ff90e12e53d93ec13790ca5ae20f71524ba2a)) -- add per-tracker poll failure circuit breaker ([f57ab8b](https://github.com/jordanlambrecht/tracker-tracker/commit/f57ab8bed174aa73f6a054c44178febba61e0068)) -- add poll-paused alert type and paused health status ([a159abc](https://github.com/jordanlambrecht/tracker-tracker/commit/a159abc22cbee2df290861977ad373571d25a1f9)) -- add resume endpoint and serialize circuit breaker state ([205a805](https://github.com/jordanlambrecht/tracker-tracker/commit/205a805983107b06b1fe4757be609e098f5892a3)) -- add resume UI for paused trackers ([5ca9d8a](https://github.com/jordanlambrecht/tracker-tracker/commit/5ca9d8a625a7b70f07152e59c34cab07f09f27d0)) -- add webhooks coming-soon placeholder to settings ([5cddf82](https://github.com/jordanlambrecht/tracker-tracker/commit/5cddf826edc04cc88b199229283bcd5ad9c37751)) -- clear scheduler key on lockdown, nuke, password change, and restore ([493686e](https://github.com/jordanlambrecht/tracker-tracker/commit/493686ebf1054d146fb96e101e80204213c9d67b)) -- migrate alert dismissals to database, add system alerts ([8e85869](https://github.com/jordanlambrecht/tracker-tracker/commit/8e85869786909ebcf52c80ed3cd9f686f201e5cd)) -- persist scheduler key on login, keep running through logout ([eca1cad](https://github.com/jordanlambrecht/tracker-tracker/commit/eca1cad3ff7d7ef86cae43876e99b5cb1f75d9d9)) -- postgresql 18 infrastructure with migration script ([4178cad](https://github.com/jordanlambrecht/tracker-tracker/commit/4178cad4d78dc210323850e5623ebc7b16505cb0)) +## [2.7.1](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v2.7.1) (2026-03-31) ### Bug Fixes -- add icons metadata for favicon ([d048355](https://github.com/jordanlambrecht/tracker-tracker/commit/d04835500a5d8071215a3613d678c1aaba51c7cd)) -- biome filter for noImportantStyles ([ae2fd08](https://github.com/jordanlambrecht/tracker-tracker/commit/ae2fd08b542a2c87cd080ce59dffe8e47ffcaace)) -- hopefully fixed un/pw field flashing on login screen ([2482473](https://github.com/jordanlambrecht/tracker-tracker/commit/2482473b950519f9bffe6cb459ec82f3bb2a9a73)) - -### Performance - -- add database indexes, column type improvements, and connection pool tuning ([c949145](https://github.com/jordanlambrecht/tracker-tracker/commit/c949145f502fe6e549bc060e15af3f2f33eb59ca)) -- distinct on query, column projections, batch inserts, jsonb/array cleanup ([f5cc7ca](https://github.com/jordanlambrecht/tracker-tracker/commit/f5cc7ca22d4c72740f627f321c0258abbd4fa96c)) - -### Refactoring +* minor stuff -- centralize localStorage keys into storage-keys.ts ([501288e](https://github.com/jordanlambrecht/tracker-tracker/commit/501288e6bd5a5c1e3d0d9f0d2bffc32fd16ffdcb)) -- consolidated the tag groups and download clients tabs in app settings ([41e1958](https://github.com/jordanlambrecht/tracker-tracker/commit/41e19583077fefe49a928c67824d64e646180a9f)) -- extract scrubObject to shared utility for tests ([dd821d3](https://github.com/jordanlambrecht/tracker-tracker/commit/dd821d3681810b79c2bd923c82e319ef220e10ae)) -- implement HKDF for session key derivation ([f38bccd](https://github.com/jordanlambrecht/tracker-tracker/commit/f38bccdb5043c643dc5d773a0b45396852369235)) -- standardize chart helpers, imports, and axis labels ([b55f0df](https://github.com/jordanlambrecht/tracker-tracker/commit/b55f0dfdcb27d5af1a203b11e8e5e162fcb8e079)) -- use HKDF for session key derivation ([d4a9001](https://github.com/jordanlambrecht/tracker-tracker/commit/d4a9001bd794e7c9a15f5e0fe085eddfd4ce84df)) -- use optional chaining ([c260fe6](https://github.com/jordanlambrecht/tracker-tracker/commit/c260fe64b5cab12e29154cf397789cfcfd2cec88)) - -## [2.0.1](https://github.com/jordanlambrecht/tracker-tracker/compare/v1.11.3...v2.0.1) (2026-03-18) +## [2.7.0](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v2.7.0) (2026-03-31) ### Features -- add boot-time scheduler recovery ([b1b1499](https://github.com/jordanlambrecht/tracker-tracker/commit/b1b1499d4cce5cfd3a621c9023ffd1f47c04322f)) -- add client IP logging for auth routes ([9442b62](https://github.com/jordanlambrecht/tracker-tracker/commit/9442b62d50a43f584f6d733ec7a0731d2bbf44ff)) -- add HKDF wrapping key and scheduler key store ([6e579c1](https://github.com/jordanlambrecht/tracker-tracker/commit/6e579c136efab3811e3a074f4a463b46588f9be7)) -- add optional BASE_URL env var with startup validation ([df1ff90](https://github.com/jordanlambrecht/tracker-tracker/commit/df1ff90e12e53d93ec13790ca5ae20f71524ba2a)) -- add per-tracker poll failure circuit breaker ([f57ab8b](https://github.com/jordanlambrecht/tracker-tracker/commit/f57ab8bed174aa73f6a054c44178febba61e0068)) -- add poll-paused alert type and paused health status ([a159abc](https://github.com/jordanlambrecht/tracker-tracker/commit/a159abc22cbee2df290861977ad373571d25a1f9)) -- add resume endpoint and serialize circuit breaker state ([205a805](https://github.com/jordanlambrecht/tracker-tracker/commit/205a805983107b06b1fe4757be609e098f5892a3)) -- add resume UI for paused trackers ([5ca9d8a](https://github.com/jordanlambrecht/tracker-tracker/commit/5ca9d8a625a7b70f07152e59c34cab07f09f27d0)) -- add webhooks coming-soon placeholder to settings ([5cddf82](https://github.com/jordanlambrecht/tracker-tracker/commit/5cddf826edc04cc88b199229283bcd5ad9c37751)) -- clear scheduler key on lockdown, nuke, password change, and restore ([493686e](https://github.com/jordanlambrecht/tracker-tracker/commit/493686ebf1054d146fb96e101e80204213c9d67b)) -- migrate alert dismissals to database, add system alerts ([8e85869](https://github.com/jordanlambrecht/tracker-tracker/commit/8e85869786909ebcf52c80ed3cd9f686f201e5cd)) -- persist scheduler key on login, keep running through logout ([eca1cad](https://github.com/jordanlambrecht/tracker-tracker/commit/eca1cad3ff7d7ef86cae43876e99b5cb1f75d9d9)) -- postgresql 18 infrastructure with migration script ([4178cad](https://github.com/jordanlambrecht/tracker-tracker/commit/4178cad4d78dc210323850e5623ebc7b16505cb0)) +* add AvistaZ slots for activity and badges +* add ConfirmRemove and SaveDiscardBar components +* add download_disabled + vip_expiring for AvistaZ plat, rename mamContext to platformContext +* add hint support to Input component and update BackupsSection to use it +* add lazy loading support to Card component +* add searchParams handling and initialTab prop to TrackerDetailPage +* add support for luminarr and darkPeers ( and) +* added confirmAction ui comp +* added support for the avistaz network +* beefed up Dialog component +* extend change-password API to handle notification target re-encryption +* implement SectionToggle and ProgressWidget components +* implement useActionStatus hook for managing action states +* **mam:** add bonus cap, VIP expiry, unsatisfied limit, and active HnR notifications +* **mam:** add Mousehole integration +* **mam:** add MyAnonaMouse adapter +* **mam:** add platform UI with health overview, badges, and FL Wedges chart +* new formatSpeed formatter +* new heatmap in torrent fleet on dashboard! +* new info tip icon system thing +* new notice component +* new skeleton loaders +* new useAnimatedPresence and useEscapeKey hooks +* parseIntClamped +* refactor dirty detection to buildPatch function +* removed gravatar fluff +* **security:** enhance security audit checks and improve vulnerability reporting +* **settings:** display database size +* **trackers:** added support for DarkPeers (, thanks @DGeyzer) +* **trackers:** added support for DarkPeers (, thanks @DGeyzer) +* useCrudCard hook ### Bug Fixes -- add icons metadata for favicon ([d048355](https://github.com/jordanlambrecht/tracker-tracker/commit/d04835500a5d8071215a3613d678c1aaba51c7cd)) -- biome filter for noImportantStyles ([ae2fd08](https://github.com/jordanlambrecht/tracker-tracker/commit/ae2fd08b542a2c87cd080ce59dffe8e47ffcaace)) -- hopefully fixed un/pw field flashing on login screen ([2482473](https://github.com/jordanlambrecht/tracker-tracker/commit/2482473b950519f9bffe6cb459ec82f3bb2a9a73)) - -### Performance - -- add database indexes, column type improvements, and connection pool tuning ([c949145](https://github.com/jordanlambrecht/tracker-tracker/commit/c949145f502fe6e549bc060e15af3f2f33eb59ca)) -- distinct on query, column projections, batch inserts, jsonb/array cleanup ([f5cc7ca](https://github.com/jordanlambrecht/tracker-tracker/commit/f5cc7ca22d4c72740f627f321c0258abbd4fa96c)) - -### Refactoring - -- centralize localStorage keys into storage-keys.ts ([501288e](https://github.com/jordanlambrecht/tracker-tracker/commit/501288e6bd5a5c1e3d0d9f0d2bffc32fd16ffdcb)) -- consolidated the tag groups and download clients tabs in app settings ([41e1958](https://github.com/jordanlambrecht/tracker-tracker/commit/41e19583077fefe49a928c67824d64e646180a9f)) -- extract scrubObject to shared utility for tests ([dd821d3](https://github.com/jordanlambrecht/tracker-tracker/commit/dd821d3681810b79c2bd923c82e319ef220e10ae)) -- implement HKDF for session key derivation ([f38bccd](https://github.com/jordanlambrecht/tracker-tracker/commit/f38bccdb5043c643dc5d773a0b45396852369235)) -- standardize chart helpers, imports, and axis labels ([b55f0df](https://github.com/jordanlambrecht/tracker-tracker/commit/b55f0dfdcb27d5af1a203b11e8e5e162fcb8e079)) -- use HKDF for session key derivation ([d4a9001](https://github.com/jordanlambrecht/tracker-tracker/commit/d4a9001bd794e7c9a15f5e0fe085eddfd4ce84df)) -- use optional chaining ([c260fe6](https://github.com/jordanlambrecht/tracker-tracker/commit/c260fe64b5cab12e29154cf397789cfcfd2cec88)) - -## [2.0.0](https://github.com/jordanlambrecht/tracker-tracker/compare/v1.11.3...v2.0.0) (2026-03-18) +* avistaZ platform-based trackers not fetching user avatars +* add notice for TOTP disabled during backup restore +* add tabIndex to InfoTip +* **backups:** enforce maximum length for backup password to 128 characters +* bad styling +* better error handling for setup response messages +* boundaryGap bug on charts +* chart content not hiding on card collapse +* don't show editable user joined date for avistaz platform +* duplicate TrackerSummary export +* enforce character limits on proxy username, password, and mousehole URL +* ensure backfill flag is set after successful checkpoint backfill +* ensure default value reference is used in useLocalStorage hook +* ensure loading state is reset after API calls in AddTrackerDialog +* error logging for BigInt conversion failures +* error logging for BigInt conversion failures in computeTodayAtAGlance +* **errors:** improve error handling and logging for backup and tracker operations +* improve error handling for decryption failures in fetchAndMergeTorrents +* improve error handling in backup password operations +* json parsing error +* make footer logo load eagerly +* make nextjs happy with image components +* make validateHttpUrl function use dynamic error labels +* minor placeholder bug +* normalize tracker tags to lowercase +* oops +* oops 2 +* optimize database queries +* optimize deletion of old checkpoints +* optimize torrent checkpoint insertion by batching database writes +* persist showTodayAtAGlance setting, serialize dates +* remove unused import +* removed unnecessary lazy loading from Elder Torrents section +* replace useEffect with useLayoutEffect +* resolve lint warnings, Copilot review issues, remove dead code, and harden error handling +* simplify shouldMount logic in ChartCard component +* unify error handling in SetupForm +* update drizzle-kit, drizzle-orm, and postgres to specific versions in Dockerfile +* update VALID_PLATFORMS to use VALID_PLATFORM_TYPES constant +* use EMPTY_TRACKERS and EMPTY_TRACKER_TAGS constants +* use localDateStr for cutoff date in pruneOldCheckpoints function +* wrong Content-Type for cached avatar images +* x-axis was showing wrong values, zoom bug + +## [2.5.0](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v2.5.0) (2026-03-26) ### Features -- add boot-time scheduler recovery ([b1b1499](https://github.com/jordanlambrecht/tracker-tracker/commit/b1b1499d4cce5cfd3a621c9023ffd1f47c04322f)) -- add client IP logging for auth routes ([9442b62](https://github.com/jordanlambrecht/tracker-tracker/commit/9442b62d50a43f584f6d733ec7a0731d2bbf44ff)) -- add HKDF wrapping key and scheduler key store ([6e579c1](https://github.com/jordanlambrecht/tracker-tracker/commit/6e579c136efab3811e3a074f4a463b46588f9be7)) -- add optional BASE_URL env var with startup validation ([df1ff90](https://github.com/jordanlambrecht/tracker-tracker/commit/df1ff90e12e53d93ec13790ca5ae20f71524ba2a)) -- add per-tracker poll failure circuit breaker ([f57ab8b](https://github.com/jordanlambrecht/tracker-tracker/commit/f57ab8bed174aa73f6a054c44178febba61e0068)) -- add poll-paused alert type and paused health status ([a159abc](https://github.com/jordanlambrecht/tracker-tracker/commit/a159abc22cbee2df290861977ad373571d25a1f9)) -- add resume endpoint and serialize circuit breaker state ([205a805](https://github.com/jordanlambrecht/tracker-tracker/commit/205a805983107b06b1fe4757be609e098f5892a3)) -- add resume UI for paused trackers ([5ca9d8a](https://github.com/jordanlambrecht/tracker-tracker/commit/5ca9d8a625a7b70f07152e59c34cab07f09f27d0)) -- add webhooks coming-soon placeholder to settings ([5cddf82](https://github.com/jordanlambrecht/tracker-tracker/commit/5cddf826edc04cc88b199229283bcd5ad9c37751)) -- clear scheduler key on lockdown, nuke, password change, and restore ([493686e](https://github.com/jordanlambrecht/tracker-tracker/commit/493686ebf1054d146fb96e101e80204213c9d67b)) -- migrate alert dismissals to database, add system alerts ([8e85869](https://github.com/jordanlambrecht/tracker-tracker/commit/8e85869786909ebcf52c80ed3cd9f686f201e5cd)) -- persist scheduler key on login, keep running through logout ([eca1cad](https://github.com/jordanlambrecht/tracker-tracker/commit/eca1cad3ff7d7ef86cae43876e99b5cb1f75d9d9)) -- postgresql 18 infrastructure with migration script ([4178cad](https://github.com/jordanlambrecht/tracker-tracker/commit/4178cad4d78dc210323850e5623ebc7b16505cb0)) +* **dashboard:** add Today At A Glance server logic, checkpoints, and deep poll fixes +* **dashboard:** add Today At A Glance UI +* **schema:** add daily checkpoint tables and TodayAtAGlance types ### Bug Fixes -- add icons metadata for favicon ([d048355](https://github.com/jordanlambrecht/tracker-tracker/commit/d04835500a5d8071215a3613d678c1aaba51c7cd)) -- biome filter for noImportantStyles ([ae2fd08](https://github.com/jordanlambrecht/tracker-tracker/commit/ae2fd08b542a2c87cd080ce59dffe8e47ffcaace)) -- hopefully fixed un/pw field flashing on login screen ([2482473](https://github.com/jordanlambrecht/tracker-tracker/commit/2482473b950519f9bffe6cb459ec82f3bb2a9a73)) - -### Performance - -- add database indexes, column type improvements, and connection pool tuning ([c949145](https://github.com/jordanlambrecht/tracker-tracker/commit/c949145f502fe6e549bc060e15af3f2f33eb59ca)) -- distinct on query, column projections, batch inserts, jsonb/array cleanup ([f5cc7ca](https://github.com/jordanlambrecht/tracker-tracker/commit/f5cc7ca22d4c72740f627f321c0258abbd4fa96c)) +* **api:** improve session expiration error message +* **auth:** return 401 on stale session instead of misleading credential errors +* **Icons:** update DownloadArrowIcon stroke width +* **ui:** prevent StatCard DOM prop leak -### Refactoring - -- centralize localStorage keys into storage-keys.ts ([501288e](https://github.com/jordanlambrecht/tracker-tracker/commit/501288e6bd5a5c1e3d0d9f0d2bffc32fd16ffdcb)) -- consolidated the tag groups and download clients tabs in app settings ([41e1958](https://github.com/jordanlambrecht/tracker-tracker/commit/41e19583077fefe49a928c67824d64e646180a9f)) -- extract scrubObject to shared utility for tests ([dd821d3](https://github.com/jordanlambrecht/tracker-tracker/commit/dd821d3681810b79c2bd923c82e319ef220e10ae)) -- implement HKDF for session key derivation ([f38bccd](https://github.com/jordanlambrecht/tracker-tracker/commit/f38bccdb5043c643dc5d773a0b45396852369235)) -- standardize chart helpers, imports, and axis labels ([b55f0df](https://github.com/jordanlambrecht/tracker-tracker/commit/b55f0dfdcb27d5af1a203b11e8e5e162fcb8e079)) -- use HKDF for session key derivation ([d4a9001](https://github.com/jordanlambrecht/tracker-tracker/commit/d4a9001bd794e7c9a15f5e0fe085eddfd4ce84df)) -- use optional chaining ([c260fe6](https://github.com/jordanlambrecht/tracker-tracker/commit/c260fe64b5cab12e29154cf397789cfcfd2cec88)) - -## [1.11.3](https://github.com/jordanlambrecht/tracker-tracker/compare/v1.10.0...v1.11.3) (2026-03-16) +## [2.4.2](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v2.4.2) (2026-03-25) ### Features -- add logging for login, logout, and TOTP verification events ([4bce6da](https://github.com/jordanlambrecht/tracker-tracker/commit/4bce6da45face537047df79d394686af49398360)) -- enhance api error handling for multiple adapters ([1891cef](https://github.com/jordanlambrecht/tracker-tracker/commit/1891cef6735e7ef0cf28bed1448a1ba0f35fb0da)) -- ip ban check for tracker api calls ([39e9554](https://github.com/jordanlambrecht/tracker-tracker/commit/39e95548f89689b17eb0308255c9d1c204ac3254)) -- log auth events for login, TOTP, and logout ([de3c0ce](https://github.com/jordanlambrecht/tracker-tracker/commit/de3c0cec82bc5d05444fb3e3a804c4cc1878e2ba)) -- show last seen and error state on download client cards ([#35](https://github.com/jordanlambrecht/tracker-tracker/issues/35)) ([36c07e6](https://github.com/jordanlambrecht/tracker-tracker/commit/36c07e689fc93fd7f1d9589d0f72bd506102e92c)) -- show per-endpoint debug info in tracker debug poll ([f088582](https://github.com/jordanlambrecht/tracker-tracker/commit/f088582119b1a9c0bb4e40c3088ecaf05aa12dbb)) -- update UploadPolarChart with html escaping ([9720759](https://github.com/jordanlambrecht/tracker-tracker/commit/972075966b8ab6e3d092c06c725d407857c4fccc)) +* add development image to docker hub ### Bug Fixes -- add --ignore-scripts option to pnpm prune in Dockerfile ([259ed39](https://github.com/jordanlambrecht/tracker-tracker/commit/259ed39e418080652e410227a227399c2444275c)) -- **ci:** add tsx as devDep and use pnpm exec instead of npx ([e9c25eb](https://github.com/jordanlambrecht/tracker-tracker/commit/e9c25eb7d0e4431f4a897c55e277eb6eaed5afed)) -- **ci:** exclude template files from tracker barrel validation ([7f6d4f4](https://github.com/jordanlambrecht/tracker-tracker/commit/7f6d4f4f17ed4e893461def6ded4f12b64482658)) -- **ci:** update codeql-action to v4, pin sbom-action version ([16082b8](https://github.com/jordanlambrecht/tracker-tracker/commit/16082b8694a7004f7a67cf2c2e970bd351455c43)) -- disable clients with cleared credentials on restore ([14ee232](https://github.com/jordanlambrecht/tracker-tracker/commit/14ee232e1800e6e46ed89f57dcfb4a0e3a4f50a2)) -- favicon wasnt showing in production ([d21622d](https://github.com/jordanlambrecht/tracker-tracker/commit/d21622d34063a0d0f88ff5fadcc335ac897e926f)) -- override browser autofill background styles for better UI consistency ([999ca5a](https://github.com/jordanlambrecht/tracker-tracker/commit/999ca5ab9326cdb40ba7a6a1c1ae74ae932ed626)) -- reduce CVE surface in Docker image ([d1196fc](https://github.com/jordanlambrecht/tracker-tracker/commit/d1196fc6487dce16bba46e05d2e39f5ab426596e)) -- sanitize error message in backup restore response ([b0fabc6](https://github.com/jordanlambrecht/tracker-tracker/commit/b0fabc62bfaa3ed90d82cdd0ac0c6e8f36a12f5d)) -- sec audit checks catch block for ignore comments ([#45](https://github.com/jordanlambrecht/tracker-tracker/issues/45)) ([1504166](https://github.com/jordanlambrecht/tracker-tracker/commit/1504166aa22db59f4de7b400ce2e22d93088f33f)) -- security audit now checks catch block body for ignore comments. Closes [#45](https://github.com/jordanlambrecht/tracker-tracker/issues/45) ([19f1cb7](https://github.com/jordanlambrecht/tracker-tracker/commit/19f1cb74258f923b1960b01de40c800ada840e8e)) -- suppress 1Password autofill on non-login password fields ([c987318](https://github.com/jordanlambrecht/tracker-tracker/commit/c987318b2b1ae3d87a9d7a9de339d574f8a08240)) -- update gazelleAuthStyle to use token ([96ca1f7](https://github.com/jordanlambrecht/tracker-tracker/commit/96ca1f78e31dcd6cb922fef39b8ce9824c806129)) -- update jsdom to v29.0.0 and dom-selector to v7.0.3 ([6565f35](https://github.com/jordanlambrecht/tracker-tracker/commit/6565f355c5cf6a1bb792a76ee8ab3b955701d0ae)) -- update PUBLIC_PREFIX to include additional paths for image loading ([8d8709a](https://github.com/jordanlambrecht/tracker-tracker/commit/8d8709acfb5c5e8c50baf90b9ef7fb6b562b2b1e)) -- validate and trim inputs on tracker test and create routes ([0c9e27f](https://github.com/jordanlambrecht/tracker-tracker/commit/0c9e27f487d707da9ddcfd9c5f912bc7cbb47cf5)) -- wrap scrub-and-delete in a transaction ([5378274](https://github.com/jordanlambrecht/tracker-tracker/commit/5378274637850e65c00c9a53c78f8a4ff115e59d)) - -### Refactoring - -- replace auto-wipe with configurable account lockout ([1bab0c5](https://github.com/jordanlambrecht/tracker-tracker/commit/1bab0c5086cdd74c4bb8a1eea93adaf6ce4e0845)) - -## [1.11.2](https://github.com/jordanlambrecht/tracker-tracker/compare/v1.10.0...v1.11.2) (2026-03-16) +* **auth:** decouple cookie secure flag from node_env for self-hosted http deployments. Closes +* **Dockerfile:** update package.json for drizzle-kit with esbuild overrides +* preload fleet dashboard tab + +## [2.4.0](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v2.4.0) (2026-03-23) ### Features -- add logging for login, logout, and TOTP verification events ([4bce6da](https://github.com/jordanlambrecht/tracker-tracker/commit/4bce6da45face537047df79d394686af49398360)) -- enhance api error handling for multiple adapters ([1891cef](https://github.com/jordanlambrecht/tracker-tracker/commit/1891cef6735e7ef0cf28bed1448a1ba0f35fb0da)) -- ip ban check for tracker api calls ([39e9554](https://github.com/jordanlambrecht/tracker-tracker/commit/39e95548f89689b17eb0308255c9d1c204ac3254)) -- log auth events for login, TOTP, and logout ([de3c0ce](https://github.com/jordanlambrecht/tracker-tracker/commit/de3c0cec82bc5d05444fb3e3a804c4cc1878e2ba)) -- show last seen and error state on download client cards ([#35](https://github.com/jordanlambrecht/tracker-tracker/issues/35)) ([36c07e6](https://github.com/jordanlambrecht/tracker-tracker/commit/36c07e689fc93fd7f1d9589d0f72bd506102e92c)) -- show per-endpoint debug info in tracker debug poll ([f088582](https://github.com/jordanlambrecht/tracker-tracker/commit/f088582119b1a9c0bb4e40c3088ecaf05aa12dbb)) -- update UploadPolarChart with html escaping ([9720759](https://github.com/jordanlambrecht/tracker-tracker/commit/972075966b8ab6e3d092c06c725d407857c4fccc)) +* add alertSlideIn keyframe animation +* add fetchTrackerStats for future live transit paper data +* add GitHub Actions workflow for building and pushing development Docker image +* add per-tracker pause polling +* add system events viewer and log management +* added Dialog and CopyButton components +* remote image upload +* **ui:** add pause/resume button +* **ui:** lazy-load chart sections, prefetch sidebar links, and fix scroll-to-top on navigation ### Bug Fixes -- add --ignore-scripts option to pnpm prune in Dockerfile ([259ed39](https://github.com/jordanlambrecht/tracker-tracker/commit/259ed39e418080652e410227a227399c2444275c)) -- **ci:** add tsx as devDep and use pnpm exec instead of npx ([e9c25eb](https://github.com/jordanlambrecht/tracker-tracker/commit/e9c25eb7d0e4431f4a897c55e277eb6eaed5afed)) -- **ci:** exclude template files from tracker barrel validation ([7f6d4f4](https://github.com/jordanlambrecht/tracker-tracker/commit/7f6d4f4f17ed4e893461def6ded4f12b64482658)) -- **ci:** update codeql-action to v4, pin sbom-action version ([16082b8](https://github.com/jordanlambrecht/tracker-tracker/commit/16082b8694a7004f7a67cf2c2e970bd351455c43)) -- disable clients with cleared credentials on restore ([14ee232](https://github.com/jordanlambrecht/tracker-tracker/commit/14ee232e1800e6e46ed89f57dcfb4a0e3a4f50a2)) -- favicon wasnt showing in production ([d21622d](https://github.com/jordanlambrecht/tracker-tracker/commit/d21622d34063a0d0f88ff5fadcc335ac897e926f)) -- override browser autofill background styles for better UI consistency ([999ca5a](https://github.com/jordanlambrecht/tracker-tracker/commit/999ca5ab9326cdb40ba7a6a1c1ae74ae932ed626)) -- reduce CVE surface in Docker image ([d1196fc](https://github.com/jordanlambrecht/tracker-tracker/commit/d1196fc6487dce16bba46e05d2e39f5ab426596e)) -- sanitize error message in backup restore response ([b0fabc6](https://github.com/jordanlambrecht/tracker-tracker/commit/b0fabc62bfaa3ed90d82cdd0ac0c6e8f36a12f5d)) -- sec audit checks catch block for ignore comments ([#45](https://github.com/jordanlambrecht/tracker-tracker/issues/45)) ([1504166](https://github.com/jordanlambrecht/tracker-tracker/commit/1504166aa22db59f4de7b400ce2e22d93088f33f)) -- security audit now checks catch block body for ignore comments. Closes [#45](https://github.com/jordanlambrecht/tracker-tracker/issues/45) ([19f1cb7](https://github.com/jordanlambrecht/tracker-tracker/commit/19f1cb74258f923b1960b01de40c800ada840e8e)) -- suppress 1Password autofill on non-login password fields ([c987318](https://github.com/jordanlambrecht/tracker-tracker/commit/c987318b2b1ae3d87a9d7a9de339d574f8a08240)) -- update gazelleAuthStyle to use token ([96ca1f7](https://github.com/jordanlambrecht/tracker-tracker/commit/96ca1f78e31dcd6cb922fef39b8ce9824c806129)) -- update jsdom to v29.0.0 and dom-selector to v7.0.3 ([6565f35](https://github.com/jordanlambrecht/tracker-tracker/commit/6565f355c5cf6a1bb792a76ee8ab3b955701d0ae)) -- update PUBLIC_PREFIX to include additional paths for image loading ([8d8709a](https://github.com/jordanlambrecht/tracker-tracker/commit/8d8709acfb5c5e8c50baf90b9ef7fb6b562b2b1e)) -- validate and trim inputs on tracker test and create routes ([0c9e27f](https://github.com/jordanlambrecht/tracker-tracker/commit/0c9e27f487d707da9ddcfd9c5f912bc7cbb47cf5)) - -### Refactoring - -- replace auto-wipe with configurable account lockout ([1bab0c5](https://github.com/jordanlambrecht/tracker-tracker/commit/1bab0c5086cdd74c4bb8a1eea93adaf6ce4e0845)) - -## [1.11.1](https://github.com/jordanlambrecht/tracker-tracker/compare/v1.10.0...v1.11.1) (2026-03-16) +* added size props to dialog comp +* **api:** orpheus was not matching seeding/leeching to response +* better regex for splitting comparison values in timing safe check +* convert bold numbered rules to markdown list items +* deploy issues +* resolve biome lint warnings +* round dashOffset to 2 decimal places +* **trackers:** markdown rendering +* update notificationDeliveryState schema to add foreign key constraint for targetId +* update timestamp format +* update type imports for CollapsibleCard +* update workflow triggers to include development branch for pull requests +* wrong postgres setup in docker-compose + +## [2.2.0](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v2.2.0) (2026-03-20) ### Features -- add logging for login, logout, and TOTP verification events ([4bce6da](https://github.com/jordanlambrecht/tracker-tracker/commit/4bce6da45face537047df79d394686af49398360)) -- enhance api error handling for multiple adapters ([1891cef](https://github.com/jordanlambrecht/tracker-tracker/commit/1891cef6735e7ef0cf28bed1448a1ba0f35fb0da)) -- ip ban check for tracker api calls ([39e9554](https://github.com/jordanlambrecht/tracker-tracker/commit/39e95548f89689b17eb0308255c9d1c204ac3254)) -- log auth events for login, TOTP, and logout ([de3c0ce](https://github.com/jordanlambrecht/tracker-tracker/commit/de3c0cec82bc5d05444fb3e3a804c4cc1878e2ba)) -- show last seen and error state on download client cards ([#35](https://github.com/jordanlambrecht/tracker-tracker/issues/35)) ([36c07e6](https://github.com/jordanlambrecht/tracker-tracker/commit/36c07e689fc93fd7f1d9589d0f72bd506102e92c)) -- show per-endpoint debug info in tracker debug poll ([f088582](https://github.com/jordanlambrecht/tracker-tracker/commit/f088582119b1a9c0bb4e40c3088ecaf05aa12dbb)) -- update UploadPolarChart with html escaping ([9720759](https://github.com/jordanlambrecht/tracker-tracker/commit/972075966b8ab6e3d092c06c725d407857c4fccc)) +* add notification delivery pipeline with circuit breaker and cooldowns +* add notification target CRUD API routes +* add notificationTargets and notificationDeliveryState schema tables +* add scoped error boundary for tracker detail page +* add server-data module with secure column projections +* add shared event detection functions and notification type definitions +* docs support for tooltips +* **docs:** brand spankin' new documentation site and integration +* expand TrackerLatestStats with bufferBytes, hitAndRuns, seedbonus, shareScore +* integrate notification targets with backup, restore, and nuke +* replace manual polling with TanStack Query +* wire notification dispatch into tracker polling scheduler -### Bug Fixes +## [2.1.1](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v2.1.1) (2026-03-18) -- add --ignore-scripts option to pnpm prune in Dockerfile ([259ed39](https://github.com/jordanlambrecht/tracker-tracker/commit/259ed39e418080652e410227a227399c2444275c)) -- **ci:** add tsx as devDep and use pnpm exec instead of npx ([e9c25eb](https://github.com/jordanlambrecht/tracker-tracker/commit/e9c25eb7d0e4431f4a897c55e277eb6eaed5afed)) -- **ci:** update codeql-action to v4, pin sbom-action version ([16082b8](https://github.com/jordanlambrecht/tracker-tracker/commit/16082b8694a7004f7a67cf2c2e970bd351455c43)) -- disable clients with cleared credentials on restore ([14ee232](https://github.com/jordanlambrecht/tracker-tracker/commit/14ee232e1800e6e46ed89f57dcfb4a0e3a4f50a2)) -- favicon wasnt showing in production ([d21622d](https://github.com/jordanlambrecht/tracker-tracker/commit/d21622d34063a0d0f88ff5fadcc335ac897e926f)) -- override browser autofill background styles for better UI consistency ([999ca5a](https://github.com/jordanlambrecht/tracker-tracker/commit/999ca5ab9326cdb40ba7a6a1c1ae74ae932ed626)) -- reduce CVE surface in Docker image ([d1196fc](https://github.com/jordanlambrecht/tracker-tracker/commit/d1196fc6487dce16bba46e05d2e39f5ab426596e)) -- sanitize error message in backup restore response ([b0fabc6](https://github.com/jordanlambrecht/tracker-tracker/commit/b0fabc62bfaa3ed90d82cdd0ac0c6e8f36a12f5d)) -- sec audit checks catch block for ignore comments ([#45](https://github.com/jordanlambrecht/tracker-tracker/issues/45)) ([1504166](https://github.com/jordanlambrecht/tracker-tracker/commit/1504166aa22db59f4de7b400ce2e22d93088f33f)) -- security audit now checks catch block body for ignore comments. Closes [#45](https://github.com/jordanlambrecht/tracker-tracker/issues/45) ([19f1cb7](https://github.com/jordanlambrecht/tracker-tracker/commit/19f1cb74258f923b1960b01de40c800ada840e8e)) -- suppress 1Password autofill on non-login password fields ([c987318](https://github.com/jordanlambrecht/tracker-tracker/commit/c987318b2b1ae3d87a9d7a9de339d574f8a08240)) -- update gazelleAuthStyle to use token ([96ca1f7](https://github.com/jordanlambrecht/tracker-tracker/commit/96ca1f78e31dcd6cb922fef39b8ce9824c806129)) -- update jsdom to v29.0.0 and dom-selector to v7.0.3 ([6565f35](https://github.com/jordanlambrecht/tracker-tracker/commit/6565f355c5cf6a1bb792a76ee8ab3b955701d0ae)) -- update PUBLIC_PREFIX to include additional paths for image loading ([8d8709a](https://github.com/jordanlambrecht/tracker-tracker/commit/8d8709acfb5c5e8c50baf90b9ef7fb6b562b2b1e)) -- validate and trim inputs on tracker test and create routes ([0c9e27f](https://github.com/jordanlambrecht/tracker-tracker/commit/0c9e27f487d707da9ddcfd9c5f912bc7cbb47cf5)) +## [2.1.0](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v2.1.0) (2026-03-18) -### Refactoring +## [2.0.2](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v2.0.2) (2026-03-18) -- replace auto-wipe with configurable account lockout ([1bab0c5](https://github.com/jordanlambrecht/tracker-tracker/commit/1bab0c5086cdd74c4bb8a1eea93adaf6ce4e0845)) +## [2.0.1](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v2.0.1) (2026-03-18) -## [1.11.0](https://github.com/jordanlambrecht/tracker-tracker/compare/v1.10.0...v1.11.0) (2026-03-16) +## [2.0.0](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v2.0.0) (2026-03-18) ### Features -- add logging for login, logout, and TOTP verification events ([4bce6da](https://github.com/jordanlambrecht/tracker-tracker/commit/4bce6da45face537047df79d394686af49398360)) -- enhance api error handling for multiple adapters ([1891cef](https://github.com/jordanlambrecht/tracker-tracker/commit/1891cef6735e7ef0cf28bed1448a1ba0f35fb0da)) -- ip ban check for tracker api calls ([39e9554](https://github.com/jordanlambrecht/tracker-tracker/commit/39e95548f89689b17eb0308255c9d1c204ac3254)) -- log auth events for login, TOTP, and logout ([de3c0ce](https://github.com/jordanlambrecht/tracker-tracker/commit/de3c0cec82bc5d05444fb3e3a804c4cc1878e2ba)) -- show last seen and error state on download client cards ([#35](https://github.com/jordanlambrecht/tracker-tracker/issues/35)) ([36c07e6](https://github.com/jordanlambrecht/tracker-tracker/commit/36c07e689fc93fd7f1d9589d0f72bd506102e92c)) -- show per-endpoint debug info in tracker debug poll ([f088582](https://github.com/jordanlambrecht/tracker-tracker/commit/f088582119b1a9c0bb4e40c3088ecaf05aa12dbb)) -- update UploadPolarChart with html escaping ([9720759](https://github.com/jordanlambrecht/tracker-tracker/commit/972075966b8ab6e3d092c06c725d407857c4fccc)) +* add boot-time scheduler recovery +* add client IP logging for auth routes +* add HKDF wrapping key and scheduler key store +* add optional BASE_URL env var with startup validation +* add per-tracker poll failure circuit breaker +* add poll-paused alert type and paused health status +* add resume endpoint and serialize circuit breaker state +* add resume UI for paused trackers +* add webhooks coming-soon placeholder to settings +* clear scheduler key on lockdown, nuke, password change, and restore +* migrate alert dismissals to database, add system alerts +* persist scheduler key on login, keep running through logout +* postgresql 18 infrastructure with migration script ### Bug Fixes -- add --ignore-scripts option to pnpm prune in Dockerfile ([259ed39](https://github.com/jordanlambrecht/tracker-tracker/commit/259ed39e418080652e410227a227399c2444275c)) -- **ci:** update codeql-action to v4, pin sbom-action version ([16082b8](https://github.com/jordanlambrecht/tracker-tracker/commit/16082b8694a7004f7a67cf2c2e970bd351455c43)) -- disable clients with cleared credentials on restore ([14ee232](https://github.com/jordanlambrecht/tracker-tracker/commit/14ee232e1800e6e46ed89f57dcfb4a0e3a4f50a2)) -- favicon wasnt showing in production ([d21622d](https://github.com/jordanlambrecht/tracker-tracker/commit/d21622d34063a0d0f88ff5fadcc335ac897e926f)) -- override browser autofill background styles for better UI consistency ([999ca5a](https://github.com/jordanlambrecht/tracker-tracker/commit/999ca5ab9326cdb40ba7a6a1c1ae74ae932ed626)) -- reduce CVE surface in Docker image ([d1196fc](https://github.com/jordanlambrecht/tracker-tracker/commit/d1196fc6487dce16bba46e05d2e39f5ab426596e)) -- sanitize error message in backup restore response ([b0fabc6](https://github.com/jordanlambrecht/tracker-tracker/commit/b0fabc62bfaa3ed90d82cdd0ac0c6e8f36a12f5d)) -- sec audit checks catch block for ignore comments ([#45](https://github.com/jordanlambrecht/tracker-tracker/issues/45)) ([1504166](https://github.com/jordanlambrecht/tracker-tracker/commit/1504166aa22db59f4de7b400ce2e22d93088f33f)) -- security audit now checks catch block body for ignore comments. Closes [#45](https://github.com/jordanlambrecht/tracker-tracker/issues/45) ([19f1cb7](https://github.com/jordanlambrecht/tracker-tracker/commit/19f1cb74258f923b1960b01de40c800ada840e8e)) -- suppress 1Password autofill on non-login password fields ([c987318](https://github.com/jordanlambrecht/tracker-tracker/commit/c987318b2b1ae3d87a9d7a9de339d574f8a08240)) -- update gazelleAuthStyle to use token ([96ca1f7](https://github.com/jordanlambrecht/tracker-tracker/commit/96ca1f78e31dcd6cb922fef39b8ce9824c806129)) -- update jsdom to v29.0.0 and dom-selector to v7.0.3 ([6565f35](https://github.com/jordanlambrecht/tracker-tracker/commit/6565f355c5cf6a1bb792a76ee8ab3b955701d0ae)) -- update PUBLIC_PREFIX to include additional paths for image loading ([8d8709a](https://github.com/jordanlambrecht/tracker-tracker/commit/8d8709acfb5c5e8c50baf90b9ef7fb6b562b2b1e)) -- validate and trim inputs on tracker test and create routes ([0c9e27f](https://github.com/jordanlambrecht/tracker-tracker/commit/0c9e27f487d707da9ddcfd9c5f912bc7cbb47cf5)) +* add icons metadata for favicon +* biome filter for noImportantStyles +* **ci:** add tsx as devDep and use pnpm exec instead of npx +* **ci:** exclude template files from tracker barrel validation +* hopefully fixed un/pw field flashing on login screen +* wrap scrub-and-delete in a transaction -### Refactoring - -- replace auto-wipe with configurable account lockout ([1bab0c5](https://github.com/jordanlambrecht/tracker-tracker/commit/1bab0c5086cdd74c4bb8a1eea93adaf6ce4e0845)) - -## [1.10.4](https://github.com/jordanlambrecht/tracker-tracker/compare/v1.10.0...v1.10.4) (2026-03-16) +## [1.11.0](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v1.11.0) (2026-03-16) ### Features -- add logging for login, logout, and TOTP verification events ([4bce6da](https://github.com/jordanlambrecht/tracker-tracker/commit/4bce6da45face537047df79d394686af49398360)) +* add logging for login, logout, and TOTP verification events +* enhance api error handling for multiple adapters +* ip ban check for tracker api calls +* log auth events for login, TOTP, and logout +* show last seen and error state on download client cards ([#35](https://github.com/jordanlambrecht/tracker-tracker/issues/35)) +* show per-endpoint debug info in tracker debug poll +* update UploadPolarChart with html escaping ### Bug Fixes -- add --ignore-scripts option to pnpm prune in Dockerfile ([259ed39](https://github.com/jordanlambrecht/tracker-tracker/commit/259ed39e418080652e410227a227399c2444275c)) -- **ci:** update codeql-action to v4, pin sbom-action version ([16082b8](https://github.com/jordanlambrecht/tracker-tracker/commit/16082b8694a7004f7a67cf2c2e970bd351455c43)) -- reduce CVE surface in Docker image ([d1196fc](https://github.com/jordanlambrecht/tracker-tracker/commit/d1196fc6487dce16bba46e05d2e39f5ab426596e)) -- update jsdom to v29.0.0 and dom-selector to v7.0.3 ([6565f35](https://github.com/jordanlambrecht/tracker-tracker/commit/6565f355c5cf6a1bb792a76ee8ab3b955701d0ae)) -- update PUBLIC_PREFIX to include additional paths for image loading ([8d8709a](https://github.com/jordanlambrecht/tracker-tracker/commit/8d8709acfb5c5e8c50baf90b9ef7fb6b562b2b1e)) +* add --ignore-scripts option to pnpm prune in Dockerfile +* disable clients with cleared credentials on restore +* favicon wasnt showing in production +* override browser autofill background styles for better UI consistency +* sanitize error message in backup restore response +* sec audit checks catch block for ignore comments ([#45](https://github.com/jordanlambrecht/tracker-tracker/issues/45)) +* security audit now checks catch block body for ignore comments. Closes +* suppress 1Password autofill on non-login password fields +* update gazelleAuthStyle to use token +* update PUBLIC_PREFIX to include additional paths for image loading +* validate and trim inputs on tracker test and create routes -## [1.10.3](https://github.com/jordanlambrecht/tracker-tracker/compare/v1.10.0...v1.10.3) (2026-03-16) +## [1.10.2](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v1.10.2) (2026-03-16) ### Bug Fixes -- add --ignore-scripts option to pnpm prune in Dockerfile ([259ed39](https://github.com/jordanlambrecht/tracker-tracker/commit/259ed39e418080652e410227a227399c2444275c)) -- **ci:** update codeql-action to v4, pin sbom-action version ([16082b8](https://github.com/jordanlambrecht/tracker-tracker/commit/16082b8694a7004f7a67cf2c2e970bd351455c43)) -- reduce CVE surface in Docker image ([d1196fc](https://github.com/jordanlambrecht/tracker-tracker/commit/d1196fc6487dce16bba46e05d2e39f5ab426596e)) -- update jsdom to v29.0.0 and dom-selector to v7.0.3 ([6565f35](https://github.com/jordanlambrecht/tracker-tracker/commit/6565f355c5cf6a1bb792a76ee8ab3b955701d0ae)) +* update jsdom to v29.0.0 and dom-selector to v7.0.3 -## [1.10.2](https://github.com/jordanlambrecht/tracker-tracker/compare/v1.10.0...v1.10.2) (2026-03-16) - -### Bug Fixes - -- **ci:** update codeql-action to v4, pin sbom-action version ([16082b8](https://github.com/jordanlambrecht/tracker-tracker/commit/16082b8694a7004f7a67cf2c2e970bd351455c43)) -- reduce CVE surface in Docker image ([d1196fc](https://github.com/jordanlambrecht/tracker-tracker/commit/d1196fc6487dce16bba46e05d2e39f5ab426596e)) -- update jsdom to v29.0.0 and dom-selector to v7.0.3 ([6565f35](https://github.com/jordanlambrecht/tracker-tracker/commit/6565f355c5cf6a1bb792a76ee8ab3b955701d0ae)) - -## [1.10.1](https://github.com/jordanlambrecht/tracker-tracker/compare/v1.10.0...v1.10.1) (2026-03-16) +## [1.10.1](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v1.10.1) (2026-03-16) ### Features -- add area and totals modes to daily volume chart ([8c6636c](https://github.com/jordanlambrecht/tracker-tracker/commit/8c6636c3f0b0f99c6e88e47bba1f56aa92bb9983)) -- add cross-seed network, size scatter, and category breakdown to fleet dashboard ([7e6bd67](https://github.com/jordanlambrecht/tracker-tracker/commit/7e6bd67998b14e4c221a9b1a50f120631b22c847)) -- add sankey flow and parallel views to distribution chart ([58e2d7c](https://github.com/jordanlambrecht/tracker-tracker/commit/58e2d7c218dbb29743a94e0ea68954e752bede23)) -- add stacked and total view modes to comparison charts ([cdf0f73](https://github.com/jordanlambrecht/tracker-tracker/commit/cdf0f732a82c675c07fcdd21c334603bce937298)) -- add volume heatmap and calendar charts ([7848e89](https://github.com/jordanlambrecht/tracker-tracker/commit/7848e89ca8462afcb1fcc5808c817b5c0533d91d)) -- added logo to footer ([1270099](https://github.com/jordanlambrecht/tracker-tracker/commit/1270099c6ad49de2d09b0f6c768789b457e7c521)) -- added timestamp to dl client disconnect error ([0cfabe2](https://github.com/jordanlambrecht/tracker-tracker/commit/0cfabe26f4740ca8dd9e9cbef3c3e3b15be5818d)) -- auto-fill proxy port based on selected type in ProxySection ([0e2f30e](https://github.com/jordanlambrecht/tracker-tracker/commit/0e2f30efdfbe544924cb2d2b9c16282acc6e70ac)) -- encrypt scheduled and manual backups with stored password ([e514f9c](https://github.com/jordanlambrecht/tracker-tracker/commit/e514f9c5f8771690eb3ed6f623bf6881ab653a9e)) +* added logo to footer +* auto-fill proxy port based on selected type in ProxySection +* encrypt scheduled and manual backups with stored password ### Bug Fixes -- add .trivyignore file with CVE entries for vulnerability scanning ([e78d5e9](https://github.com/jordanlambrecht/tracker-tracker/commit/e78d5e9939f81349e2e82f5caa89510f850f0bc7)) -- add missing alias for typography in vitest configuration ([e7a7e34](https://github.com/jordanlambrecht/tracker-tracker/commit/e7a7e3498f199b1e3d99e6dd211183d486379a9e)) -- add missing permissions for actions in CodeQL workflow ([c1210df](https://github.com/jordanlambrecht/tracker-tracker/commit/c1210df290cccff1c7f333c6ee5df0cbb4d0e35e)) -- **ci:** update codeql-action to v4, pin sbom-action version ([16082b8](https://github.com/jordanlambrecht/tracker-tracker/commit/16082b8694a7004f7a67cf2c2e970bd351455c43)) -- commit-msg hook ([e8c81d1](https://github.com/jordanlambrecht/tracker-tracker/commit/e8c81d1cbbb077c2e3e385270d83f2443740b819)) -- construct DATABASE_URL from POSTGRES env vars when not set ([c8c473a](https://github.com/jordanlambrecht/tracker-tracker/commit/c8c473a718bd47c50498a744c5b9c49572d38579)) -- re-encrypt backup password on password change, clear on lockdown ([cf7a3dc](https://github.com/jordanlambrecht/tracker-tracker/commit/cf7a3dca1e242501cbafde491e9f8654e7e8e78f)) -- reduce CVE surface in Docker image ([d1196fc](https://github.com/jordanlambrecht/tracker-tracker/commit/d1196fc6487dce16bba46e05d2e39f5ab426596e)) -- remove redundant comment ([18688d5](https://github.com/jordanlambrecht/tracker-tracker/commit/18688d5f1dc4cc1f7c78bb4825b1e9892a344dbf)) -- update .trivyignore with additional CVE entries ([88142b5](https://github.com/jordanlambrecht/tracker-tracker/commit/88142b5ad8a6cfe819b1611aa8760454b20ef7aa)) -- update tracker file detection method and enhance session secret length ([5a23ccd](https://github.com/jordanlambrecht/tracker-tracker/commit/5a23ccdcade32f17fbd4a9d495ee65bce9e2df24)) -- update Trivy to version 0.35.0 in CI and release workflows ([88877f4](https://github.com/jordanlambrecht/tracker-tracker/commit/88877f4cace97937a50d07218333f7b19b0d9b1e)) - -### Performance +* add .trivyignore file with CVE entries for vulnerability scanning +* add missing permissions for actions in CodeQL workflow +* **ci:** update codeql-action to v4, pin sbom-action version +* re-encrypt backup password on password change, clear on lockdown +* reduce CVE surface in Docker image +* update .trivyignore with additional CVE entries +* update tracker file detection method and enhance session secret length +* update Trivy to version 0.35.0 in CI and release workflows -- batch snapshot queries and eliminate redundant DB round-trip ([ec4f77b](https://github.com/jordanlambrecht/tracker-tracker/commit/ec4f77b9eb7d813e88304ade53faad01b731a00d)) - -### Refactoring - -- consolidate chart utilities and wire into 25+ chart files ([426b017](https://github.com/jordanlambrecht/tracker-tracker/commit/426b01747095147cc970708b57cff61a4871a95e)) -- extract shared server helpers and wire into consumers ([07c80d4](https://github.com/jordanlambrecht/tracker-tracker/commit/07c80d4e59863d0d15310841e57a3f558316b47d)) -- merge TopTorrentsTable and ElderTorrentsTable into TorrentRankingTable ([003b01c](https://github.com/jordanlambrecht/tracker-tracker/commit/003b01ce9a002c30e0e1e5b91b61804656503023)) -- streamline request creation in tracker routes tests ([13a42f7](https://github.com/jordanlambrecht/tracker-tracker/commit/13a42f793dd128f8d523ddebfa6953830dff09cb)) - -## [1.10.0](https://github.com/jordanlambrecht/tracker-tracker/compare/v1.8.4...v1.10.0) (2026-03-16) +## [1.9.0](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v1.9.0) (2026-03-16) ### Features -- add area and totals modes to daily volume chart ([8c6636c](https://github.com/jordanlambrecht/tracker-tracker/commit/8c6636c3f0b0f99c6e88e47bba1f56aa92bb9983)) -- add cross-seed network, size scatter, and category breakdown to fleet dashboard ([7e6bd67](https://github.com/jordanlambrecht/tracker-tracker/commit/7e6bd67998b14e4c221a9b1a50f120631b22c847)) -- add sankey flow and parallel views to distribution chart ([58e2d7c](https://github.com/jordanlambrecht/tracker-tracker/commit/58e2d7c218dbb29743a94e0ea68954e752bede23)) -- add stacked and total view modes to comparison charts ([cdf0f73](https://github.com/jordanlambrecht/tracker-tracker/commit/cdf0f732a82c675c07fcdd21c334603bce937298)) -- add volume heatmap and calendar charts ([7848e89](https://github.com/jordanlambrecht/tracker-tracker/commit/7848e89ca8462afcb1fcc5808c817b5c0533d91d)) -- added logo to footer ([1270099](https://github.com/jordanlambrecht/tracker-tracker/commit/1270099c6ad49de2d09b0f6c768789b457e7c521)) -- added timestamp to dl client disconnect error ([0cfabe2](https://github.com/jordanlambrecht/tracker-tracker/commit/0cfabe26f4740ca8dd9e9cbef3c3e3b15be5818d)) -- auto-fill proxy port based on selected type in ProxySection ([0e2f30e](https://github.com/jordanlambrecht/tracker-tracker/commit/0e2f30efdfbe544924cb2d2b9c16282acc6e70ac)) -- encrypt scheduled and manual backups with stored password ([e514f9c](https://github.com/jordanlambrecht/tracker-tracker/commit/e514f9c5f8771690eb3ed6f623bf6881ab653a9e)) +* add area and totals modes to daily volume chart +* add cross-seed network, size scatter, and category breakdown to fleet dashboard +* add sankey flow and parallel views to distribution chart +* add stacked and total view modes to comparison charts +* add volume heatmap and calendar charts +* added timestamp to dl client disconnect error ### Bug Fixes -- add .trivyignore file with CVE entries for vulnerability scanning ([e78d5e9](https://github.com/jordanlambrecht/tracker-tracker/commit/e78d5e9939f81349e2e82f5caa89510f850f0bc7)) -- add missing alias for typography in vitest configuration ([e7a7e34](https://github.com/jordanlambrecht/tracker-tracker/commit/e7a7e3498f199b1e3d99e6dd211183d486379a9e)) -- add missing permissions for actions in CodeQL workflow ([c1210df](https://github.com/jordanlambrecht/tracker-tracker/commit/c1210df290cccff1c7f333c6ee5df0cbb4d0e35e)) -- commit-msg hook ([e8c81d1](https://github.com/jordanlambrecht/tracker-tracker/commit/e8c81d1cbbb077c2e3e385270d83f2443740b819)) -- construct DATABASE_URL from POSTGRES env vars when not set ([c8c473a](https://github.com/jordanlambrecht/tracker-tracker/commit/c8c473a718bd47c50498a744c5b9c49572d38579)) -- re-encrypt backup password on password change, clear on lockdown ([cf7a3dc](https://github.com/jordanlambrecht/tracker-tracker/commit/cf7a3dca1e242501cbafde491e9f8654e7e8e78f)) -- remove redundant comment ([18688d5](https://github.com/jordanlambrecht/tracker-tracker/commit/18688d5f1dc4cc1f7c78bb4825b1e9892a344dbf)) -- update .trivyignore with additional CVE entries ([88142b5](https://github.com/jordanlambrecht/tracker-tracker/commit/88142b5ad8a6cfe819b1611aa8760454b20ef7aa)) -- update tracker file detection method and enhance session secret length ([5a23ccd](https://github.com/jordanlambrecht/tracker-tracker/commit/5a23ccdcade32f17fbd4a9d495ee65bce9e2df24)) -- update Trivy to version 0.35.0 in CI and release workflows ([88877f4](https://github.com/jordanlambrecht/tracker-tracker/commit/88877f4cace97937a50d07218333f7b19b0d9b1e)) - -### Performance +* add missing alias for typography in vitest configuration +* commit-msg hook +* construct DATABASE_URL from POSTGRES env vars when not set -- batch snapshot queries and eliminate redundant DB round-trip ([ec4f77b](https://github.com/jordanlambrecht/tracker-tracker/commit/ec4f77b9eb7d813e88304ade53faad01b731a00d)) - -### Refactoring - -- consolidate chart utilities and wire into 25+ chart files ([426b017](https://github.com/jordanlambrecht/tracker-tracker/commit/426b01747095147cc970708b57cff61a4871a95e)) -- extract shared server helpers and wire into consumers ([07c80d4](https://github.com/jordanlambrecht/tracker-tracker/commit/07c80d4e59863d0d15310841e57a3f558316b47d)) -- merge TopTorrentsTable and ElderTorrentsTable into TorrentRankingTable ([003b01c](https://github.com/jordanlambrecht/tracker-tracker/commit/003b01ce9a002c30e0e1e5b91b61804656503023)) -- streamline request creation in tracker routes tests ([13a42f7](https://github.com/jordanlambrecht/tracker-tracker/commit/13a42f793dd128f8d523ddebfa6953830dff09cb)) - -## [1.9.0](https://github.com/jordanlambrecht/tracker-tracker/compare/v1.8.4...v1.9.0) (2026-03-16) - -### Features - -- add area and totals modes to daily volume chart ([8c6636c](https://github.com/jordanlambrecht/tracker-tracker/commit/8c6636c3f0b0f99c6e88e47bba1f56aa92bb9983)) -- add cross-seed network, size scatter, and category breakdown to fleet dashboard ([7e6bd67](https://github.com/jordanlambrecht/tracker-tracker/commit/7e6bd67998b14e4c221a9b1a50f120631b22c847)) -- add sankey flow and parallel views to distribution chart ([58e2d7c](https://github.com/jordanlambrecht/tracker-tracker/commit/58e2d7c218dbb29743a94e0ea68954e752bede23)) -- add stacked and total view modes to comparison charts ([cdf0f73](https://github.com/jordanlambrecht/tracker-tracker/commit/cdf0f732a82c675c07fcdd21c334603bce937298)) -- add volume heatmap and calendar charts ([7848e89](https://github.com/jordanlambrecht/tracker-tracker/commit/7848e89ca8462afcb1fcc5808c817b5c0533d91d)) -- added timestamp to dl client disconnect error ([0cfabe2](https://github.com/jordanlambrecht/tracker-tracker/commit/0cfabe26f4740ca8dd9e9cbef3c3e3b15be5818d)) +## [1.8.5](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v1.8.5) (2026-03-15) ### Bug Fixes -- add missing alias for typography in vitest configuration ([e7a7e34](https://github.com/jordanlambrecht/tracker-tracker/commit/e7a7e3498f199b1e3d99e6dd211183d486379a9e)) -- commit-msg hook ([e8c81d1](https://github.com/jordanlambrecht/tracker-tracker/commit/e8c81d1cbbb077c2e3e385270d83f2443740b819)) -- construct DATABASE_URL from POSTGRES env vars when not set ([c8c473a](https://github.com/jordanlambrecht/tracker-tracker/commit/c8c473a718bd47c50498a744c5b9c49572d38579)) -- remove redundant comment ([18688d5](https://github.com/jordanlambrecht/tracker-tracker/commit/18688d5f1dc4cc1f7c78bb4825b1e9892a344dbf)) - -### Performance +* remove redundant comment -- batch snapshot queries and eliminate redundant DB round-trip ([ec4f77b](https://github.com/jordanlambrecht/tracker-tracker/commit/ec4f77b9eb7d813e88304ade53faad01b731a00d)) - -### Refactoring - -- consolidate chart utilities and wire into 25+ chart files ([426b017](https://github.com/jordanlambrecht/tracker-tracker/commit/426b01747095147cc970708b57cff61a4871a95e)) -- extract shared server helpers and wire into consumers ([07c80d4](https://github.com/jordanlambrecht/tracker-tracker/commit/07c80d4e59863d0d15310841e57a3f558316b47d)) -- merge TopTorrentsTable and ElderTorrentsTable into TorrentRankingTable ([003b01c](https://github.com/jordanlambrecht/tracker-tracker/commit/003b01ce9a002c30e0e1e5b91b61804656503023)) -- streamline request creation in tracker routes tests ([13a42f7](https://github.com/jordanlambrecht/tracker-tracker/commit/13a42f793dd128f8d523ddebfa6953830dff09cb)) - -## [1.8.5](https://github.com/jordanlambrecht/tracker-tracker/compare/v1.8.4...v1.8.5) (2026-03-15) +## [1.8.4](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v1.8.4) (2026-03-15) ### Bug Fixes -- remove redundant comment ([18688d5](https://github.com/jordanlambrecht/tracker-tracker/commit/18688d5f1dc4cc1f7c78bb4825b1e9892a344dbf)) +* collapse tracker validation warnings -## [1.8.4](https://github.com/jordanlambrecht/tracker-tracker/compare/v1.8.3...v1.8.4) (2026-03-15) +## [1.8.3](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v1.8.3) (2026-03-15) -### Features - -- Cached torrent fallback: stores last successful torrent list per tag, serves cached data when qBittorrent is unreachable, with stale data banner indicating cache age -- Anniversary milestone detection with dashboard alerts for tracker join date anniversaries -- Login timer dashboard setting (showLoginTimers) with shared state toggle -- Last access date field and enhanced community data in tracker adapters -- Bento grid slot system with explicit-positioning layout algorithms for tracker detail stat cards -- Responsive bento grid: 2-col (mobile), 3-col (md), and 3-or-4-col (lg) breakpoints with per-breakpoint layout algorithms -- Stat card alert system: danger glow, outline, and tooltip icon for ratio below required and negative buffer -- Daily buffer candlestick chart on tracker detail pages -- Dashboard alerts "Clear All" button for batch dismissal -- Login timer cards link to tracker site with hover external-link indicator -- Rank timeline: promotion/demotion chevrons (green/red), anniversary milestones, horizontal scroll -- Swipe/drag gestures on sidebar client carousel (pointer events with capture) -- 4 new draft trackers: AsianCinema, Bibliotik, UHDBits, SeedPool -- TrackerHub slugs and status page URLs populated across 19 existing trackers -- Download client uptime tracking: 24h heartbeat history displayed as a horizontal status bar in each client's settings card, with 5-minute bucket granularity and long-term retention for future chart overlays -- Live active torrents: 5-second polling of actively transferring torrents on tracker detail page with live speed/state updates -- Per-client stat card breakdowns: Seeding and Total Size cards show per-client rows when multiple download clients are configured (stacked variant with sumIsHero) -- H&R risk separated from unsatisfied: stat card shows only stopped/paused unsatisfied torrents as actual risk, tooltip shows total unsatisfied count -- Required ratio fallback: stat card falls back to tracker registry minimumRatio when the tracker API doesn't provide requiredRatio (UNIT3D) -- Aggregate upload/download speeds shown inline in Active Uploads/Downloads section headers -- Untagged torrents now shown in tag group breakdown charts (donut, bar, treemap) when "Count Not Tagged" is enabled - -### Security - -- Strip announce URL passkeys from torrent responses at both cache-write time and API response time -- Sanitize raw error messages in client scheduler — generic messages to client, raw errors to server logs only - -### Changed - -- StatCard expanded with stacked and ring variants for bento grid layouts -- Tracker detail cards migrated to slot-based grid system with slot registry -- LoginTimers custom ring replaced with StatCard type=ring -- formatTimeAgo extracted to shared formatters module -- formatBytesNum improved with negative value handling and variable precision -- CoreStatCards refactored: buildCoreStatDescriptors extracted as pure data function, component wrapper removed -- Slot rendering consolidated: renderSlotElement single source in slot-registry, replaces duplicate SLOT_COMPONENT_MAP lookups -- loginDeadlineSlot promoted to span:2 with priority 30 (stacked data cards get triple promotion over compact ring) -- gazelleCommentsSlot guarded against NaN from missing API fields -- Explicit draft: true/false required on all tracker registry entries (enforced by test) -- Import order standardized across codebase -- Shared portal-based Tooltip component replaces all native title attributes and inline tooltip implementations across 15 files -- Backup settings restructured: Export, Restore, and Configuration split into separate sections -- "Encrypt backups" renamed to "Password-protect backups" for clarity -- Backup password field moved from Configuration to Export section (ephemeral per-export, not a saved setting) -- Storage path input visible outside scheduled backup toggle (used by both manual exports and scheduled backups) -- Backup Now saves to disk silently when storage path is available; browser download only as fallback -- Changelog dialog renders markdown instead of raw text -- Changelog version header auto-updates on release via pnpm version lifecycle hook -- Deep poll optimized: parallel per-tag torrent fetching replaces single unfiltered dump (20MB → 10MB, 33% faster), public torrents filtered out before processing -- Heartbeat interval reduced from 10s to 5s for more responsive speed data -- Deep poll minimum raised from 10s to 60s, default from 30s to 300s (full torrent dump is ~20MB) -- Download client settings: explicit Save/Discard buttons replace auto-save on every keystroke -- Client snapshot retention now uses configurable snapshotRetentionDays instead of hardcoded 30 days -- Fleet snapshots API max query window raised from 30 to 365 days -- Icons: seeding changed from anchor to seedling, seedbonus changed from star to gem, required ratio uses balance scale (distinct from ratio arrows and favorite star) -- StatCard shadow reduced from nm-raised to nm-raised-sm to prevent neighbor shadow bleed in grids -- Card component no longer applies overflow-hidden by default (was clipping neumorphic shadows on nested cards) -- ChartCard uses p-6/-m-6 breathing room for nested neumorphic shadows with documentation in globals.css -- Ecosystem stats (Total Uploaded/Downloaded/Buffer) now use unit prop for smaller unit text -- Unsatisfied torrents table: scrollable when >15 rows, percentage-based column widths, MarqueeText for long names -- Top Seeded and Elder Torrents tables: percentage-based column widths with edge padding -- Table component: overscroll-contain prevents elastic bounce on scrollable tables, empty state vertically centered -- Leeching and Upload Speed stat cards removed from Torrents tab (redundant with Active Downloads table and inline speed display) - -### Fixed - -- Chart spacing on tracker detail pages: reduced top margin from 78px to 16-40px (was designed for multi-tracker dashboard legends) -- Double/triple slot index mapping collision when algorithm promotes doubles to triples -- Sidebar duplicate filepath comment removed -- Color hex code validation bug -- Backup storage path validated on filesystem (mkdir + access check) when saving settings -- Backup Now disabled when configuration has unsaved changes -- Default storage path (/data/backups) shown in input instead of empty placeholder -- Export error messages now visible in Export section (were orphaned after section split) -- Hydration mismatch on tracker detail page: added loading.tsx for framework-level Suspense boundary -- Cross-seed donut chart vertically centered in card when adjacent Categories card is taller -- Tag group breakdown charts (donut/bar/treemap) now include "Untagged" slice when countUnmatched is enabled (previously only worked in numbers view) -- Uptime accumulator cleanup on client delete prevents FK violation on flush -- Backup restore uses onConflictDoNothing for uptime buckets to handle edge-case duplicates -- Active torrents poll correctly transitions state from "uploading"/"downloading" to stalled when torrent drops from active list -- POST /api/clients default pollIntervalSeconds fixed from 30 to 300 (was below the validated minimum of 60) - -## v1.6.0 — Settings & Debug - -### Features +### Bug Fixes -- Debug button that shows raw API response output for tracker polling -- Settings page decomposed into section components +* update release scripts to push tags to specific origin -## v1.5.0 — Security & Backup Hardening +## [1.8.2](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v1.8.2) (2026-03-15) ### Features -- Re-encryption for backup restore: tokens re-encrypted from backup salt to current salt via reencryptField() -- Progressive login throttling: escalating lockout delays (5 req → 30s, 10 → 2m, 15 → 15m, 20 → 1h) with 429 responses -- SSRF protection: isUnsafeNetworkHost blocks private/loopback/link-local addresses in tracker URLs - -### Changed +* add security audit file tamper check in CI workflow -- Extracted DB-aware privacy operations (createPrivacyMask, scrubSnapshotUsernames) into new privacy-db.ts module to eliminate duplication across 4 route handlers -- Added shared reencrypt() function in crypto.ts, used by change-password and backup-restore routes +### Bug Fixes -### Fixed +* encode PostgreSQL password in DATABASE_URL and update healthcheck command +* refine tracker validation logic to exclude drafts and improve error messages -- Critical SQL column mapping bug in scrubSnapshotUsernames: "group" → group_name (Drizzle schema field mapping) -- UTF-8 consistency in scrubSnapshotUsernames: LENGTH → CHAR_LENGTH -- Missing transaction error handling in change-password route (would crash with opaque 500) -- Master password now validated before parsing backup payload +## [1.8.1](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v1.8.1) (2026-03-15) -## v1.4.0 — Docker & Deployment +### Bug Fixes -### Changed +* move DEFAULT_API_PATHS to constants module -- Simplified backup process by removing encryption for automated backups -- Dockerfile optimized for drizzle-kit schema sync (dedicated build stage, bash in runner) -- Docker entrypoint improved for schema sync process -- PostgreSQL image updated to 17-alpine +## [1.8.0](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v1.8.0) (2026-03-15) -### Fixed +## [1.7.0](https://github.com/jordanlambrecht/tracker-tracker/releases/tag/v1.7.0) (2026-03-15) -- Proxy configuration updated to include tracker logo path -- Drizzle config dotenv handling improved +### Features -## v1.3.0 — Chart & Detail Overhaul +* add 'warned' alert type to AlertsBanner for improved alert handling +* add adapterFetch function and integrate it into Unit3dAdapter for improved API handling +* add alert indicators to stat cards for ratio and buffer warnings +* add anniversary milestone detection with dashboard alerts +* add avatarRemoteUrl update in pollTracker for enhanced avatar handling +* add avatarUrl to Gazelle user stats and update phoenixproject with new user classes and rules +* add BackToTop component +* add cached torrent fallback to useTrackerTorrents hook +* add cached torrents fallback endpoint +* add cachedTorrents columns to downloadClients schema +* add changelog and health check API routes +* add chart preferences hook with DnD reorder and category support +* add clear-all button to alerts, external links on login timers +* add core security and utility libraries +* add custom UI component library +* add dashboard components with token and robustness fixes +* add dashboard settings API route and DB-backed hook +* add devIndicators configuration for improved development experience +* add DND reordering, explicit save, and numbers display to tag groups +* add feature components for settings management +* add fleet dashboard with torrent analytics across all clients +* add formatStatValue function and related tests +* add GET /api/clients/[id]/uptime endpoint +* add lastAccessDate field and enhanced community data in adapters +* add nebulance to valid platforms in tracker validation script +* add nebulanceMeta handling in TrackerDetailPage for improved tracker support +* add nebulanceMeta to AnalyticsTab for enhanced tracker details +* add Prettier +* add promotion/demotion indicators and anniversary milestones to rank timeline +* add qBittorrent client integration +* add quicklinks API route for DB persistence +* add re-encryption functionality for backup restore process and update validation rules for empty API tokens +* add React hooks for click-outside and localStorage +* add required ratio fallback to tracker minimum ratio +* add settings and admin API routes +* add settings page, dashboard layout, and page-level components +* add shared ECharts config builder functions +* add shared Icons, ChevronToggle, and ChartEmptyState components +* add showLoginTimers dashboard setting with shared state +* add support for Nebulance tracker, including adapter and tests +* add swipe/drag gestures to client carousel in sidebar +* add tag group and download client API routes +* add template for new tracker registry entries with guidance and validation steps +* add tracker API extensions with security hardening +* add unit display for ecosystem aggregate stats +* add untagged torrents to tag group breakdown charts +* add update check badge to sidebar +* add upload polar heatmap chart with day-of-week by hour breakdown +* add UptimeBar component to download client settings +* add useClickOutside hook with stable ref pattern +* added debug button that shows raw api response output +* added new tracker logos +* adjust download client poll interval defaults and snapshot retention +* backup system +* cache filtered torrent list on successful deep poll +* conditionally render Join Date input based on selected tracker properties +* **database:** add app_settings, tracker roles, and tracker snapshots tables with initial columns +* docker hardening — standalone output, node 24, multi-arch builds +* enhance security audit with raw SQL check and update test count +* enhance security documentation with comprehensive sections and updates +* enhance StatCard with stacked hero totals and unit support +* enhance tracker support with nebulance integration and improve alert handling +* expand security audit with inline suppression and 8 new checks +* harden auth routes with defense-in-depth security +* heartbeat chart for download clients +* implement bento grid slot system and layout algorithms +* implement explicit save/discard pattern for download client settings +* implement GGnAdapter with user stats fetching and response handling +* implement live active torrents polling and inline speed display +* improve table scrollability and chart card spacing +* include uptime buckets in backup and restore +* increase fleet snapshots query window to 365 days +* initial tracker registries/entries +* integrate uptime recording into heartbeat and deep poll +* login, auth, and totp +* new chart types +* new logo! +* optimize torrent polling with parallel per-tag fetching and state filtering +* portal tooltips, backup settings overhaul, changelog improvements +* refactor chart legend system with log scale support +* refactor EmojiPickerPopover to use createPortal for rendering and improve positioning logic +* refactor torrent stat cards with per-client breakdown and consolidated tables +* separate H&R risk from unsatisfied torrents with distinct display +* show stale data banner when using cached torrents +* Stores 5-minute heartbeat success/failure buckets per download client. Unique constraint on (clientId, bucketTs) prevents duplicates. Cascade delete on client removal. Added to wipe.ts for security scrub coverage. +* torrents tab polish, stat card tooltips, ratio baseline, log scale toggle +* tracker detail UX improvements and new torrent charts +* **trackers:** add new trackers HAWKE-UNO, Nebulance, and REDacted +* update avatarUrl function to include avatarRemoteUrl parameter for improved avatar handling +* update StarIcon and SeedingIcon, add RequiredRatioIcon +* wire responsive bento grid with 3-col and 2-col layout algorithms -### Features +### Bug Fixes -- Chart legend system overhaul: scroll pagination replaced with natural wrapping, adjustable spacing, and an All/None toggle button on multi-series charts -- Log scale toggle added to 6 dashboard charts (Buffer, Seedbonus, Active Torrents, Total Uploaded, Ratio Stability, Buffer Velocity) with auto-detect when data spans >100x range -- Tracker detail page redesign: TrackerHub status card with animated collapse, user ranks table with perk pills, release/notable/banned group badges, elder torrents table with rank column and marquee ticker -- Torrents tab: compact table mode, category acquisition chart, 3D torrent age scatter plot, dead torrents card, average seed time stat card and cohort chart, full-width unsatisfied progress bars -- Ratio chart: red dashed baseline at minimum required ratio -- StatCard tooltip support with hover popup -- Sidebar: archived tracker styling (dimmed, static dot, "Archived" label) and GitHub repo link -- New logo - -### Refactoring - -- Tracker detail page slimmed from 1,229 to 265 lines — extracted TrackerDetailHeader, UserProfileCard, AnalyticsTab, TrackerInfoTab, CoreStatCards, PollLog, platform-specific guard components, and independent data-fetching hooks with AbortControllers -- Dashboard page split into AnalyticsSection and EcosystemStatsSection components -- Chart preference hooks consolidated into shared `useChartPreferencesBase` -- StatCard value/unit prop split for consistent formatting - -### Fixes - -- Log(0) crash in ComparisonChart when switching to log scale with zero-value data points -- VolumeSurface3D (Upload Landscape) background now matches card surface instead of broken WebGL transparent -- Tracker GET endpoint hardened with column projection (excludes avatarData, encrypted tokens from response) -- computeDelta BigInt null guard prevents crash on missing snapshot data -- StrictMode abort race condition: loading state only clears when the active request completes -- Emoji picker overflow in tag group settings -- Proxy toggle disabled when no proxy is configured -- Join date input capped to today in both UI and API validation -- Redirect to dashboard after archiving a tracker -- Design system alignment: raw `

` elements replaced with `

` component across dashboard sections -- Archived trackers no longer appear on the dashboard -- Normalized content categories: "Software" → "Apps", "Sport" → "Sports", "Animation" → "TV" -- Tracker registry test allows `loginIntervalDays: 0` to mean "no login interval policy" - -## v0.1.0 — Initial Release - -- Dashboard with aggregate stats, comparison charts, and tracker leaderboard -- Tracker detail pages with upload/download history, ratio, buffer, seedbonus, and seeding charts -- UNIT3D platform adapter with encrypted API token storage -- Master password auth with Argon2 hash + AES-256-GCM encryption -- Global polling interval (15 min - 24 hours) with unified batch timestamps -- Dark neumorphic UI with per-tracker accent colors -- Sidebar with drag-and-drop reorder, stat modes, and sort options -- Tracker registry with detailed data for Aither, Blutopia, FearNoPeer, OnlyEncodes, and Upload.cx -- TrackerHub integration for site status monitoring -- Rank progression timeline and rank change alerts -- Privacy mode with username/group redaction -- App-wide settings (privacy toggle, data scrub) -- Poll log with collapsible history per tracker +* add aria-label comment for anchor element +* add comment to ignore security audit for async JSON parsing in proxyFetch +* add container names for db and app services in docker-compose.yml +* add dynamic export to AuthLayout for forced dynamic rendering +* add linting step to CI and update dependency review workflow +* add loading boundary to prevent hydration mismatch on tracker detail page +* add permissions for CI workflow to comment on PRs +* add progressive login throttling, SSRF protection, and error sanitization +* add type annotations for TrackerRegistryEntry in TrackerOverviewGrid +* address PR security review findings +* address security audit findings +* allow lint step to continue on error and add dummy DATABASE_URL for build +* bump setup-qemu-action to v4 for node 24 compat +* color hex code bug +* correct barrel inclusion filter to match import format +* correct spelling of 'Sports' in VALID_CONTENT_CATEGORIES +* enhance compareVersions to strip pre-release and build metadata +* enhance tooltip accessibility and cleanup in StatCard component +* ensure master password is validated before parsing backup payload +* harden chart components against XSS and stale data +* harden tracker GET endpoint and align design system usage +* improve security test count verification by refining output parsing +* improve tracker validation comment handling in CI workflow +* improve UI component robustness and React patterns +* isolate drizzle-kit deps in dedicated build stage +* markdown not rendering in changelog dialog +* normalize content categories and update changelog +* now noClients is set based on the latest response every fetch. +* optimize line caching in getCachedLines function +* refactor tracker exports and initialize ALL_TRACKERS array +* remove onRefresh prop from FleetDashboard component +* remove unused onRefresh prop from FleetDashboardProps interface +* remove unused timeAgo function and update category map initialization +* reorder import +* reorder import +* reorder imports +* reorder imports +* replace corepack enable with npm install for pnpm in Dockerfile +* replace div with button for SortableTrackerItem accessibility improvements +* replace img with Image component for logo in Login and Setup pages +* resolve AuthShell hydration mismatch +* resolve biome a11y lint errors and barrel test quote mismatch +* tighten chart spacing on tracker pages, add daily buffer candlestick +* UI polish and input validation improvements +* update allowed content categories in tracker validation +* update base image to node:22-alpine and install corepack globally +* update color definitions to use OKLCH values instead of hex +* update Docker cache settings for multi-platform builds +* update drizzle-kit command path in docker-entrypoint.sh +* update import for CSSProperties type in ChartECharts component +* update import paths for ALL_TRACKERS and DEFAULT_API_PATHS in validate-trackers script +* update json parsing in proxyFetch to use async/await +* update key prop in perks mapping to include index for uniqueness +* update key prop in perks mapping to use perk label for uniqueness +* update matcher in proxy configuration to include trackerTracker_logo +* update packageManager version and upgrade @types/node in package.json and pnpm-lock.yaml +* update packageManager version in package.json and refactor routePathFromFile function +* update PostgreSQL image version to 17-alpine +* update security audit to include additional checks and improve documentation +* update security test count minimum from 41 to 78 +* update security test for setup route username validation +* update SortableTrackerItem to use a div for accessibility and keyboard navigation +* update SortableTrackerItem to use a span for favorite toggle with accessibility improvements +* update version number in changelog to v1.3.0 +* use built-in corepack in node:25-alpine base image +* use URL-based registry lookup in dashboard alerts diff --git a/Dockerfile b/Dockerfile index 1d45ac35..68fc91f8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,10 +21,12 @@ RUN pnpm install --frozen-lockfile # Stage 2 — Build the Next.js app # --------------------------------------------------------------------------- FROM base AS builder +ARG NEXT_PUBLIC_RELEASE_CHANNEL=stable WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 +ENV NEXT_PUBLIC_RELEASE_CHANNEL=$NEXT_PUBLIC_RELEASE_CHANNEL # Dummy DATABASE_URL so Next.js can evaluate route modules during build # (In case DB is never actually queried at build time) ENV DATABASE_URL=postgresql://build:build@localhost:5432/build diff --git a/README.md b/README.md index 4dddab7c..d8f5e387 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Self-hosted dashboard for monitoring private tracker stats over time. Track uplo ## Features - Per-tracker and fleet-wide stats with 30+ charts -- UNIT3D, Gazelle, GGn, and Nebulance support out of the box +- UNIT3D, Gazelle, GGn, Nebulance, MAM, and AvistaZ network support out of the box - qBittorrent integration - cross-seed tracking, activity heatmaps, speed history, etc - Everything stays on your machine. No telemetry, no phoning home. @@ -60,21 +60,25 @@ You can check out a few other, longer, screenshots in the docs folder. | PassThePopcorn (PTP) | Gazelle | 🟡 Unverified | | | 720pier | Custom | 📋 Needs adapter | | | ABTorrents | Custom | 📋 Needs adapter | | -| AvistaZ | Custom | 📋 Needs adapter | | +| AvistaZ | AvistaZ | ✅ Verified | Cookie auth + HTML scraping | +| AnimeZ | AvistaZ | 🟡 Unverified | Same adapter as AvistaZ | +| CinemaZ | AvistaZ | ✅ Verified | Same adapter as AvistaZ | +| ExoticaZ | AvistaZ | 🟡 Unverified | Same adapter as AvistaZ | +| PrivateHD | AvistaZ | 🟡 Unverified | Same adapter as AvistaZ | +| MyAnonamouse (MAM) | MAM | ✅ Verified | Cookie auth via mam_id | +| DarkPeers | UNIT3D | ✅ Verified | | +| Luminarr | UNIT3D | 🟡 Unverified | | | CathodeRayTube (CRT) | UNIT3D | 📋 Draft | | -| CinemaZ | Custom | 📋 Needs adapter | | +| DigitalCore | Custom | 📋 Needs adapter | | | HDBits | Custom | 📋 Needs adapter | | -| MyAnonamouse (MAM) | Custom | 📋 Needs adapter | | | SecretCinema | Custom | 📋 Needs adapter | | | SportsCult | Custom | 📋 Needs adapter | | | TorrentLeech | Custom | 📋 Needs adapter | | | BeyondHD | Custom | ⛔ Stuck | | | Cinemageddon | Custom | ⛔ Stuck | | -| ExotikaZ | Custom | ⛔ Stuck | | | FileList | Custom | ⛔ Stuck | | | HD-Torrents | Custom | ⛔ Stuck | | | IPTorrents | Custom | ⛔ Stuck | | -| PrivateHD | Custom | ⛔ Stuck | | | TVVault | Custom | ⛔ Stuck | | | HawkeUno | UNIT3D | ❌ Broken | API does not permit /user requests | @@ -187,7 +191,7 @@ All other settings — polling interval, privacy mode, proxy, backups — are co Full documentation is available at **[jordanlambrecht.github.io/tracker-tracker](https://jordanlambrecht.github.io/tracker-tracker/)**. -Covers installation, tracker setup (UNIT3D, Gazelle, GGn), features (proxies, TOTP, backups, download clients, notifications), and troubleshooting. +Covers installation, tracker setup (UNIT3D, Gazelle, GGn, MAM, AvistaZ), features (proxies, TOTP, backups, download clients, notifications), and troubleshooting. ## Contributing @@ -199,7 +203,7 @@ PRs welcome. Areas where help matters most: - **Security auditing** — Check out SECURITY.md for threat surfice info. - **Responsiveness** - I only have my 16" MBP to work off of, so feedback of different screen experiences is much appreciated - **Data Visualization** - I ain't no math wizard, so any contributions for data viz, charts/graphs, etc. -- **Custom platform adapters** — trackers marked "Custom" need bespoke adapters since they don't run UNIT3D or Gazelle. +- **Custom platform adapters** — trackers marked "Custom" need bespoke adapters since they don't run a supported platform. - **HawkeUno lobbying** — convince the Hawke mods to add a `/users` endpoint so the adapter can work ## Architecture diff --git a/SECURITY.md b/SECURITY.md index 2cc996a6..5692a7cd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -153,7 +153,7 @@ All API routes validate inputs — `src/app/api/trackers/route.ts`, `src/app/api | qBittorrent tag | string, max 100 chars, trimmed | | Poll interval | integer, clamped to 15-1440 minutes | | Tracker ID | parsed as integer, NaN rejected | -| Platform type | allowlist: `["unit3d", "gazelle", "ggn", "nebulance"]` | +| Platform type | allowlist: `["unit3d", "gazelle", "ggn", "nebulance", "avistaz", "mam", "custom"]` | | Password | string, 8-128 chars | | Role name | string, max 255 chars | | joinedAt | regex-validated YYYY-MM-DD or null | @@ -330,7 +330,7 @@ If you discover a security vulnerability: ### Security Testing -Security invariants are verified by 86 automated tests in `src/lib/__tests__/security.test.ts`: +Security invariants are verified by 106 automated tests in `src/lib/__tests__/security.test.ts`: | Category | Tests | What's Verified | | ---------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -357,7 +357,7 @@ pnpm test:run The GitHub Actions workflow (`.github/workflows/ci.yml`) runs on every push and PR to `main`: 1. **Type check** (`pnpm tsc`) — catches type errors before runtime -2. **Full test suite** (`pnpm test:run`) — all 1250+ tests including security invariants +2. **Full test suite** (`pnpm test:run`) — all 2050+ tests including security invariants 3. **Security test count guard** — fails the build if the security test count drops below 78, preventing accidental removal of security tests 4. **Static security audit** (`scripts/security-audit.ts`) — runs on every PR, comments results on the PR, and fails on critical findings diff --git a/biome.json b/biome.json index 2c54afc6..77861493 100644 --- a/biome.json +++ b/biome.json @@ -1,7 +1,7 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.9/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.11/schema.json", "files": { - "includes": ["**", "!.next", "!node_modules", "!public", "!.history", "!.claude"] + "includes": ["**", "!.next", "!node_modules", "!public", "!.history", "!docs/kb/site"] }, "assist": { "enabled": true, @@ -19,6 +19,9 @@ "noUnusedImports": "warn", "noUnusedVariables": "warn" }, + "suspicious": { + "noArrayIndexKey": "off" + }, "complexity": { "noImportantStyles": "off" } diff --git a/commitlint.config.mjs b/commitlint.config.mjs index fdd00f37..15b3fed2 100644 --- a/commitlint.config.mjs +++ b/commitlint.config.mjs @@ -1,4 +1,37 @@ // commitlint.config.mjs export default { extends: ["@commitlint/config-conventional"], + rules: { + "scope-enum": [ + 2, + "always", + [ + "tracker-adapters", + "alerts", + "api", + "auth", + "backups", + "charts", + "dashboard", + "db", + "events", + "db-fleet", + "login", + "release", + "user-proxy", + "scheduler", + "ci", + "circuit-breaker", + "deps", + "deps-dev", + "schema", + "security", + "settings", + "sidebar", + "trackers", + "ui", + "webhooks", + ], + ], + }, } diff --git a/docs/kb/docs/assets/images/avistaz-cookie-copy-value.png b/docs/kb/docs/assets/images/avistaz-cookie-copy-value.png new file mode 100644 index 00000000..0a339fa8 Binary files /dev/null and b/docs/kb/docs/assets/images/avistaz-cookie-copy-value.png differ diff --git a/docs/kb/docs/assets/images/digitalcore-cookie.png b/docs/kb/docs/assets/images/digitalcore-cookie.png new file mode 100644 index 00000000..fd50d291 Binary files /dev/null and b/docs/kb/docs/assets/images/digitalcore-cookie.png differ diff --git a/docs/kb/docs/contributing/adding-a-tracker.md b/docs/kb/docs/contributing/adding-a-tracker.md index 47ffc4bc..e7fbd26d 100644 --- a/docs/kb/docs/contributing/adding-a-tracker.md +++ b/docs/kb/docs/contributing/adding-a-tracker.md @@ -1,16 +1,16 @@ # Adding a Tracker to the Registry -This guide covers adding a new tracker entry for an **existing platform** (UNIT3D, Gazelle, GGn, or Nebulance). If the tracker runs on a platform that does not have an adapter yet, stop here and read [Tracker API Responses](tracker-responses.md) first — you will need to write an adapter before the registry entry. +Adding a new tracker to an **existing platform** (UNIT3D, Gazelle, GGn, Nebulance, MAM, or AvistaZ) requires one file and two lines in the barrel export. No adapter code needed. -If the tracker you want to add runs on UNIT3D, Gazelle, GGn, or Nebulance, you only need to create one file and add two lines to the barrel export. No adapter code required. +If your tracker runs on a new platform, read [Tracker API Responses](tracker-responses.md) first — you'll need to write an adapter before adding the registry entry. --- ## Standardization Philosophy -Every tracker file in `src/data/trackers/` follows the same field order and completeness rules. This makes files easy to compare, review, and diff. +Every tracker file follows the same field order and completeness rules. This makes diffs clean and reviews fast. -**Every field must be present in every tracker file, even if empty.** Use `""` for empty strings, `[]` for empty arrays, and `false` for booleans. Do not omit fields and do not use `undefined` as a value. Presence in the file shows the field was considered. +**Every field must be present, even if empty.** Use `""` for empty strings, `[]` for empty arrays, and `false` for booleans. Never omit fields or use `undefined` — if it's there, you've decided on it. ```typescript abbreviation: "" // not: abbreviation: undefined @@ -20,23 +20,23 @@ bannedGroups: [] // not: bannedGroups: undefined warning: false // not: warning: undefined ``` -**There are three exceptions to this rule:** +**Three exceptions:** -1. The `stats` block is omitted entirely when no real data exists. Do not include the block with `undefined` values. -2. `rules.fulfillmentPeriodHours`, `rules.hnrBanLimit`, and `rules.fullRulesMarkdown` are truly optional — omit them when unknown rather than setting them to `undefined`. -3. Platform-specific fields (`gazelleAuthStyle`, `gazelleEnrich`, `unit3dAuthStyle`) only appear in tracker files for their respective platform. Do not add them to tracker files on other platforms. +1. Omit the `stats` block entirely when you don't have real data. Don't include it with `undefined` values. +2. `rules.fulfillmentPeriodHours`, `rules.hnrBanLimit`, and `rules.fullRulesMarkdown` are truly optional — omit them when unknown. +3. Platform-specific fields (`gazelleAuthStyle`, `gazelleEnrich`, `unit3dAuthStyle`) only belong in tracker files for their own platform. Don't add them to other trackers. --- ## 1. Copy the Template -The template lives at `src/data/trackers/_template.ts`. Copy it to a new file named after your tracker's slug. The slug must be lowercase with hyphens only — no underscores, no uppercase, no special characters. +Copy `src/data/trackers/_template.ts` to a new file matching your tracker's slug (lowercase, hyphens only): ```bash cp src/data/trackers/_template.ts src/data/trackers/mytracker.ts ``` -Here is the full template for reference: +Full template: ```typescript // src/data/trackers/_template.ts @@ -142,7 +142,7 @@ export const mytracker: TrackerRegistryEntry = { ## 2. Field Reference -Fields are documented in the same order they appear in the template, grouped by section. +Fields are documented in template order, grouped by section. ### Identity @@ -150,7 +150,7 @@ Fields are documented in the same order they appear in the template, grouped by Type: `string` -The unique identifier for this tracker. Used in file names, URL paths, and database lookups. Must be lowercase with hyphens only. +The unique identifier for this tracker. Used in filenames, URLs, and database lookups. Lowercase with hyphens only. ```typescript slug: "blutopia" @@ -184,19 +184,19 @@ abbreviation: "" // no abbreviation Type: `string` -The base URL of the tracker site, including protocol. HTTPS only. No trailing slash. +The base URL including protocol. HTTPS only, no trailing slash. ```typescript url: "https://blutopia.cc" ``` -The adapter constructs the API request by appending `apiPath` to this value. +The adapter appends `apiPath` to this URL to make API requests. #### `description` Type: `string` -One or two sentences describing what the tracker is about — content focus, community reputation, anything a prospective member would want to know. +One or two sentences about what the tracker is — content focus, community reputation, anything someone might want to know before joining. ```typescript description: "The largest general music tracker (also has some software). Has an interview to join, although the wait can be notoriously long." @@ -210,16 +210,17 @@ description: "The largest general music tracker (also has some software). Has an Type: `"unit3d" | "gazelle" | "ggn" | "nebulance" | "mam" | "custom"` -Which adapter handles API requests for this tracker. This controls how the scheduler fetches stats. Must match the software the tracker runs. +Which adapter handles API requests. This tells the scheduler how to fetch stats. Pick the one that matches the tracker's software. | Platform | What it means | | ------------- | -------------------------------------------------------------- | -| `"unit3d"` | Runs the UNIT3D codebase | -| `"gazelle"` | Runs Gazelle or a derivative (Orpheus, Gazelle-Music, etc.) | -| `"ggn"` | GazelleGames only — custom API different from standard Gazelle | -| `"nebulance"` | Nebulance-specific API | -| `"mam"` | MyAnonaMouse — cookie-based auth via `mam_id` session cookie | -| `"custom"` | Placeholder, not implemented — do not use | +| `"unit3d"` | Runs UNIT3D | +| `"gazelle"` | Runs Gazelle or a fork (Orpheus, Gazelle-Music, etc.) | +| `"ggn"` | GazelleGames only — has its own custom API | +| `"nebulance"` | Uses Nebulance's API | +| `"mam"` | MyAnonaMouse — cookie-based auth via `mam_id` | +| `"avistaz"` | AvistaZ network — cookie auth + profile scraping | +| `"custom"` | Placeholder, not implemented yet | #### `gazelleAuthStyle` @@ -236,7 +237,7 @@ Only include this field for Gazelle trackers. If you are unsure which style a Ga Type: `boolean` — Gazelle trackers only -When `true`, the adapter makes a second API call (`action=user&id=X`) after the initial `action=index` call to fetch seeding/leeching counts, warned status, joined date, avatar, ranks, and community stats. **All Gazelle trackers must set this to `true`** — without it, seeding and leeching counts will always be 0. +When `true`, the adapter makes a second API call (`action=user&id=X`) after the initial `action=index` call to get seeding/leeching counts, warned status, joined date, avatar, ranks, and community stats. **Set this to `true` for all Gazelle trackers** — without it, seeding and leeching will show as 0. ```typescript gazelleEnrich: true @@ -265,24 +266,19 @@ Only include this field for UNIT3D trackers. Type: `string` -The path appended to `url` when making API requests. Must match the platform's actual API endpoint. +The path appended to `url` for API requests. Must match the platform's actual endpoint. -| Platform | Default apiPath | -| ----------- | --------------- | -| `unit3d` | `"/api/user"` | -| `gazelle` | `"/ajax.php"` | -| `ggn` | `"/api.php"` | -| `nebulance` | `"/api.php"` | +| Platform | Default | +| ----------- | ------- | +| `unit3d` | `/api/user` | +| `gazelle` | `/ajax.php` | +| `ggn` | `/api.php` | +| `nebulance` | `/api.php` | +| `avistaz` | `/profile` | -```typescript -// UNIT3D tracker -apiPath: "/api/user" - -// Gazelle tracker -apiPath: "/ajax.php" -``` +Almost all trackers use the platform default. Only change it if you've verified the tracker deviates. -Do not change this from the platform default unless you have verified that the tracker uses a non-standard path. Almost no trackers deviate from the defaults above. +AvistaZ trackers scrape HTML using browser cookies. All five AvistaZ sites (AvistaZ, AnimeZ, PrivateHD, CinemaZ, ExoticaZ) use the same adapter. --- @@ -306,7 +302,7 @@ Type: `string[]` The categories of content hosted on the tracker. Must use values from the allowed list exactly as written (case-sensitive): -``` +```md Movies, TV, Music, Games, Apps, Sports, Books, Audiobooks, Comics, Manga, Anime, XXX, Documentaries, Education, Tutorials, Fanres, iOS Apps, Graphics, Audio @@ -345,7 +341,7 @@ color: "#1a4fc2" // Nebulance blue Type: `string` -Path to the tracker's logo file under the `public/` directory. The file must actually exist — do not set this field to a path unless you have added the logo. Use `""` if none. +Path to the tracker's logo file in `public/`. Only set this if the file exists. Use `""` if none. SVG preferred, PNG acceptable. ```typescript logo: "/tracker-logos/aither_logo.svg" @@ -353,8 +349,6 @@ logo: "/tracker-logos/nebulance_logo.png" logo: "" ``` -SVG is preferred. PNG is acceptable. - --- ### External Links @@ -428,9 +422,9 @@ profileUrlPattern: "" ### Stats -The `stats` block is **omitted entirely** when no real data is available. Do not include the block with `undefined` values — the absence of the block signals that no stats have been sourced yet. +**Omit the `stats` block entirely** when you don't have real data. Don't include it with `undefined` values — the absence of the block signals that stats haven't been sourced yet. -When you do have data, include only the fields you know: +When you have data, include only the fields you know: ```typescript stats: { @@ -468,7 +462,7 @@ interface TrackerUserClass { } ``` -For most trackers, `name` and `requirements` are all you need. Write `requirements` as a human-readable summary — no need to be exhaustive, but include the key numeric thresholds (upload amount, ratio, account age, seed count). +For most trackers, `name` and `requirements` are enough. Write `requirements` as a human-readable summary — hit the key numeric thresholds (upload, ratio, account age, seed count) without being exhaustive. ```typescript // From aither.ts — upload-based progression @@ -609,7 +603,7 @@ rules: { } ``` -For `fullRulesMarkdown`, use the array-join format. This keeps diffs clean and avoids multiline template literal indentation issues: +For `fullRulesMarkdown`, use array-join format. It keeps diffs clean and avoids multiline template literal indentation issues: ```typescript fullRulesMarkdown: [ @@ -629,37 +623,28 @@ fullRulesMarkdown: [ ## 6. Register in the Barrel File -Open `src/data/trackers/index.ts`. You need to add the tracker in two places. - -**Step 1 — Add a named export** in the `export *` block at the top. Keep the list alphabetically sorted: +Open `src/data/trackers/index.ts` and add in three places (alphabetized): +**Export block:** ```typescript -export * from "./morethantv" -export * from "./mytracker" // add this -export * from "./myanonamouse" +export * from "./mytracker" ``` -**Step 2 — Add a named import** in the import block in the middle of the file: - +**Import block:** ```typescript -import { morethantv } from "./morethantv" -import { mytracker } from "./mytracker" // add this -import { myanonamouse } from "./myanonamouse" +import { mytracker } from "./mytracker" ``` -**Step 3 — Add the tracker to the `ALL_TRACKERS` array** at the bottom. Keep this list alphabetically sorted as well: - +**`ALL_TRACKERS` array:** ```typescript export const ALL_TRACKERS: TrackerRegistryEntry[] = [ // ... - morethantv, - mytracker, // add this - myanonamouse, + mytracker, // ... ] ``` -The exported const name must match the variable exported from your tracker file. For a file that exports `export const mytracker: TrackerRegistryEntry = { ... }`, the import and array entry are both `mytracker`. +The const name must match what you exported from your tracker file. --- @@ -671,7 +656,7 @@ The exported const name must match the variable exported from your tracker file. pnpm tsc ``` -This will catch any missing required fields or type mismatches. Fix all errors before proceeding. +This catches missing required fields or type mismatches. Fix all errors before proceeding. ### Run the test suite @@ -679,7 +664,7 @@ This will catch any missing required fields or type mismatches. Fix all errors b pnpm test:run ``` -The test suite validates tracker registry entries — required fields, valid platform types, correct `apiPath` values for each platform, valid content category names, and that all `bannedGroups` entries are plain strings. +The test suite validates tracker registry entries: required fields, valid platform types, correct `apiPath` values per platform, valid content category names, and plain-string `bannedGroups` entries. ### Check it in the UI @@ -698,28 +683,28 @@ If the tracker appears in search results and polls successfully, the registry en ### Forgetting to add to the barrel file -The most common mistake. If you create `src/data/trackers/mytracker.ts` but do not edit `index.ts`, the tracker will never appear in the UI. The file must be exported and imported in `index.ts`, and the variable must be added to `ALL_TRACKERS`. +The most common mistake. If you create `src/data/trackers/mytracker.ts` but skip editing `index.ts`, the tracker won't appear in the UI. Export and import it in `index.ts`, and add the variable to `ALL_TRACKERS`. ### Wrong `platform` type -Using `"unit3d"` for a Gazelle tracker or vice versa will cause the adapter to send the wrong API request. The scheduler will log a `fetch` error or return garbled data. Check the tracker's tech stack — most UNIT3D sites have `/api/user` in their documentation, and most Gazelle sites have `/ajax.php`. +Using `"unit3d"` for a Gazelle tracker (or vice versa) causes the adapter to send the wrong API request. The scheduler will log a `fetch` error or return garbled data. Check the tracker's tech stack — most UNIT3D sites document `/api/user`, and most Gazelle sites use `/ajax.php`. ### Wrong `apiPath` -Each platform has a default API path (see the table in the field reference above). Setting `apiPath: "/api/user"` on a Gazelle tracker, for example, will cause every poll to fail with a 404. The path must match what the platform actually serves. +Each platform has a default API path (see the field reference table above). Setting `apiPath: "/api/user"` on a Gazelle tracker will cause every poll to fail with 404. Match what the platform actually serves. ### `draft: true` left in a finished entry -If `draft` is `true`, the entry is filtered out of `TRACKER_REGISTRY` at runtime and never shown to users. Remove the field or set `draft: false` when the entry is complete. +If `draft: true`, the entry is filtered out at runtime and never shown to users. Remove the field or set `draft: false` when you're done. ### Invalid content category names -The `contentCategories` array only accepts values from the fixed allowed list. A typo like `"Movie"` instead of `"Movies"`, or `"Audiobook"` instead of `"Audiobooks"`, will fail the registry validation tests. The list is case-sensitive. +The `contentCategories` array only accepts the fixed allowed list. A typo like `"Movie"` instead of `"Movies"` will fail validation. The list is case-sensitive. ### `color` not a valid hex code -The `color` field must be a full six-digit hex string starting with `#`. Shorthand hex (`#fff`) and named colors (`red`) are not accepted. Use a real hex value. +The `color` field must be a full six-digit hex string starting with `#`. Shorthand hex (`#fff`) and named colors don't work. Use real hex. ### Logo path points to a missing file -If you set `logo: "/tracker-logos/mytracker.svg"` but the file does not exist under `public/`, the logo image will silently 404 and show a broken image in the UI. Either add the file or set `logo: ""`. +If you set `logo: "/tracker-logos/mytracker.svg"` but the file doesn't exist in `public/`, the image will 404 and show broken in the UI. Add the file or set `logo: ""`. diff --git a/docs/kb/docs/contributing/index.md b/docs/kb/docs/contributing/index.md deleted file mode 100644 index b5246eaf..00000000 --- a/docs/kb/docs/contributing/index.md +++ /dev/null @@ -1,119 +0,0 @@ -# Contributing - -This section covers the development workflow for working on Tracker Tracker itself — setting up a local environment, running tests, and keeping the codebase clean before submitting changes. - ---- - -## Dev Environment Setup - -**Prerequisites:** Node.js >= 22, pnpm, PostgreSQL - -```bash -# 1. Install dependencies -pnpm install - -# 2. Copy the environment template and fill in your values -cp .env.example .env - -# 3. Start a local PostgreSQL instance (Docker is easiest) -docker run -d \ - --name tracker-tracker-db \ - -e POSTGRES_USER=postgres \ - -e POSTGRES_PASSWORD=postgres \ - -e POSTGRES_DB=trackertracker \ - -p 5432:5432 \ - postgres:16-alpine - -# 4. Push the Drizzle schema to the database -pnpm db:push - -# 5. Start the dev server -pnpm dev -``` - -The app will be available at `http://localhost:3000`. On first run it redirects to `/setup` to create a master password. - -### Key environment variables - -| Variable | Description | -| ------------------------- | ------------------------------------------------ | -| `DATABASE_URL` | Postgres connection string | -| `NEXTAUTH_SECRET` | Random secret for JWE session signing | -| `NEXT_PUBLIC_APP_VERSION` | Auto-populated from `package.json` at build time | - ---- - -## Running Tests - -The test suite uses [Vitest](https://vitest.dev/). - -```bash -# Run all tests once (CI mode) -pnpm test:run - -# Run in watch mode during development -pnpm test -``` - -Test files live in `src/lib/__tests__/` and alongside source files as `*.test.ts`. When adding new adapter logic or utility functions, add a corresponding test file. - ---- - -## Type Checking - -```bash -pnpm tsc -``` - -This runs `tsc --noEmit` against the full project. The codebase must be clean before merging — no `any` escape hatches without a comment explaining why. - ---- - -## Linting - -```bash -pnpm lint -``` - -This runs both steps in sequence: - -1. `pnpm tsc` — TypeScript type checking -2. `biome check .` — Biome lint rules (import ordering, unused variables, etc.) - -The project uses **Biome** for linting, not ESLint. Do not add ESLint config or ESLint plugins. Biome is also **not** used for formatting — Prettier handles that separately via `pnpm format`. - -To check formatting without writing: - -```bash -pnpm format:check -``` - ---- - -## Database Schema Changes - -The project uses **Drizzle's schema-first approach**. All schema changes go in `src/lib/db/schema.ts`. Never write raw SQL migrations. - -After editing the schema: - -```bash -pnpm db:push -``` - -This pushes schema changes directly to your local database. For production, the same command applies — there is no separate migration file to commit. - ---- - -## Project Conventions - -- **Package manager:** pnpm only. Never use npm or yarn. -- **File header comment:** Every JS/TS file starts with `// path/to/file.ts` (relative to project root). -- **Function index comment:** If a file contains five or more functions, add a comment block at the top listing them all. -- **Imports:** Import only what you need — `import { useEffect } from "react"`, not `React.useEffect`. -- **Error handling:** Never swallow errors silently. Wrap thrown errors with context. - ---- - -## Further Reading - -- [Tracker API Responses](tracker-responses.md) — Raw JSON shapes from each tracker platform, used as a reference when adding new trackers or debugging adapter issues. diff --git a/docs/kb/docs/contributing/slot-system.md b/docs/kb/docs/contributing/slot-system.md index dfb27566..0d0a912a 100644 --- a/docs/kb/docs/contributing/slot-system.md +++ b/docs/kb/docs/contributing/slot-system.md @@ -1,16 +1,16 @@ # Bento Grid Slot System -The tracker detail page's Data & Analytics tab uses a slot-based bento grid to render per-platform stat cards alongside the universal core stats. This document covers how the system works and how to add a new stat card. +The tracker detail page's Data & Analytics tab uses a slot-based bento grid to render platform-specific stat cards alongside universal core stats. Learn how it works and how to add a new card. --- ## What is the bento grid? -The analytics tab combines eight fixed core stats (Uploaded, Downloaded, Ratio, Buffer, Seeding, Leeching, Hit & Runs, Required Ratio) with a variable number of platform-specific slot cards. Slots are registered centrally and each slot decides at render time whether it has anything to show for the current tracker — if not, it returns `null` and is excluded entirely from the layout. +The analytics tab combines eight fixed core stats with platform-specific slot cards. Slots decide at render time whether to show — return `null` to hide. -The grid needs to pack single-height and double-height cards into a clean rectangular layout without orphaned cells or large gaps. Rather than using CSS auto-placement (which can leave unpredictable holes), the layout algorithm runs ahead of time and produces explicit `row-start`, `col-start`, and `row-span` classes for every card. Each responsive breakpoint has its own algorithm and produces an independent placement. +The grid packs 1-tall and 2-tall cards cleanly without orphans or gaps. Instead of CSS auto-placement, the layout algorithm pre-computes `row-start`, `col-start`, and `row-span` classes per breakpoint. -**Why explicit positioning?** Tailwind's CSS grid auto-placement works fine for uniform grids, but breaks down when mixing 1-tall and 2-tall cards across multiple breakpoints. Explicit positioning gives full control over where each card lands and eliminates gaps. +**Why explicit positioning?** Tailwind auto-placement leaves holes when mixing 1-tall and 2-tall cards. Explicit positioning gives full control. --- @@ -34,14 +34,12 @@ This document focuses on **`stat-card`** slots, as they are the most common thin ## Slot sizes -Each stat-card slot has a `span` field that determines how many grid rows it occupies. +| `span` | CardType | Description | +| --- | --- | --- | +| `1` (default) | `single` | 1x1 card | +| `2` | `double` or `triple` | 2-row tall card; algorithm may promote to `triple` (3 rows) for better layout | -| `span` | CardType in layout | Description | -| ------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| `1` (default) | `single` | Standard 1×1 card — one row tall, one column wide | -| `2` | `double` (or `triple` if promoted) | Tall card — two rows tall, one column wide. Can be promoted to `triple` (three rows) by the algorithm when it produces a better layout. | - -The algorithm may promote a `double` to a `triple` (three rows) when doing so reduces gaps or eliminates an orphan. This is purely a layout decision — the slot itself only declares `span: 1` or `span: 2`. The `triple` type exists in `CardType` but is never assigned directly by slot authors. +Promotion happens automatically when it reduces gaps. You only declare `span: 1` or `span: 2` — the algorithm handles `triple`. --- @@ -49,25 +47,24 @@ The algorithm may promote a `double` to a `triple` (three rows) when doing so re ### SlotContext -The object passed to every slot's `resolve` function: +Data your slot's `resolve` function receives: ```ts -// src/lib/slot-types.ts export interface SlotContext { - tracker: TrackerSummary // DB row + computed fields + tracker: TrackerSummary latestSnapshot: Snapshot | null snapshots: Snapshot[] meta: GGnPlatformMeta | GazellePlatformMeta | NebulancePlatformMeta | null registry: TrackerRegistryEntry | undefined - accentColor: string // tracker's hex color, e.g. "#00d4ff" + accentColor: string // hex, e.g. "#00d4ff" } ``` -`meta` is the platform-specific extra data returned by the adapter. It is `null` for UNIT3D trackers (they have no extra meta yet). Always guard with `if (!meta)` before accessing platform fields. +`meta` is null for UNIT3D trackers and platforms without extra data. Always guard `if (!meta)` before accessing platform fields. ### ResolvedSlot -What the registry produces after calling `resolve`: +What the registry produces after it calls `resolve`: ```ts // src/lib/slot-types.ts @@ -101,26 +98,26 @@ interface SlotDefinition

> { ## StatCard variants -All stat-card slots render via the unified `StatCard` component in `src/components/ui/StatCard.tsx`. It has three variants selected by the `type` prop. +All stat-card slots use `StatCard` from `src/components/ui/StatCard.tsx`. Pick one of three variants via `type`. ### `basic` (default) -Single hero value. Use for any scalar metric. +Single hero value for any scalar metric. ```ts interface StatCardBasicProps { - type?: "basic" // optional — "basic" is the default - label: string // card title, displayed uppercase - value: string | number // large hero number - unit?: string // displayed small next to value, e.g. "BON", "GiB" - subtitle?: string // small text below the value - subValue?: string // secondary line, mono font, tertiary color + type?: "basic" + label: string + value: string | number + unit?: string // "BON", "GiB", etc. + subtitle?: string + subValue?: string trend?: "up" | "down" | "flat" - tooltip?: string // adds a "?" button with a popover - icon?: ReactNode // 16×16 icon in the top-right corner - accentColor?: string // hex color for the glow effect + tooltip?: string // shows "?" button with popover + icon?: ReactNode // 16x16 + accentColor?: string alert?: "warn" | "danger" - alertReason?: string // shown in a "!" tooltip when alert is set + alertReason?: string // shown in "!" tooltip } ``` @@ -153,18 +150,18 @@ interface StatCardStackedProps { } ``` -A `stacked` card with `span: 2` occupies two grid rows, giving the rows more vertical breathing room. +A `stacked` card with `span: 2` takes two grid rows, giving rows more vertical space. ### `ring` -Countdown progress ring. Used exclusively for the Login Deadline card. Renders an SVG ring that fills as the deadline approaches and turns amber → red as it gets close. +Countdown progress ring for login deadlines. Renders an SVG ring that fills and turns amber → red as the deadline nears. ```ts interface StatCardRingProps { - type: "ring" // required + type: "ring" title?: string // defaults to "Login Deadline" - lastAccessAt: string // ISO date string of last tracker visit - loginIntervalDays: number // from registry entry's rules.loginIntervalDays + lastAccessAt: string // ISO date + loginIntervalDays: number tooltip?: string accentColor?: string alert?: "warn" | "danger" @@ -172,7 +169,7 @@ interface StatCardRingProps { } ``` -The ring variant is driven by data from `ctx.tracker.lastAccessAt` and `ctx.registry?.rules?.loginIntervalDays`. It is unlikely you will need a second ring card. +You'll rarely need more than one ring card. --- @@ -180,7 +177,7 @@ The ring variant is driven by data from `ctx.tracker.lastAccessAt` and `ctx.regi ### 1. Decide what data you need -Look at `SlotContext` above. If your data is in `latestSnapshot`, you can read it directly. If it requires platform-specific `meta`, check what fields are available on the relevant `*PlatformMeta` type in `src/lib/adapters/types.ts`. +If your data is in `latestSnapshot`, read it directly. For platform-specific `meta`, check `*PlatformMeta` in `src/lib/adapters/types.ts`. ### 2. Write the slot definition @@ -238,22 +235,19 @@ const myTrackerTokensSlot: SlotDefinition = { ### 3. Register it -Add the slot to the `SLOT_DEFINITIONS` array at the bottom of `slot-registry.ts`. The array order does not determine layout position — `priority` does. Lower priority numbers appear first (leftmost in the first available row). +Add the slot to `SLOT_DEFINITIONS` in `slot-registry.ts`. Array order doesn't matter — `priority` does. Lower priority = renders first. ```ts export const SLOT_DEFINITIONS: AnySlotDefinition[] = [ - // stat-card slots loginDeadlineSlot, goldSlot, - // ... existing slots ... myTrackerInvitesSlot, // add here myTrackerTokensSlot, - // badge slots // ... ] ``` -That is all. The resolver, layout algorithm, and renderer pick it up automatically. +Done. The system picks it up automatically. ### 4. Verify the resolve guard @@ -292,29 +286,29 @@ The first N cards in the `single` pool are marked `fixed` (N = number of columns ### 4-column breakpoint (`findOptimalLayout4Col`) -This is the primary desktop layout. It brute-forces all valid combinations of column count (3 or 4) and double-to-triple promotions, then ranks them by: +This is the main desktop layout. It tries all valid combinations of column count (3 or 4) and promotions, ranking by: 1. No orphaned card in the last row (preferred) 2. Fewest gap cells 3. Prefer 4 columns over 3 -4. Fewest triples (promotions) +4. Fewest promotions -The winning configuration's cards each receive a `{ row, col, span }` placement. `getCardClasses` turns these into static Tailwind classes (`row-start-N col-start-N row-span-N`). The row/col start classes are pre-enumerated as lookup tables (up to 30 rows) rather than generated dynamically, because Tailwind v4 requires static class names for its JIT scanner. +The winner's cards get a `{ row, col, span }` placement. `getCardClasses` converts these to static Tailwind classes (`row-start-N col-start-N row-span-N`). Row/col classes are pre-enumerated lookup tables (up to 30 rows) instead of generated, because Tailwind v4 requires static class names. -**Placement order within the winner:** +**Placement order:** - Row 1: core stat singles (up to 4) -- Triple-height blocks: promoted doubles fill columns left to right; remaining columns in the same row block are filled with singles stacked 3-tall -- Double-height blocks: doubles fill columns left to right; remaining columns filled with pairs of singles -- Remaining singles: flow left to right, top to bottom in remaining rows +- Triple-height blocks: promoted doubles fill columns left to right; other columns get stacked singles +- Double-height blocks: doubles fill columns left to right; other columns get pairs of singles +- Remaining singles: flow left to right, top to bottom ### 3-column breakpoint (`findOptimalLayout3Col`) -Fixed 3 columns. Same brute-force promotion strategy but ranks by: no-orphan → fewest gaps → fewest triples (no column-count preference since columns are fixed). Implemented and tested. **Currently wired into the `md` breakpoint** (`hidden md:grid md:grid-cols-3 lg:hidden`). +Fixed 3 columns. Same brute-force strategy, ranks by: no-orphan → fewest gaps → fewest triples. **Currently wired to the `md` breakpoint** (`hidden md:grid md:grid-cols-3 lg:hidden`). ### 2-column breakpoint (`findOptimalLayout2Col`) -Fixed 2 columns. Deterministic: promotes at most one double to a triple when the total cell count is odd (to keep columns balanced). **Currently wired into the mobile grid** (`grid grid-cols-2 md:hidden`). +Fixed 2 columns. Deterministic: promotes at most one double to triple when the total cell count is odd. **Currently wired to mobile** (`grid grid-cols-2 md:hidden`). ### Breakpoint wiring (current status) @@ -324,39 +318,39 @@ Fixed 2 columns. Deterministic: promotes at most one double to a triple when the | Medium (`md` to `lg`) | `md:grid-cols-3` | `findOptimalLayout3Col` | Wired and active | | Large (`>= lg`) | `lg:grid-cols-3` or `lg:grid-cols-4` | `findOptimalLayout4Col` | Wired and active | -The large grid uses `lg:grid-cols-4` when the algorithm selects 4 columns, or `lg:grid-cols-3` when it finds 3 columns produces fewer gaps. +The large grid uses `lg:grid-cols-4` when the algorithm picks 4 columns, or `lg:grid-cols-3` when 3 produces fewer gaps. --- ## How the renderer maps cards to content -In `AnalyticsTab.tsx`, `renderLayoutCards` iterates the placed cards and maps each card ID to a React element: +In `AnalyticsTab.tsx`, `renderLayoutCards` iterates placed cards and maps each ID to a React element: -- `s1` through `s{coreCount}` → core stat descriptors from `buildCoreStatDescriptors` -- `s{coreCount + 1}` onward → `span: 1` slot cards in priority order -- `t1`, `t2`, ... → the first T promoted slot doubles (triples) -- `d1`, `d2`, ... → the remaining slot doubles (at offset T) +- `s1` through `s{coreCount}` → core stat descriptors +- `s{coreCount + 1}` onward → `span: 1` slot cards, by priority +- `t1`, `t2`, ... → the first T promoted doubles (triples) +- `d1`, `d2`, ... → remaining doubles (offset by T) -The full pipeline for stat-card slots: +The full pipeline: -``` -SlotContext built in tracker detail page - → SLOT_DEFINITIONS[n].resolve(ctx) called for each definition - → null returns filtered out - → surviving slots sorted by priority - → split into singleSlots (span=1) and doubleSlots (span=2) - → counts passed to layout algorithms - → layout algorithms return PlacedCard[] +```md +Build SlotContext in tracker detail page + → Call SLOT_DEFINITIONS[n].resolve(ctx) for each + → Filter out null returns + → Sort survivors by priority + → Split into singleSlots (span=1) and doubleSlots (span=2) + → Pass counts to layout algorithms + → Layout algorithms return PlacedCard[] → renderLayoutCards maps card IDs to elements → getCardClasses(card) produces positioning classes - → rendered as

{element}
+ → Render as
{element}
``` --- ## Adding a new badge slot -Badge slots follow the same `SlotDefinition` shape but use `SlotBadge` as the component: +Badge slots use the same `SlotDefinition` shape but render via `SlotBadge`: ```ts const myBadgeSlot: SlotDefinition = { @@ -371,7 +365,7 @@ const myBadgeSlot: SlotDefinition = { } ``` -`SlotBadgeProps` variants: `"default"`, `"accent"`, `"warn"`, `"danger"`. Badges are collected separately from stat-card slots and rendered as a horizontal pill row, not inside the bento grid. +`SlotBadgeProps` variants: `"default"`, `"accent"`, `"warn"`, `"danger"`. Badges are collected separately and rendered as a horizontal pill row above the bento grid. --- diff --git a/docs/kb/docs/contributing/tracker-responses-gazelle.md b/docs/kb/docs/contributing/tracker-responses-gazelle.md index 0ffe9382..726d5b05 100644 --- a/docs/kb/docs/contributing/tracker-responses-gazelle.md +++ b/docs/kb/docs/contributing/tracker-responses-gazelle.md @@ -2,21 +2,21 @@ ## Endpoint (action=index) -``` +```bash GET {baseUrl}/ajax.php?action=index ``` -The path `ajax.php` comes from the tracker's `apiPath` field. This is standard across all Gazelle forks. +All Gazelle forks use `ajax.php` by default, but you can override it per tracker via `apiPath`. ## Authentication -API token passed as an HTTP header: +Most forks expect the token as a header: -``` +```bash Authorization: token TOKEN ``` -Some forks (configured with `authStyle: "raw"`) use the token value directly without the `token ` prefix. The adapter checks `options.authStyle` to handle this. +Some sites (`authStyle: "raw"`) omit the `token` prefix. The adapter checks `options.authStyle` to handle both. ## Example Response @@ -50,7 +50,7 @@ Some forks (configured with `authStyle: "raw"`) use the token value directly wit } ``` -Byte values are **raw integers** (bytes), not formatted strings. The `id` field in the top-level response is the user's remote ID — the adapter caches this as `remoteUserId` for use in the enrichment call. +Byte values are raw integers (bytes), not formatted strings. The `id` field is the remote user ID — cached as `remoteUserId` for the optional enrichment call. ## Field Mapping (action=index) @@ -75,14 +75,12 @@ Byte values are **raw integers** (bytes), not formatted strings. The `id` field ## Enrichment Response (action=user) -Trackers configured with `enrich: true` make a second call after the index request: +If the tracker has `gazelleEnrich: true`, a second call fetches the full profile (warned status, join date, reliable seeding/leeching counts, ranks, avatar): -``` +```bash GET {baseUrl}/ajax.php?action=user&id={USER_ID} ``` -This call fetches the full user profile including warned status, join date, seeding/leeching counts from the community object, ranks, and avatar. - ### Example Response ```json @@ -144,32 +142,32 @@ This call fetches the full user profile including warned status, join date, seed ### What the enrichment step overrides -| TrackerStats field | Source in action=user response | Notes | -| ------------------ | -------------------------------- | ---------------------------------------- | -| `warned` | `personal.warned` | Overrides the `false` default from index | -| `joinedDate` | `stats.joinedDate` | Not available from index | -| `lastAccessDate` | `stats.lastAccess` | Not available from index | -| `bufferBytes` | `stats.buffer` | Richer than the calculated value | -| `seedingCount` | `community.seeding` | More reliable than index for many forks | -| `leechingCount` | `community.leeching` | More reliable than index for many forks | -| `avatarUrl` | `avatar` | Not available from index | -| `platformMeta` | `personal`, `ranks`, `community` | Full `GazellePlatformMeta` object | +| Field | Source | Notes | +| --- | --- | --- | +| `warned` | `personal.warned` | Overrides `false` default | +| `joinedDate` | `stats.joinedDate` | Index has none | +| `lastAccessDate` | `stats.lastAccess` | Index has none | +| `bufferBytes` | `stats.buffer` | Better than calculated value | +| `seedingCount` | `community.seeding` | More reliable than index | +| `leechingCount` | `community.leeching` | More reliable than index | +| `avatarUrl` | `avatar` | Index has none | +| `platformMeta` | `personal`, `ranks`, `community` | Full object | -If the enrichment call fails for any reason, the adapter continues with core stats from the index response — the failure is non-fatal. +If enrichment fails, we keep the index stats. No problem. --- ## Gazelle Fork Variations -| Site | bonusPoints field | freeleechTokens | seedingcount in index | -| -------------------- | ----------------- | --------------- | --------------------- | -| Redacted (RED) | `bonusPoints` | Sometimes | No | -| Orpheus (OPS) | `bonusPoints` | Sometimes | No | -| BroadcasTheNet (BTN) | Varies | No | No | -| PassThePopcorn (PTP) | Varies | No | No | -| AnimeBytes (AB) | Varies | Varies | No | +| Site | bonusPoints field | freeleechTokens | seedingcount in index | +| --- | --- | --- | --- | +| Redacted (RED) | `bonusPoints` | Sometimes | No | +| Orpheus (OPS) | `bonusPoints` | Sometimes | No | +| BroadcasTheNet (BTN) | Varies | No | No | +| PassThePopcorn (PTP) | Varies | No | No | +| AnimeBytes (AB) | Varies | Varies | No | -GazelleGames (GGn) is handled by its own separate adapter — see the [GGn page](tracker-responses-ggn.md). +GGn uses a separate adapter — see the [GGn page](tracker-responses-ggn.md). ## Supported Trackers diff --git a/docs/kb/docs/contributing/tracker-responses-ggn.md b/docs/kb/docs/contributing/tracker-responses-ggn.md index 62c3f193..5af0563f 100644 --- a/docs/kb/docs/contributing/tracker-responses-ggn.md +++ b/docs/kb/docs/contributing/tracker-responses-ggn.md @@ -1,19 +1,19 @@ # GGn (GazelleGames) API Response -GGn shares Gazelle's overall architecture but differs enough to warrant its own adapter. It uses query-parameter auth instead of headers, a two-step fetch on first poll, and its own field naming conventions. +GGn is Gazelle-based but uses query-param auth (not headers), two calls on first poll, and different field names. Hence its own adapter. ## Endpoint -Two requests per poll (first poll only — subsequent polls skip step 1 using the cached `remoteUserId`): +First poll: two requests. Subsequent polls: one request (using cached `remoteUserId`). -``` +```bash GET {baseUrl}/api.php?request=quick_user&key={TOKEN} GET {baseUrl}/api.php?request=user&id={USER_ID}&key={TOKEN} ``` ## Authentication -API token passed as a query parameter: `?key=TOKEN`. No authorization header. +API token as query parameter: `?key=TOKEN`. No authorization header. ## Example quick_user Response @@ -27,7 +27,7 @@ API token passed as a query parameter: `?key=TOKEN`. No authorization header. } ``` -The only purpose of this call is to resolve the user's numeric ID. Once the adapter has stored this as `remoteUserId` in the database, it skips this call on all future polls and goes straight to `request=user`. +Gets your numeric ID. After caching as `remoteUserId`, we skip this and go straight to `request=user` on subsequent polls. ## Example user Response @@ -151,22 +151,21 @@ The only purpose of this call is to resolve the user's numeric ID. Once the adap ## Quirks -**`ratio` is a string.** Unlike every other platform, GGn returns `stats.ratio` as a string (`"0.99699"`), not a number. The adapter handles this with: +**`ratio` is a string.** Returns as `"0.99699"` instead of a number: ```typescript -const ratio = - typeof resp.stats.ratio === "number" ? resp.stats.ratio : parseFloat(resp.stats.ratio) || 0 +const ratio = typeof resp.stats.ratio === "number" ? resp.stats.ratio : parseFloat(resp.stats.ratio) || 0 ``` -**Seedbonus is called `gold`.** GGn has a currency called gold, not bonus points. The adapter maps `stats.gold` → `seedbonus` in the `TrackerStats` output so the dashboard can display it consistently. +**Seedbonus is called `gold`.** We map `stats.gold` → `seedbonus` so dashboards are consistent. -**Seeding and leeching are paranoia-dependent.** `community.seeding` and `community.leeching` return `null` when the user's paranoia level hides community stats. The adapter defaults to `0` in that case, but `null` in the raw response is expected and normal for many GGn users. +**Seeding/leeching are paranoia-dependent.** Users with paranoia enabled return `null`. We default to `0`. -**`hnrs` (hit and runs) can be `null`.** GGn tracks hit-and-runs as `personal.hnrs`, but it can be `null` if the user has none or the field is hidden. The adapter passes `null` through directly to `hitAndRuns`. +**`hnrs` can be `null`.** Stored in `personal.hnrs`, null if hidden. Passed through as-is. -**Two-step fetch, first poll only.** The `quick_user` call exists solely to resolve the numeric user ID. After the first successful poll, the adapter stores `remoteUserId` in the database and skips `quick_user` on all subsequent polls, going directly to `request=user&id=X`. This saves one round-trip per poll cycle. +**Two-step fetch, first poll only.** Afterward, we cache `remoteUserId` and hit `request=user&id=X` directly. -**Buffs are upload/download multipliers.** The `buffs` object contains per-category multipliers active on the user's account (e.g. `Upload: 2` means 2x upload credit). These are stored in `GGnPlatformMeta.buffs` but not currently used in the dashboard display — tracked for future buffer projection features. +**Buffs are multipliers.** Stored in `GGnPlatformMeta.buffs` (e.g., `Upload: 2` = 2x credit). Not displayed yet, but reserved for buffer projections. ## Supported Trackers diff --git a/docs/kb/docs/contributing/tracker-responses-mam.md b/docs/kb/docs/contributing/tracker-responses-mam.md index 60b86d1f..253fa794 100644 --- a/docs/kb/docs/contributing/tracker-responses-mam.md +++ b/docs/kb/docs/contributing/tracker-responses-mam.md @@ -1,42 +1,34 @@ # MAM (MyAnonaMouse) API Response -MAM uses a custom JSON API with cookie-based authentication. A single endpoint returns all user stats including a detailed snatch summary breakdown. +MAM uses a custom JSON API with cookie auth. One endpoint returns stats plus a detailed snatch breakdown. ## Endpoint -One request per poll: - -``` +```bash GET {baseUrl}/jsonLoad.php?snatch_summary¬if ``` -The `snatch_summary` query parameter enables the detailed torrent category breakdown. The `notif` parameter includes notification counts (PMs, tickets, requests). Without these, only basic stats (username, ratio, uploaded, downloaded) are returned. +Add `snatch_summary` for torrent breakdown by category, and `notif` for notification counts (PMs, tickets, requests). Without them, you get only basic stats. Optional parameters (not used by the adapter): -- `clientStats` — includes per-client connectivity info from MAM's perspective (30min cache). Returns empty array without `?id=`, full breakdown with `?id={uid}`. -- `pretty` — pretty-prints the JSON output -- `id={userid}` — load a specific user's data. When set to your own UID, `clientStats` returns the full per-IP/port breakdown. When set to another user's UID, returns limited public data. +- `clientStats` — per-client connectivity info (30-min cache) +- `pretty` — pretty-prints JSON +- `id={userid}` — load specific user data (limited public data for others) -**Note:** The `?id=` parameter does NOT return additional profile fields (like join date). The response shape is identical to the self-lookup — `created` and `update` fields are cache timestamps that change between requests, not account dates. +**Note:** The `?id=` parameter doesn't add fields like join date. The `created` and `update` fields are cache timestamps, not account dates (see Quirks). ## Authentication -MAM uses a `mam_id` session cookie instead of an API key or authorization header: +Use a session cookie (`mam_id`) instead of an API key: -``` +```bash Cookie: mam_id={SESSION_COOKIE} ``` -The session cookie is obtained from MAM's Security Settings page (User Preferences → Security). Users should create an IP-locked or ASN-locked session for API use. **Session cookies rotate monthly** — users must update the stored token periodically. +Get it from User Preferences → Security. Create an IP-locked or ASN-locked session for API use. MAM rotates these monthly, so refresh periodically. -Auth failures return an HTML error string, not JSON: - -``` -Error, you are not signed in
Other error -``` - -The adapter detects this by checking for the absence of `username` in the response. +Auth fails silently — you get HTML instead of JSON. We detect it by checking if `username` is missing. ## Example Response @@ -116,48 +108,46 @@ The adapter detects this by checking for the absence of `username` in the respon ## Snatch Summary Categories -MAM's snatch summary groups all torrents into categories based on seeding status and satisfaction: - -| Category | Field | Meaning | Red flag? | +| Category | Field | Meaning | Alert? | | --- | --- | --- | --- | -| Seeding - Satisfied | `sSat` | Fully seeded past 72hrs, still active | No | -| Seeding - H&R - Not Yet Satisfied | `seedHnr` | Active HnR being resolved by seeding | Yes | -| Seeding - pre-H&R - Not Yet Satisfied | `seedUnsat` | Not yet HnR, still seeding toward 72hrs | No | -| Seeding - Uploads | `upAct` | User's own uploads, still seeding | No | -| Not Seeding - H&R - Not Yet Satisfied | `inactHnr` | **Danger:** inactive HnR, needs immediate attention | Yes | -| Not Seeding - pre-H&R - Not Yet Satisfied | `inactUnsat` | Ticking clock toward HnR status | Yes | -| Not Seeding - Satisfied | `inactSat` | Completed, no longer seeding | No | -| Not Seeding - Uploads | `upInact` | User's uploads, not currently seeding | No | -| Leeching | `leeching` | Currently downloading | No | -| Unsatisfied | `unsat` | Total unsatisfied (includes `limit` field) | No (has limit) | - -Each category object has: `name` (human-readable), `count` (number), `red` (boolean — MAM flags it as concerning), `size` (bytes or null). +| Seeding - Satisfied | `sSat` | Past 72h, active | No | +| Seeding - H&R - Not Yet Satisfied | `seedHnr` | Active HnR being resolved | Yes | +| Seeding - pre-H&R - Not Yet Satisfied | `seedUnsat` | Seeding toward 72h | No | +| Seeding - Uploads | `upAct` | Own uploads, seeding | No | +| Not Seeding - H&R - Not Yet Satisfied | `inactHnr` | Inactive HnR — urgent | Yes | +| Not Seeding - pre-H&R - Not Yet Satisfied | `inactUnsat` | Ticking clock to HnR | Yes | +| Not Seeding - Satisfied | `inactSat` | Completed | No | +| Not Seeding - Uploads | `upInact` | Own uploads, inactive | No | +| Leeching | `leeching` | Downloading | No | +| Unsatisfied | `unsat` | Total unsatisfied | No | + +Each object: `name`, `count`, `red` (boolean alert flag), `size` (bytes or null). ## Quirks -**Dual byte representation.** MAM returns both formatted strings (`uploaded`: `"5.125 TiB"`) and raw integers (`uploaded_bytes`: `5635036489461`). The adapter uses the raw integers directly via `BigInt()`, avoiding the `parseBytes()` parsing that UNIT3D requires. +**Dual byte representation.** Both formatted strings (`"5.125 TiB"`) and raw bytes. We use raw bytes with `BigInt()` — no parsing needed. -**Bonus points cap at 99,999.** MAM has a hard cap on seedbonus. Points earned above this are lost. The notification system should alert when the cap is reached. +**Bonus points cap at 99,999.** Hard ceiling. Anything above is lost. -**FL Wedges are not bonus points.** Wedges (`wedges`) are a separate currency from seedbonus. They are earned from the Millionaire's Vault and can be exchanged for Personal or Staff Freeleech on individual torrents. +**FL Wedges are separate currency.** Earned from Millionaire's Vault, traded for personal or staff freeleech. -**Cookie auth, not API key.** MAM is the only platform using cookie-based auth. The `mam_id` session cookie must be set up in MAM's Security Settings as an IP-locked or ASN-locked session. Regular browser session cookies also work but are less stable. Cookies rotate monthly. +**Cookie auth only.** Set `mam_id` as IP-locked or ASN-locked in Security Settings. Monthly rotations are normal. -**`created` and `update` are cache timestamps, NOT account dates.** Verified: these values change between consecutive API calls (observed `1774552832` → `1774553470` seconds apart). They reflect when MAM's internal cache was last refreshed. **MAM does not expose account join date via any API endpoint** — users must enter it manually. +**`created` and `update` are cache timestamps.** They change on each API call — they're not account dates. MAM doesn't expose join date via API. -**`unsat.limit` is class-dependent.** The unsatisfied torrent limit varies by user class: User=50, Power User=100, VIP=150, above VIP=200. The API returns the current limit for the authenticated user. +**`unsat.limit` is class-dependent.** User=50, Power User=100, VIP=150, above VIP=200. -**72-hour seed requirement.** MAM requires 72 hours of seeding within 30 days per torrent. Failure to meet this results in a Hit & Run. The `inactHnr` count represents torrents that have passed the deadline without sufficient seeding. +**72-hour seed requirement.** 72h seeding per torrent within 30 days, or it's an H&R. `inactHnr` = torrents past deadline that you stopped seeding. ## Other MAM Endpoints (Not Used by Adapter) -The following endpoints exist in the MAM API but are not used by the Tracker Tracker adapter. They are documented here for reference and potential future use. +Alternative endpoints for reference and future use. ### `/jsonLoad.php?clientStats` -Returns torrent client connectivity information from MAM's perspective (30-minute cache). Not used by the adapter because Tracker Tracker has its own qBittorrent integration. However, the data is useful for diagnosing connectivity issues since it shows what MAM sees. +Torrent client connectivity info from MAM's perspective (30-min cache). Not used because Tracker Tracker has its own qBT integration, but useful for diagnosing MAM-side connectivity. -When called without `?id=`, `clientStats` is an empty array. When called with `?id={uid}` (your own user ID), it returns the full client breakdown: +Without `?id=`: empty array. With `?id={uid}` (your ID): full breakdown: ```json { @@ -221,7 +211,7 @@ Note: A single user can have multiple client entries across different IPs/ports ### `/jsonLoad.php?notif` -Returns notification counts (PMs, tickets, requests). The adapter includes `?notif` in every poll alongside `?snatch_summary`. The counts are stored in `MamPlatformMeta` (`unreadPMs`, `openTickets`, `pendingRequests`, `unreadTopics`) and surfaced as an unread badge on the tracker detail page. +Notification counts (PMs, tickets, requests). Included in every poll alongside `?snatch_summary`. Stored in `MamPlatformMeta` and surfaced as an unread badge on the tracker detail page. The `notifs` object appears when `?notif` is included: @@ -242,18 +232,18 @@ These fields are merged into the standard `/jsonLoad.php` response alongside the ### `/json/userBonusHistory.php` -Shows a history of bonus points and wedge transactions. Requires `mam_id` cookie auth on `www.myanonamouse.net`. +Bonus points and wedge transaction history. Requires `mam_id` cookie auth on `www.myanonamouse.net`. **Parameters:** | Parameter | Type | Description | | --- | --- | --- | -| `other_userid` | int | Filter to transactions with a specific user | -| `type[]` | list | Which transaction types to show: `giftPoints`, `giftWedge`, `wedgePF`, `wedgeGFL`, `torrentThanks`, `millionaires` | +| `other_userid` | int | Filter by user | +| `type[]` | list | Transaction types: `giftPoints`, `giftWedge`, `wedgePF`, `wedgeGFL`, `torrentThanks`, `millionaires` | -**Example request:** +**Example:** -``` +```bash GET /json/userBonusHistory.php?type[]=giftWedge&type[]=wedgePF&type[]=wedgeGFL ``` @@ -304,11 +294,11 @@ GET /json/userBonusHistory.php?type[]=giftWedge&type[]=wedgePF&type[]=wedgeGFL ### `/json/dynamicSeedbox.php` -Sets the dynamic seedbox IP. Operational tool for VPN/seedbox users — not relevant to stats tracking. Requires a specially configured API session (ASN-locked + Dynamic Seedbox permission) on `t.myanonamouse.net`. +Sets the dynamic seedbox IP. For VPN/seedbox users, not relevant to stats tracking. Requires specially configured API session (ASN-locked + Dynamic Seedbox permission) on `t.myanonamouse.net`. -**Rate limit:** Once per hour (rolling window). +**Rate limit:** Once per hour. -**Example response (success):** +**Success:** ```json { @@ -320,7 +310,7 @@ Sets the dynamic seedbox IP. Operational tool for VPN/seedbox users — not rele } ``` -**Example response (rate limited):** +**Rate limited:** ```json { @@ -346,7 +336,7 @@ Sets the dynamic seedbox IP. Operational tool for VPN/seedbox users — not rele ### `/json/jsonIp.php` -Returns the caller's current IP, ASN, and AS organization name. Available on both `www.myanonamouse.net` and `t.myanonamouse.net`. No authentication required. +Current IP, ASN, and organization name. Available on both `www` and `t` subdomains. No auth required. ```json { diff --git a/docs/kb/docs/contributing/tracker-responses-unit3d.md b/docs/kb/docs/contributing/tracker-responses-unit3d.md index f261cfc0..23b3a225 100644 --- a/docs/kb/docs/contributing/tracker-responses-unit3d.md +++ b/docs/kb/docs/contributing/tracker-responses-unit3d.md @@ -2,15 +2,15 @@ ## Endpoint -``` +```bash GET {baseUrl}/api/user?api_token={TOKEN} ``` -The path `/api/user` comes from the tracker's `apiPath` field in the database. Most UNIT3D sites use this default. +Most UNIT3D sites use `/api/user` by default. Override via the tracker's `apiPath` field if needed. ## Authentication -API token passed as a query parameter: `?api_token=TOKEN`. No request headers required beyond the default `User-Agent`. +Pass API token as a query parameter: `?api_token=TOKEN`. No special headers required. ## Example Response @@ -29,7 +29,7 @@ API token passed as a query parameter: `?api_token=TOKEN`. No request headers re } ``` -All byte values are **formatted strings** (`"500.25 GiB"`), not integers. The `ratio`, `buffer`, and `seedbonus` fields are also strings even though they represent numbers. The adapter runs everything through `parseBytes()` or `parseFloat()` accordingly. +Byte values return as formatted strings (`"500.25 GiB"`), not integers. Same for `ratio`, `buffer`, and `seedbonus` — all strings. The adapter parses via `parseBytes()` or `parseFloat()`. ## Field Mapping @@ -49,7 +49,7 @@ All byte values are **formatted strings** (`"500.25 GiB"`), not integers. The `r | `warned` | — | — | Always `null` — not in UNIT3D API | | `freeleechTokens` | — | — | Always `null` — not in UNIT3D API | -UNIT3D makes a single API call per poll. No enrichment step. +One API call per poll, no enrichment step. ## Supported Trackers diff --git a/docs/kb/docs/contributing/tracker-responses.md b/docs/kb/docs/contributing/tracker-responses.md index ee223c15..7de207fa 100644 --- a/docs/kb/docs/contributing/tracker-responses.md +++ b/docs/kb/docs/contributing/tracker-responses.md @@ -1,6 +1,6 @@ # Tracker API Responses -This section documents the raw JSON responses from each tracker platform's API and how the adapter maps those fields to the shared `TrackerStats` interface. Use it when adding support for a new tracker or debugging why a field is coming back wrong. +This section shows the raw JSON responses from each tracker platform's API and how the adapter maps those fields to the shared `TrackerStats` interface. Use it when adding a new tracker or debugging why a field isn't mapping correctly. Source of truth: `src/lib/adapters/` @@ -8,7 +8,7 @@ Source of truth: `src/lib/adapters/` ## TrackerStats Interface -All adapters return a `TrackerStats` object defined in `src/lib/adapters/types.ts`. +All adapters return `TrackerStats` defined in `src/lib/adapters/types.ts`. ```typescript interface TrackerStats { @@ -25,8 +25,6 @@ interface TrackerStats { requiredRatio: number | null warned: boolean | null freeleechTokens: number | null - - // Optional — populated when available remoteUserId?: number joinedDate?: string lastAccessDate?: string @@ -36,7 +34,7 @@ interface TrackerStats { } ``` -Fields marked `null` in the platform pages mean the platform does not expose that data — the adapter explicitly returns `null`, not `undefined` or `0`. +Fields marked `null` indicate the tracker doesn't expose that data. We return `null` explicitly, not `undefined` or `0`. ## Platform Reference @@ -49,14 +47,14 @@ Fields marked `null` in the platform pages mean the platform does not expose tha ## Adding a New Tracker Platform -If you are adding support for an entirely new platform type (not a new tracker on an existing platform): +For a brand new platform (not just a new tracker on an existing one): 1. Create `src/lib/adapters/{platform}.ts` implementing `TrackerAdapter` -2. Add the response interface(s) to the top of the file -3. Add the new platform type to `src/lib/adapters/types.ts` if it needs new `platformMeta` fields -4. Register the adapter in `src/lib/adapters/index.ts` (`getAdapter()` factory) -5. Add the platform to the `platform` enum in `src/lib/db/schema.ts` -6. Add tracker registry entries in `src/data/trackers/` using the new platform type -7. Document the raw response shape in this section - -For a new tracker on an existing platform (e.g. a new UNIT3D site), you only need to add an entry in `src/data/trackers/` — no adapter code required. +2. Add response interface(s) at the top +3. Update `src/lib/adapters/types.ts` with new `platformMeta` fields if needed +4. Register in `src/lib/adapters/index.ts` (`getAdapter()` factory) +5. Add platform to the `platform` enum in `src/lib/db/schema.ts` +6. Add tracker entries in `src/data/trackers/` +7. Document the raw response shape here + +For a new tracker on an existing platform (i.e., a new UNIT3D site), just add an entry in `src/data/trackers/`. diff --git a/docs/kb/docs/contributing/trackers/aither.md b/docs/kb/docs/contributing/trackers/aither.md index 5400de6d..dcd2c2ca 100644 --- a/docs/kb/docs/contributing/trackers/aither.md +++ b/docs/kb/docs/contributing/trackers/aither.md @@ -11,7 +11,7 @@ ## Notes -Standard UNIT3D configuration. No tracker-specific quirks. +This is a standard UNIT3D setup. ## Slots diff --git a/docs/kb/docs/contributing/trackers/alpharatio.md b/docs/kb/docs/contributing/trackers/alpharatio.md index 7caa54df..a82f3ef9 100644 --- a/docs/kb/docs/contributing/trackers/alpharatio.md +++ b/docs/kb/docs/contributing/trackers/alpharatio.md @@ -1,5 +1,8 @@ # AlphaRatio (AR) +!!! Warning + This tracker is marked **Unvalidated** and has not been tested it against a real account yet. The field names and response structure should match the standard Gazelle layout, but there's no proof yet. + | Field | Value | | ------------ | ----------------------------------------- | | Platform | Gazelle | @@ -11,7 +14,7 @@ ## Notes -Standard Gazelle configuration. This tracker is marked **Unvalidated** — the API integration has not been confirmed against a live account. Field names and response structure are assumed to match the standard Gazelle layout. +This is a standard Gazelle setup. ## Slots diff --git a/docs/kb/docs/contributing/trackers/animebytes.md b/docs/kb/docs/contributing/trackers/animebytes.md index 487a86c7..9053653d 100644 --- a/docs/kb/docs/contributing/trackers/animebytes.md +++ b/docs/kb/docs/contributing/trackers/animebytes.md @@ -1,5 +1,8 @@ # AnimeBytes (AB) +!!! Warning + This tracker is marked **Unvalidated** and has not been tested it against a real account yet. The field names and response structure should match the standard Gazelle layout, but there's no proof yet. + | Field | Value | | ------------ | ----------------------------------------- | | Platform | Gazelle | @@ -11,7 +14,7 @@ ## Notes -Standard Gazelle configuration. This tracker is marked **Unvalidated** — the API integration has not been confirmed against a live account. Field names and response structure are assumed to match the standard Gazelle layout. +This is a standard Gazelle setup. ## Slots diff --git a/docs/kb/docs/contributing/trackers/anthelion.md b/docs/kb/docs/contributing/trackers/anthelion.md index c2753705..c9a071d8 100644 --- a/docs/kb/docs/contributing/trackers/anthelion.md +++ b/docs/kb/docs/contributing/trackers/anthelion.md @@ -11,7 +11,7 @@ ## Notes -Anthelion uses the Nebulance platform, which is a Gazelle-derived fork. It uses `api.php` as the endpoint path (not `ajax.php`) and authenticates via query parameter rather than HTTP header. Sister site of Nebulance (NBL). +Anthelion runs on Nebulance, a Gazelle fork. It has two quirks: the endpoint is `api.php` (not `ajax.php`), and it authenticates via query parameter instead of HTTP header. It's the sister site to Nebulance (NBL). ## Slots diff --git a/docs/kb/docs/contributing/trackers/blutopia.md b/docs/kb/docs/contributing/trackers/blutopia.md index 2224239f..d454faca 100644 --- a/docs/kb/docs/contributing/trackers/blutopia.md +++ b/docs/kb/docs/contributing/trackers/blutopia.md @@ -11,7 +11,7 @@ ## Notes -Standard UNIT3D configuration. No tracker-specific quirks. +This is a standard UNIT3D setup. Nothing unusual here. ## Slots diff --git a/docs/kb/docs/contributing/trackers/broadcasthenet.md b/docs/kb/docs/contributing/trackers/broadcasthenet.md index 328cade8..638eeada 100644 --- a/docs/kb/docs/contributing/trackers/broadcasthenet.md +++ b/docs/kb/docs/contributing/trackers/broadcasthenet.md @@ -1,5 +1,8 @@ # BroadcasTheNet (BTN) +!!! Warning + This tracker is marked **Unvalidated** and has not been tested it against a real account yet. + | Field | Value | | ------------ | ----------------------------------------- | | Platform | Gazelle | @@ -11,7 +14,7 @@ ## Notes -Standard Gazelle configuration. This tracker is marked **Unvalidated** — the API integration has not been confirmed against a live account. The `bonusPoints` field naming may vary from standard. +This is a standard Gazelle setup. It's marked **Unvalidated** because no one's tested it against a real account yet. Fair warning: the `bonusPoints` field might not match the standard Gazelle naming. ## Slots diff --git a/docs/kb/docs/contributing/trackers/concertos.md b/docs/kb/docs/contributing/trackers/concertos.md index e6d18f57..1a193c10 100644 --- a/docs/kb/docs/contributing/trackers/concertos.md +++ b/docs/kb/docs/contributing/trackers/concertos.md @@ -11,7 +11,7 @@ ## Notes -Standard UNIT3D configuration. No tracker-specific quirks. +This is a standard UNIT3D setup. Nothing unusual here. ## Slots diff --git a/docs/kb/docs/contributing/trackers/empornium.md b/docs/kb/docs/contributing/trackers/empornium.md index 7420ba7b..e99e5dd7 100644 --- a/docs/kb/docs/contributing/trackers/empornium.md +++ b/docs/kb/docs/contributing/trackers/empornium.md @@ -1,5 +1,8 @@ # Empornium (EMP) +!!! Warning + This tracker is marked **Unvalidated** and has not been tested it against a real account yet. The field names and response structure should match the standard Gazelle layout, but there's no proof yet. + | Field | Value | | ------------ | ----------------------------------------- | | Platform | Gazelle | @@ -11,13 +14,13 @@ ## Notes -Standard Gazelle configuration. This tracker is marked **Unvalidated** — the API integration has not been confirmed against a live account. Field names and response structure are assumed to match the standard Gazelle layout. +Uses standard Gazelle. ## Slots **Profile Card:** username · group (no avatar or join date — no enrichment) -**Badges:** `warned` (conditional — always `false` without enrichment) +**Badges:** `warned` (conditional — always `false` since there's no enrichment) **Stat Cards:** `seedbonus`, `login-deadline` (loginIntervalDays: 90) diff --git a/docs/kb/docs/contributing/trackers/fearnopeer.md b/docs/kb/docs/contributing/trackers/fearnopeer.md index c88948c2..658e402b 100644 --- a/docs/kb/docs/contributing/trackers/fearnopeer.md +++ b/docs/kb/docs/contributing/trackers/fearnopeer.md @@ -11,13 +11,13 @@ ## Notes -Standard UNIT3D configuration. No tracker-specific quirks. +Standard UNIT3D. Nothing special here. ## Slots **Profile Card:** username · group (no avatar or join date — UNIT3D platform) -**Badges:** `warned` (conditional — only resolves when `warned === true` in snapshot) +**Badges:** `warned` (shows up only if `warned === true` in the API response) **Stat Cards:** `seedbonus`, `login-deadline` (loginIntervalDays: 150) diff --git a/docs/kb/docs/contributing/trackers/gazellegames.md b/docs/kb/docs/contributing/trackers/gazellegames.md index ed18c852..dae8e944 100644 --- a/docs/kb/docs/contributing/trackers/gazellegames.md +++ b/docs/kb/docs/contributing/trackers/gazellegames.md @@ -11,22 +11,22 @@ ## Notes -GGn uses its own dedicated adapter rather than the standard Gazelle one. Key differences: +GGn needs its own adapter — not the standard Gazelle one. Here's what makes it different: -- Auth is via query parameter `?key=TOKEN`, not a header. -- First poll makes two requests: `?request=quick_user&key=TOKEN` to resolve the numeric user ID, then `?request=user&id=X&key=TOKEN` for full stats. Subsequent polls skip the first request and go directly to `request=user`. -- The currency field is called `gold` (not `bonusPoints` or `seedbonus`) — mapped to `seedbonus` in the dashboard. -- `stats.ratio` is a string (`"0.99699"`), not a number. The adapter handles the conversion. -- Seeding and leeching counts are paranoia-dependent and may return `null`. -- `freeleechTokens` is always `null` — GGn does not expose FL token counts via the API. +- Auth uses a query parameter (`?key=TOKEN`), not a header. +- The first poll does two requests: first hits `?request=quick_user&key=TOKEN` to get your numeric user ID, then `?request=user&id=X&key=TOKEN` to grab the full stats. After that, we skip the ID lookup and go straight to `request=user`. +- Currency is called `gold` here (not `bonusPoints` or `seedbonus`), but we map it to `seedbonus` in the dashboard. +- `stats.ratio` comes back as a string like `"0.99699"`, not a number. The adapter converts it. +- Seeding and leeching counts depend on your paranoia level and might be `null`. +- `freeleechTokens` is always `null` — GGn doesn't expose those via the API. -See the [GGn platform page](../tracker-responses-ggn.md) for full field mapping details. +See the [GGn platform page](../tracker-responses-ggn.md) for the full field mapping. ## Slots -**Profile Card:** username · group · join date (GGn adapter provides joinedDate) +**Profile Card:** username · group · join date (GGn's adapter gives us joinedDate) -**Badges:** `warned`, `donor`, `disabled`, `ggn-parked`, `ggn-invites`, `ggn-irc` +**Badges:** `warned`, `donor`, `disabled`, `ggn-parked`, `ggn-invites`, `ggn-irc` (GGn-specific statuses) **Stat Cards:** `gold`, `ggn-share-score-card`, `login-deadline` (loginIntervalDays: 60) diff --git a/docs/kb/docs/contributing/trackers/greatposterwall.md b/docs/kb/docs/contributing/trackers/greatposterwall.md index ed5f676a..2a4c7e35 100644 --- a/docs/kb/docs/contributing/trackers/greatposterwall.md +++ b/docs/kb/docs/contributing/trackers/greatposterwall.md @@ -1,5 +1,8 @@ # Great Poster Wall (GPW) +!!! Warning + This tracker is marked **Unvalidated** and has not been tested it against a real account yet. + | Field | Value | | ------------ | ----------------------------------------- | | Platform | Gazelle | @@ -11,13 +14,13 @@ ## Notes -Standard Gazelle configuration. This tracker is marked **Unvalidated** — the API integration has not been confirmed against a live account. The site is primarily Chinese-language. +- Standard Gazelle config. ## Slots **Profile Card:** username · group (no avatar or join date — no enrichment) -**Badges:** `warned` (conditional — always `false` without enrichment) +**Badges:** `warned` (conditional — always `false` since there's no enrichment) **Stat Cards:** `seedbonus`, `login-deadline` (loginIntervalDays: 90) diff --git a/docs/kb/docs/contributing/trackers/lst.md b/docs/kb/docs/contributing/trackers/lst.md index 9f7c9331..6268c737 100644 --- a/docs/kb/docs/contributing/trackers/lst.md +++ b/docs/kb/docs/contributing/trackers/lst.md @@ -11,13 +11,13 @@ ## Notes -Standard UNIT3D configuration. No tracker-specific quirks. +Standard UNIT3D. Nothing special here. ## Slots **Profile Card:** username · group (no avatar or join date — UNIT3D platform) -**Badges:** `warned` (conditional — only resolves when `warned === true` in snapshot) +**Badges:** `warned` (shows up only if `warned === true` in the API response) **Stat Cards:** `seedbonus`, `login-deadline` (loginIntervalDays: 90) diff --git a/docs/kb/docs/contributing/trackers/morethantv.md b/docs/kb/docs/contributing/trackers/morethantv.md index deb6a5a4..3d320521 100644 --- a/docs/kb/docs/contributing/trackers/morethantv.md +++ b/docs/kb/docs/contributing/trackers/morethantv.md @@ -1,5 +1,8 @@ # MoreThanTV (MTV) +!!! Warning + This tracker is marked **Unvalidated** and has not been tested it against a real account yet. + | Field | Value | | ------------ | ----------------------------------------- | | Platform | Gazelle | @@ -11,13 +14,13 @@ ## Notes -Standard Gazelle configuration. This tracker is marked **Unvalidated** — the API integration has not been confirmed against a live account. Field names and response structure are assumed to match the standard Gazelle layout. +Standard Gazelle. ## Slots **Profile Card:** username · group (no avatar or join date — no enrichment) -**Badges:** `warned` (conditional — always `false` without enrichment) +**Badges:** `warned` (conditional — always `false` since there's no enrichment) **Stat Cards:** `seedbonus`, `login-deadline` (loginIntervalDays: 90) diff --git a/docs/kb/docs/contributing/trackers/nebulance.md b/docs/kb/docs/contributing/trackers/nebulance.md index a4b69b86..5e32b376 100644 --- a/docs/kb/docs/contributing/trackers/nebulance.md +++ b/docs/kb/docs/contributing/trackers/nebulance.md @@ -11,13 +11,13 @@ ## Notes -Nebulance is a Gazelle-derived fork that uses `api.php` as the endpoint path (not `ajax.php`) and authenticates via query parameter rather than HTTP header. Sister site of Anthelion (ANT). +Nebulance is a Gazelle fork that uses `api.php` instead of `ajax.php` and authenticates via query parameter rather than HTTP header. Sister site to Anthelion (ANT). ## Slots -**Profile Card:** username · group · join date (Nebulance provides joinedDate for NBL) +**Profile Card:** username · group · join date (Nebulance gives us joinedDate) -**Badges:** `warned` (conditional — always `false`, no enrichment on Nebulance platform) +**Badges:** `warned` (always `false` — Nebulance doesn't provide enrichment) **Stat Cards:** `snatched-nebulance`, `login-deadline` (loginIntervalDays: 90) diff --git a/docs/kb/docs/contributing/trackers/oldtoons.md b/docs/kb/docs/contributing/trackers/oldtoons.md index 0cd3dc58..d9208d33 100644 --- a/docs/kb/docs/contributing/trackers/oldtoons.md +++ b/docs/kb/docs/contributing/trackers/oldtoons.md @@ -11,13 +11,13 @@ ## Notes -Standard UNIT3D configuration. No tracker-specific quirks. +Standard UNIT3D. Nothing special here. ## Slots **Profile Card:** username · group (no avatar or join date — UNIT3D platform) -**Badges:** `warned` (conditional — only resolves when `warned === true` in snapshot) +**Badges:** `warned` (shows up only if `warned === true` in the API response) **Stat Cards:** `seedbonus`, `login-deadline` (loginIntervalDays: 90) diff --git a/docs/kb/docs/contributing/trackers/onlyencodes.md b/docs/kb/docs/contributing/trackers/onlyencodes.md index 83b5a945..554e89cf 100644 --- a/docs/kb/docs/contributing/trackers/onlyencodes.md +++ b/docs/kb/docs/contributing/trackers/onlyencodes.md @@ -11,7 +11,7 @@ ## Notes -Standard UNIT3D configuration. No tracker-specific quirks. +It's a standard UNIT3D setup with nothing unusual to worry about. ## Slots diff --git a/docs/kb/docs/contributing/trackers/orpheus.md b/docs/kb/docs/contributing/trackers/orpheus.md index 8f7265f5..704eb63d 100644 --- a/docs/kb/docs/contributing/trackers/orpheus.md +++ b/docs/kb/docs/contributing/trackers/orpheus.md @@ -1,5 +1,8 @@ # Orpheus (OPS) +!!! Outdated + This doc page needs to be updated with correct response shapes. + | Field | Value | | ------------ | ----------------------------------------- | | Platform | Gazelle | @@ -11,7 +14,7 @@ ## Notes -Standard Gazelle configuration. No tracker-specific quirks. Similar to REDacted — both are based on What.CD's Gazelle fork. +Standard Gazelle setup with no quirks. Like REDacted, it's built on the What.CD Gazelle fork. ## Slots diff --git a/docs/kb/docs/contributing/trackers/passthepopcorn.md b/docs/kb/docs/contributing/trackers/passthepopcorn.md index 00ec2010..5fd6df7f 100644 --- a/docs/kb/docs/contributing/trackers/passthepopcorn.md +++ b/docs/kb/docs/contributing/trackers/passthepopcorn.md @@ -1,5 +1,8 @@ # PassThePopcorn (PTP) +!!! Warning + This tracker is marked **Unvalidated** and has not been tested it against a real account yet. The `bonusPoints` field might be named differently than we expect. + | Field | Value | | ------------ | ----------------------------------------- | | Platform | Gazelle | @@ -11,7 +14,7 @@ ## Notes -Standard Gazelle configuration. This tracker is marked **Unvalidated** — the API integration has not been confirmed against a live account. The `bonusPoints` field naming may vary from standard. +Standard Gazelle setup. ## Slots diff --git a/docs/kb/docs/contributing/trackers/phoenixproject.md b/docs/kb/docs/contributing/trackers/phoenixproject.md index a3a9ec6a..5eaf065c 100644 --- a/docs/kb/docs/contributing/trackers/phoenixproject.md +++ b/docs/kb/docs/contributing/trackers/phoenixproject.md @@ -11,7 +11,7 @@ ## Notes -Enrichment is enabled, which means a second `?action=user&id=X` call is made after the index request to retrieve `warned` status, join date, last access date, and more accurate seeding/leeching counts. +Enrichment is on, so we make a second `?action=user&id=X` call after the first one to grab `warned` status, join date, last access, and better seeding/leeching numbers. ## Slots @@ -23,4 +23,4 @@ Enrichment is enabled, which means a second `?action=user&id=X` call is made aft **Progress:** none -> `login-deadline` does not resolve for Phoenix Project because `loginIntervalDays` is set to `0` in the registry. +> `login-deadline` won't show for Phoenix Project because we set `loginIntervalDays` to `0` in the registry. diff --git a/docs/kb/docs/contributing/trackers/racing4everyone.md b/docs/kb/docs/contributing/trackers/racing4everyone.md index 49e702a2..42d29c26 100644 --- a/docs/kb/docs/contributing/trackers/racing4everyone.md +++ b/docs/kb/docs/contributing/trackers/racing4everyone.md @@ -11,7 +11,7 @@ ## Notes -Standard UNIT3D configuration. No tracker-specific quirks. +It's a standard UNIT3D setup with nothing unusual to worry about. ## Slots diff --git a/docs/kb/docs/contributing/trackers/redacted.md b/docs/kb/docs/contributing/trackers/redacted.md index f5d74cdc..e17f6f01 100644 --- a/docs/kb/docs/contributing/trackers/redacted.md +++ b/docs/kb/docs/contributing/trackers/redacted.md @@ -11,9 +11,9 @@ ## Notes -REDacted uses `gazelleAuthStyle: "token"` — the `Authorization` header value is the raw token string rather than the `token TOKEN` prefixed form used by most other Gazelle sites. +REDacted uses `gazelleAuthStyle: "token"` — it takes the raw token string in the `Authorization` header, not the `token TOKEN` format most Gazelle sites expect. -Enrichment is enabled, which means a second `?action=user&id=X` call is made after the index request to retrieve `warned` status, join date, last access date, and more accurate seeding/leeching counts. +Enrichment is on, so we make a second `?action=user&id=X` call after the first one to grab `warned` status, join date, last access, and better seeding/leeching numbers. ## Slots diff --git a/docs/kb/docs/contributing/trackers/reelflix.md b/docs/kb/docs/contributing/trackers/reelflix.md index c1af8a8e..a5b3c55d 100644 --- a/docs/kb/docs/contributing/trackers/reelflix.md +++ b/docs/kb/docs/contributing/trackers/reelflix.md @@ -11,7 +11,7 @@ ## Notes -Standard UNIT3D configuration. No tracker-specific quirks. +It's a standard UNIT3D setup with nothing unusual to worry about. ## Slots diff --git a/docs/kb/docs/contributing/trackers/seedpool.md b/docs/kb/docs/contributing/trackers/seedpool.md index 42029d5d..284b01be 100644 --- a/docs/kb/docs/contributing/trackers/seedpool.md +++ b/docs/kb/docs/contributing/trackers/seedpool.md @@ -11,13 +11,11 @@ ## Notes -Standard UNIT3D configuration. No tracker-specific quirks. +Standard UNIT3D setup, but Seed Pool has its own promotion system based on seedsize instead of upload amount. The class names are all pool-themed: User → Pool → PowerPool → SuperPool → UberPool → MegaPool → GodPool. There's also a ProPool class you can buy on IRC. -Seed Pool uses a seedsize-based promotion system rather than upload amount. All class names are pool-themed (User → Pool → PowerPool → SuperPool → UberPool → MegaPool → GodPool). There is also a purchasable ProPool class available via IRC. +Fall below a 1.0 ratio and you'll hit `Cesspool` — you lose download privileges. `KiddiePool` is a timeout zone. -The `Cesspool` class is a demotion for users whose ratio drops below 1 — download privileges are revoked. `KiddiePool` is a timeout zone. - -Status page available at `https://status.seedpool.org/`. +Check the status page at `https://status.seedpool.org/`. ## Slots diff --git a/docs/kb/docs/contributing/trackers/skipthecommercials.md b/docs/kb/docs/contributing/trackers/skipthecommercials.md index 158b87f1..b0fa7ec5 100644 --- a/docs/kb/docs/contributing/trackers/skipthecommercials.md +++ b/docs/kb/docs/contributing/trackers/skipthecommercials.md @@ -11,7 +11,7 @@ ## Notes -Standard UNIT3D configuration. No tracker-specific quirks. +It's a standard UNIT3D setup with nothing unusual to worry about. ## Slots diff --git a/docs/kb/docs/contributing/trackers/uploadcx.md b/docs/kb/docs/contributing/trackers/uploadcx.md index 4f4fa0a3..7f4cbfbc 100644 --- a/docs/kb/docs/contributing/trackers/uploadcx.md +++ b/docs/kb/docs/contributing/trackers/uploadcx.md @@ -11,7 +11,7 @@ ## Notes -Standard UNIT3D configuration. No tracker-specific quirks. +It's a standard UNIT3D setup with nothing unusual to worry about. ## Slots diff --git a/docs/kb/docs/features/backups.md b/docs/kb/docs/features/backups.md index 3ec49882..6ff59463 100644 --- a/docs/kb/docs/features/backups.md +++ b/docs/kb/docs/features/backups.md @@ -5,47 +5,38 @@ description: Export, schedule, encrypt, and restore Tracker Tracker backups. # Backups -Tracker Tracker can back up your configuration and history. You can download a backup manually at any time, or set up automatic scheduled backups saved to disk on the server. +Tracker Tracker backs up your configuration and history. Download backups manually whenever you want, or set up automatic scheduled backups that save to disk on the server. ## What Gets Backed Up -A backup captures a snapshot of everything you'd need to fully restore the app: - -- App settings (poll intervals, proxy settings, notification settings, TOTP state) -- All trackers and their configurations -- Full upload/download history (snapshots) -- Tracker membership roles -- Download client configurations -- Tag groups -- Download client speed history -- Notification targets - -Sensitive values — API tokens, download client credentials, proxy passwords, webhook URLs, TOTP secrets — are included in the backup in encrypted form. They stay encrypted the whole time they're in the file. - -### What is NOT in backups - -- **Your password.** It's never exported. A backup file cannot be used to recover your login password. -- Failed login attempt counters (these reset to zero on restore). -- Transient runtime state: last poll time, last error, cached torrent lists. -- Notification delivery history. +| Item | Backed up? | Notes | +|-----------------------------------------------------------------|------------|-------------------------------------------------| +| App settings (poll intervals, proxy, notifications, TOTP state) | ✓ | Encrypted | +| Trackers and configurations | ✓ | All metadata | +| Upload/download history (snapshots) | ✓ | Full time-series | +| Download client configurations | ✓ | Encrypted (credentials) | +| Tag groups & notification targets | ✓ | All destinations | +| **Your login password** | ✗ | Never exported; use current password to restore | +| **Encryption salt** | ✗ | Never backed up; stays on instance | +| **Failed login counters** | ✗ | Reset to zero on restore | +| Transient state (last poll time, errors, cached torrents) | ✗ | Not persisted | +| Notification delivery history | ✗ | Not included | + +Sensitive values — API tokens, client credentials, proxy passwords, webhook URLs, TOTP secrets — remain encrypted in the backup file. ## Backup File Format -Backups are plain JSON files. Encrypted backups use the `.ttbak` extension. - -The file includes a header with the version, creation time, your instance URL, and counts of each data type — useful for confirming you're restoring the right file before proceeding. +Backups are plain JSON (encrypted backups use `.ttbak`). The header includes version, creation time, instance URL, and data counts to confirm you're restoring the right file. ## Encrypted Backups (.ttbak) -You can wrap a backup in an extra layer of encryption. This produces a `.ttbak` file that requires a password to restore. - -The encryption password for backups is separate from your login password. Set it in **Settings → Backups**. Each backup generates its own random key — two backups with the same password produce different ciphertext. +Add an extra encryption layer to any backup. Set the password in **Settings → Backups** — it's separate from your login password. Each backup generates a random key, so two backups with the same password produce different ciphertext. -Encrypted backups are useful if you store them in cloud storage, send them offsite, or anywhere you'd rather not have the raw config readable. +This is useful if you store backups in cloud storage, send them offsite, or anywhere you don't want the raw config readable. ## Exporting a Backup Manually -Go to **Settings → Backups** and click **Export Now**. The file downloads directly to your browser. Manual exports are not saved on the server and won't appear in backup history. +Go to **Settings → Backups** and click **Export Now**. Manual exports download to your browser and don't save to the server. ![Backup export and restore UI](../assets/images/backups-exportAndRestore.png) @@ -54,50 +45,48 @@ Go to **Settings → Backups** and click **Export Now**. The file downloads dire Scheduled backups run automatically at **03:00 server time**. | Frequency | When it runs | -| --------- | -------------------------------- | +|-----------|----------------------------------| | Daily | Every day at 03:00 | | Weekly | Every Monday at 03:00 | | Monthly | First day of each month at 03:00 | -Scheduled backups are saved to the storage path you set in **Settings → Backups** and are listed in the backup history. +They save to the storage path you set in **Settings → Backups** and show up in your backup history. ### Retention -Set a retention count (1-365, default 14). When a new scheduled backup is created and the total exceeds that number, the oldest backup files are deleted automatically. +Set a retention count (1-365, default 14). Once exceeded, the oldest backups delete automatically. ## Restoring a Backup ### Before you start -- You'll need your **current login password** to confirm the restore. -- If the backup is a `.ttbak` encrypted file, you'll also need the backup's encryption password. +- Your **current login password** (for confirmation) +- For `.ttbak` encrypted backups, the backup password -### What happens during a restore +### What happens during restore -1. The backup file is validated. -2. All existing data is deleted. -3. Backup data is written to the database. -4. If the backup came from a different instance (different encryption setup), all encrypted fields are automatically re-encrypted to work with your current password. -5. Failed login attempts are reset to zero. -6. **Your current login password is not changed.** You don't need to log out or log back in. +1. Backup file is validated +2. All existing data is deleted +3. Backup data is written to the database +4. If the backup is from a different instance, encrypted fields are automatically re-encrypted for your current password +5. Failed login attempts reset to zero +6. **Your login password stays the same.** You stay logged in. ### Cross-instance restores -If you're restoring a backup from a different Tracker Tracker installation — one that was set up with a different password — Tracker Tracker will re-encrypt the sensitive fields automatically so they work with your current password. - -If a field can't be re-encrypted (for example, because the backup was encrypted with a password you no longer know), that field is cleared rather than saved in a broken state. For TOTP, the restore screen will tell you if 2FA was turned off as a result. You can re-enable it after the restore completes. +Restoring from a different Tracker Tracker instance with a different password? The app re-encrypts sensitive fields automatically. If a field can't be re-encrypted (i.e., the backup was encrypted with an unknown password), it's cleared. For TOTP, the restore screen tells you if 2FA is disabled — re-enable it after restoring. !!! warning "TOTP after a cross-instance restore" - If 2FA was active on the source instance but can't be carried over, it will be disabled. Re-enroll in **Settings → Security** after the restore. + If 2FA was active but can't be carried over, it'll be disabled. Re-enroll in **Settings → Security** after the restore. ![Backup configuration — encryption, scheduling, and storage path](../assets/images/backups-configuration.png) ## Settings Reference -| Setting | Default | Description | -| ----------------- | ------- | ----------------------------------------------------------------- | -| Scheduled backups | Off | Enable automatic backups on a schedule | -| Frequency | Daily | How often scheduled backups run: daily, weekly, or monthly | -| Retention count | 14 | How many scheduled backups to keep (1-365) | -| Encrypt backups | Off | Wrap scheduled backups in an additional encryption layer (.ttbak) | -| Storage path | — | Directory on the server where scheduled backup files are saved | +| Setting | Default | Description | +|-------------------|---------|-------------------------------------------------------------| +| Scheduled backups | Off | Enable automatic backups on a schedule | +| Frequency | Daily | How often to run: daily, weekly, or monthly | +| Retention count | 14 | How many backups to keep (1-365) | +| Encrypt backups | Off | Add an extra encryption layer to scheduled backups (.ttbak) | +| Storage path | — | Server directory where scheduled backups are saved | diff --git a/docs/kb/docs/features/download-clients.md b/docs/kb/docs/features/download-clients.md index a3dca79a..a6e10477 100644 --- a/docs/kb/docs/features/download-clients.md +++ b/docs/kb/docs/features/download-clients.md @@ -5,7 +5,7 @@ description: Connect qBittorrent to track per-tracker torrent stats, seeding cou # Download Clients -Tracker Tracker connects to qBittorrent's web interface to pull live torrent data. This powers the Torrents tab on the dashboard — showing active downloads and uploads, speeds, seeding counts, ratio histograms, and cross-seed stats. +Tracker Tracker connects to qBittorrent's web interface to pull live torrent data. This powers the Torrents tab with active downloads and uploads, speeds, seeding counts, ratio histograms, and cross-seed stats. ## Supported Clients @@ -18,7 +18,7 @@ Tracker Tracker connects to qBittorrent's web interface to pull live torrent dat ## Adding a Client -Go to **Settings → Download Clients** and fill in the connection details: +Go to **Settings → Download Clients** and fill in your connection details: | Field | Notes | | -------- | ----------------------------------------------------------- | @@ -36,33 +36,27 @@ After saving, use the **Test Connection** button to confirm Tracker Tracker can ## Linking Trackers to a Client -Each tracker can have a **qBittorrent tag** assigned to it. Set this tag to match the label you use in qBittorrent for that tracker's torrents. - -When Tracker Tracker polls for torrent data, it fetches only torrents with the matching tag — so you get per-tracker stats rather than a combined total. - -To assign a tag, open the tracker's settings page and fill in the qBittorrent tag field. +Assign each tracker a **qBittorrent tag** that matches the label you use for that tracker's torrents. When polling, Tracker Tracker fetches only torrents with the matching tag for per-tracker stats. Open the tracker's settings and fill in the qBittorrent tag field. ## How Polling Works -Tracker Tracker uses two separate polling loops: +Tracker Tracker runs two polling loops: === "Live speed (every 30 seconds)" - Fetches current upload/download speeds from qBittorrent. This is a single lightweight request. The result shows in the sidebar speed display and in the uptime tracker. + Lightweight request for current upload/download speeds. Displayed in the sidebar and uptime tracker. === "Full torrent data (every 5 minutes)" - Fetches the full torrent list for each configured tag, then aggregates per-tag stats: seeding count, leeching count, speeds. The result is saved as a snapshot. - - This loop also caches the torrent list so the Torrents tab has data to show even if qBittorrent is briefly unreachable. + Fetches the full torrent list per tag and aggregates stats: seeding count, leeching count, speeds. Cached as snapshots so the Torrents tab works even if qBittorrent is briefly offline. -Both loops reuse the existing session. qBittorrent only re-authenticates if the session expires (which shows up as a 403 response). +Both loops reuse the same session. qBittorrent only re-authenticates on 403 responses. ## Cross-Seed Detection -If you use [cross-seed](https://cross-seed.org/) to find matching torrents across trackers, you can configure cross-seed tags on the download client. Any torrent tagged with one of those tags is counted separately in the cross-seed stats on the Torrents tab — so you can see how many of your torrents are cross-seeded vs. original grabs. +Use [cross-seed](https://cross-seed.org/) to find matching torrents across trackers? Configure cross-seed tags on the download client. Any torrent with one of those tags is counted separately in the cross-seed stats — so you can see how many torrents are cross-seeded vs. original grabs. -Set the cross-seed tags in the client's settings after adding it. Common tags are `cross-seed` (the default from cross-seed) or category-based variants like `cs-link-movies`, `cs-link-tv`. +Set cross-seed tags in the client settings after adding it. Common tags are `cross-seed` (the default) or category-based like `cs-link-movies`, `cs-link-tv`. ![Cross-seed ratio chart showing 741 cross-seeded vs 1307 unique](../assets/images/tracker-page-cross-seed-chart.png) @@ -70,16 +64,16 @@ For more on setting up cross-seed itself, see the [cross-seed documentation](htt ## Privacy: What Gets Stripped -Tracker Tracker deliberately removes certain fields from torrent data before caching or displaying it: +Tracker Tracker removes these fields from torrent data before caching or displaying: -- Announce URLs (these contain your tracker passkey) +- Announce URLs (which contain your tracker passkey) - File paths on disk -This applies everywhere — the dashboard, the cached torrent list, and any API response. +This applies everywhere: dashboard, cached lists, API responses. ## Credential Security -Your qBittorrent username and password are encrypted at rest. They're decrypted in memory only when a poll is about to run, used for authentication, and never written to logs. +Your qBittorrent username and password are encrypted at rest. They're decrypted in memory only when polling starts, used for authentication, and never logged. ## Troubleshooting @@ -91,4 +85,4 @@ Your qBittorrent username and password are encrypted at rest. They're decrypted | `Authentication failed — check username and password` | Wrong credentials | | `Authentication failed — SID cookie not found in response` | Unexpected response from qBittorrent — check that the Web UI is enabled in qBittorrent's settings | -If the Test Connection button reports an error, it always forces a fresh login attempt — it won't reuse a cached session. So the error you see reflects the actual current state. +The Test Connection button forces a fresh login, so errors reflect the current state. diff --git a/docs/kb/docs/features/image-hosting.md b/docs/kb/docs/features/image-hosting.md deleted file mode 100644 index bb2ed715..00000000 --- a/docs/kb/docs/features/image-hosting.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: Image Hosting -description: Upload screenshots to PTPImg, OnlyImage, or ImgBB directly from Tracker Tracker. ---- - -# Image Hosting - -!!! danger "Functionality has not been implemented and this currently does nothing" - This is in preparation for the Tracker Transit Papers that will be launched in an upcoming release. No harm is done from adding your api keys now, but they won't do anything. - -Tracker Tracker can upload images to external hosting services and return a direct link. This is useful for uploading screenshots when creating or editing torrent listings on trackers that require images hosted on approved services. - -## Supported Hosts - -| Host | URL | Expiration | Notes | -| ------------- | ------------- | --------------------------- | ------------------------------------------ | -| **PTPImg** | ptpimg.me | Not supported | Required by PTP, accepted by most trackers | -| **OnlyImage** | onlyimage.org | Time-based (ISO 8601) | Used by OnlyEncodes and other trackers | -| **ImgBB** | imgbb.com | Time-based (60s - 180 days) | Widely accepted free host | - -None of the three services support "burn after reading" (view-count-based self-destruct). PTPImg hosts images permanently. OnlyImage and ImgBB support time-based auto-deletion. - -## Setting Up API Keys - -Go to **Settings → General → Image Hosting** to add your API keys. - -Each key is encrypted at rest using the same AES-256-GCM encryption used for tracker API tokens. The app only stores whether a key is configured (shown as a "configured" badge) — the plaintext key is never returned to the browser after saving. - -### Where to Get Your API Key - -**PTPImg:** Log into ptpimg.me, view page source, and find the `api_key` value. It looks like a UUID: `44171be4-eb87-444f-9c49-11268f470e12`. - -**OnlyImage:** Go to your user settings at onlyimage.org and find the API section. The key is a long hex string starting with `chv_`. - -**ImgBB:** Create a free account at api.imgbb.com and generate an API key from the dashboard. It's a 32-character hex string. - -### Managing Keys - -- **Save Key** — Paste the key and click Save. It's encrypted before storage. -- **Replace Key** — Click "Replace Key" to enter a new one. The old key is overwritten. -- **Remove** — Click "Remove" to delete the stored key. This does not delete any images already uploaded. - -## Expiration - -When uploading to ImgBB or OnlyImage, you can optionally set images to auto-delete after a set time. PTPImg does not support expiration — all PTPImg uploads are permanent. - -| Duration | ImgBB | OnlyImage | -| -------- | ------------- | --------- | -| 1 minute | Yes (minimum) | Yes | -| 1 hour | Yes | Yes | -| 1 day | Yes | Yes | -| 1 week | Yes | Yes | -| 30 days | Yes | Yes | -| 180 days | Yes (maximum) | Yes | - -## Supported File Types - -JPEG, PNG, GIF, WebP, BMP, and AVIF. Maximum file size is 32 MB. - -## Backups - -Image hosting API keys are included in backups as encrypted ciphertext. When restoring from a backup created on a different instance (different encryption salt), the keys are automatically re-encrypted using the current instance's key. If re-encryption fails, the keys are silently cleared — you'll need to re-enter them after restore. - -Backups created before the image hosting feature was added will not contain these keys. Restoring from such a backup will not affect any keys you've already configured. - -## Security - -- API keys are encrypted at rest using the same encryption as your tracker tokens -- Keys are never shown after saving — the app only displays whether a key is configured -- Uploads require you to be logged in -- Keys are sent securely to the hosting services (never in URLs where they could leak into logs) diff --git a/docs/kb/docs/features/proxies.md b/docs/kb/docs/features/proxies.md index 482d8775..8e7d6bc3 100644 --- a/docs/kb/docs/features/proxies.md +++ b/docs/kb/docs/features/proxies.md @@ -8,62 +8,60 @@ description: Route tracker polling through SOCKS5, HTTP, or HTTPS proxies on a p !!! warning "Experimental" Proxy support is experimental and may not work with all trackers or proxy configurations. Use at your own risk. -You can route outbound tracker API requests through a proxy. Proxy support is opt-in — you configure one global proxy, and then individually enable it per tracker. +Route tracker API requests through a proxy. Set up one global proxy, then enable it per tracker. ## Supported Proxy Types | Type | Common use | -| -------- | ----------------------------------------------- | +|----------|-------------------------------------------------| | `socks5` | Tor, SSH tunnels, most privacy-oriented proxies | | `http` | Standard HTTP CONNECT proxies | | `https` | TLS-wrapped HTTP CONNECT proxies | -The proxy type controls how your traffic reaches the proxy server — SOCKS5 for tunnel-level proxying, HTTP/HTTPS for CONNECT-based proxying. Either way, the actual request to the tracker is always HTTPS, so your API token is encrypted end-to-end regardless of proxy type. - -When a proxy is enabled for a tracker, the tracker sees the proxy's IP address — not yours. +The proxy type controls how traffic reaches the proxy server. SOCKS5 uses tunnel-level proxying, HTTP/HTTPS uses CONNECT-based proxying. Either way, tracker requests stay HTTPS, keeping your API token encrypted. When enabled, the tracker sees the proxy's IP instead of yours. !!! warning "Some trackers ban proxy and VPN traffic" - Many private trackers explicitly prohibit accessing the site from VPNs, proxies, or shared IPs. Using a proxy for API polling may trigger automated security flags or get your account disabled. Check your tracker's rules before enabling this. If a tracker allows API access from a different IP than your browsing IP, you're probably fine — but not all trackers make that distinction. + Many private trackers prohibit access from VPNs, proxies, or shared IPs. Using a proxy for API polling may trigger security flags or get you disabled. Check your tracker's rules first. If a tracker allows API access from a different IP than your browsing IP, you're probably fine — but not all make that distinction. !!! info "DNS resolution" - HTTP and HTTPS proxies resolve the tracker's hostname on the proxy side — your local DNS provider never sees the domain. SOCKS5 behavior depends on configuration: most SOCKS5 proxies also resolve remotely, but some setups resolve locally first. If DNS privacy matters to you, verify your SOCKS5 proxy does remote resolution. + HTTP and HTTPS proxies resolve on the proxy side — your DNS provider never sees it. SOCKS5 varies: most resolve remotely, some resolve locally first. If DNS privacy matters, verify your SOCKS5 proxy does remote resolution. ## Setup -Proxy settings live in **Settings → General → Proxy**. +Proxy settings are in **Settings → General → Proxy**. ### Step 1: Configure the global proxy -Fill in the proxy details: +Fill in your proxy details: | Field | Description | -| ---------- | --------------------------------------------------------- | +|------------|-----------------------------------------------------------| | Proxy type | `socks5`, `http`, or `https` | -| Host | Hostname or IP address of your proxy server | +| Host | Hostname or IP of your proxy | | Port | Port number (commonly `1080` for SOCKS5, `8080` for HTTP) | -| Username | Optional — only needed for authenticated proxies | -| Password | Optional — stored securely, never in plaintext | +| Username | Optional — for authenticated proxies only | +| Password | Optional — stored securely, never plaintext | -The master switch at the top of the proxy section enables or disables the proxy globally. Even if individual trackers have the proxy toggled on, nothing is routed through the proxy while the master switch is off. +The master switch controls the proxy globally. Even if trackers have the toggle on, nothing routes through while the master switch is off. -### Step 2: Enable the proxy per tracker +### Step 2: Enable per tracker -On any tracker's settings page, toggle **Use Proxy**. That tracker's polls will then go through the global proxy. +On a tracker's settings page, toggle **Use Proxy**. That tracker's polls go through the global proxy. -Trackers with the toggle off always poll directly, even if a global proxy is configured. +Trackers with the toggle off always poll directly. ## Credential Security -Your proxy password is encrypted at rest. It's decrypted in memory only when a poll is about to run, used for the request, and never written to logs. +Your proxy password is encrypted at rest. It's decrypted in memory only when polling starts, used for the request, and never logged. ## Troubleshooting -| Error message | What it means | -| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Failed to create proxy agent (check proxy host/port configuration)` | The host or port format is invalid. Double-check there are no typos and no `http://` prefix in the host field. | -| `Failed to decrypt proxy password, proceeding without auth` | The proxy password couldn't be read — this can happen after a restore from a backup with a different password. Re-enter the proxy password in settings. | -| `Request timed out after 15000ms` | The proxy or tracker didn't respond within 15 seconds. Check that the proxy is running and reachable. | -| `proxyFetch only supports HTTPS URLs` | The tracker URL is using `http://` — change it to `https://`. | +| Error message | What it means | +|----------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------| +| `Failed to create proxy agent (check proxy host/port configuration)` | Invalid host or port format. Check for typos and no `http://` prefix in the host field. | +| `Failed to decrypt proxy password, proceeding without auth` | The proxy password couldn't be read — can happen after restore from a backup with a different password. Re-enter the proxy password in settings. | +| `Request timed out after 15000ms` | The proxy or tracker didn't respond in 15 seconds. Check that the proxy is running and reachable. | +| `proxyFetch only supports HTTPS URLs` | The tracker URL uses `http://` — change it to `https://`. | ## Proxy Host Format diff --git a/docs/kb/docs/features/qbitmanage.md b/docs/kb/docs/features/qbitmanage.md index ceeaf467..fa3e3d80 100644 --- a/docs/kb/docs/features/qbitmanage.md +++ b/docs/kb/docs/features/qbitmanage.md @@ -5,19 +5,17 @@ description: Automatically categorize and visualize torrents using qbitmanage st # qbitmanage Integration -[qbitmanage](https://github.com/StuffAnThings/qbit_manage) is a tool that automatically manages your qBittorrent torrents — tagging, categorizing, cleaning up orphaned data, enforcing share limits, and more. If you already run it, Tracker Tracker can read its tags and turn them into useful charts. +[qbitmanage](https://github.com/StuffAnThings/qbit_manage) automatically manages qBittorrent torrents — tags, categories, cleanup, share limits, and more. If you're already running it, Tracker Tracker can read those tags and build charts from them. -This page covers how the two tools work together. For general tag group setup, see [Tag Groups](tag-groups.md). +For general tag group setup, see [Tag Groups](tag-groups.md). ## How It Works -qbitmanage writes tags to your torrents in qBittorrent. Tracker Tracker reads those tags during its deep poll cycle. You set up tag groups that match qbitmanage's tag names, and the charts appear automatically on each tracker's Torrents tab. - -Nothing is pushed between the two — they both talk to qBittorrent independently. +qbitmanage writes tags to qBittorrent. Tracker Tracker reads them during its deep poll cycle. Create tag groups matching qbitmanage's tag names and charts appear automatically on each tracker's Torrents tab. Both tools talk to qBittorrent independently. ## Built-In Status Tag Tracking -Tracker Tracker has built-in support for qbitmanage's status tags. Enable it in **Settings → Download Clients → qbitmanage Tag Tracking**. +Tracker Tracker has built-in support for qbitmanage's status tags. Turn it on in **Settings → Download Clients → qbitmanage Tag Tracking**. ![qbitmanage tag tracking settings](../assets/images/qbitmanage-settings.png) @@ -32,14 +30,14 @@ Map each status to the tag name from your qbitmanage config. Here are qbitmanage | Last Active Limit Not Reached | `LastActiveLimitNotReached` | `share_limits_last_active_tag` | Hasn't been inactive long enough for removal | | Last Active Not Reached | `LastActiveNotReached` | `share_limits_last_active_tag` | Last activity hasn't crossed the threshold | -Many people customize these with emoji prefixes (e.g., `⚠️ Issue` instead of `issue`). If you've changed them in your qbitmanage `config.yml`, enter **your** tag names — not the defaults above. +Many people customize them with emoji (i.e., `⚠️ Issue`). If you've changed them in your qbitmanage `config.yml`, use your actual tag names here. Here's what the qbitmanage status breakdown looks like on a tracker's Torrents tab: ![qbitmanage status bar chart showing No Hardlinks, Min Seeds Not Met, Last Active Limit, and Last Active Not Reached](../assets/images/tracker-page-qbitmanage.png) !!! tip "Match your config exactly" - Tag names must match character-for-character, including any emoji or special characters. Copy them directly from your qbitmanage `config.yml`. + Tag names must match character-for-character, including emoji and special characters. Copy them straight from your qbitmanage `config.yml`. ## Tag Group Examples @@ -137,10 +135,10 @@ Create a tag group with just the `⛓️‍💥 noHL` tag and enable **Count unm ## Tips -- **qbitmanage runs on its own schedule.** Tags may take a few minutes to appear in qBittorrent after a new torrent is added. Tracker Tracker polls qBittorrent every 5 minutes by default, so there's a lag between qbitmanage tagging a torrent and the chart updating. -- **Emoji in tags are fine.** qbitmanage commonly uses emoji prefixes for visual organization. Tracker Tracker handles them without issues — just make sure the exact emoji sequence matches. -- **Priority tags come from the `tracker:` section,** not from share limits. Share limits _use_ the priority tags for filtering, but the tags themselves are assigned by the tracker keyword matching. -- **You don't need qbitmanage to use tag groups.** Any tags in qBittorrent work — qbitmanage just happens to be the most popular way to automate tagging in the homelab community. +- **qbitmanage runs independently.** Tags take a few minutes to appear, and Tracker Tracker polls every 5 minutes by default, so there's some lag. +- **Emoji in tags work fine** — just match the exact sequence. +- **Priority tags come from the `tracker:` section**, not share limits. Share limits use them for filtering, but the tags come from tracker keyword matching. +- **You don't need qbitmanage for tag groups.** Any qBittorrent tags work — qbitmanage is just popular in the homelab community. ## Resources diff --git a/docs/kb/docs/features/tag-groups.md b/docs/kb/docs/features/tag-groups.md index e721908d..09e5493c 100644 --- a/docs/kb/docs/features/tag-groups.md +++ b/docs/kb/docs/features/tag-groups.md @@ -5,7 +5,7 @@ description: Bundle qBittorrent tags into named groups to see breakdown charts o # Tag Groups -If you tag your torrents in qBittorrent, tag groups let you visualize those tags as charts on each tracker's Torrents tab. You define the groups once, and they show up on every tracker. +Tag groups turn qBittorrent tags into charts on each tracker's Torrents tab. Define them once and they appear everywhere. ## What They're For @@ -33,13 +33,13 @@ This is what the Priority group looks like on a tracker's Torrents tab: ## Creating a Tag Group -1. Go to **Settings → Download Clients** (scroll past your client cards). -2. Click **Add Tag Group**. -3. Name it and pick an emoji. -4. Add rows — each row maps a **qBittorrent tag** (the exact tag string as it appears in qBit) to a **display label** (what shows up in the chart). -5. Save. +1. Go to **Settings → Download Clients** and scroll past your client cards +2. Click **Add Tag Group** +3. Name it and pick an emoji +4. Add rows mapping **qBittorrent tags** (exact strings) to **display labels** (what appears in the chart) +5. Save -The group immediately appears on the Torrents tab of every tracker that has matching torrents. Trackers with no matching torrents skip the chart silently. +The group appears instantly on the Torrents tab for trackers with matching torrents. If there are no matches, the chart won't appear. ## Display Types @@ -54,11 +54,11 @@ Each group can use a different chart style: ## Count Unmatched Tags -When enabled, the chart includes an extra segment for torrents that don't match _any_ tag in the group. Useful for seeing how many torrents aren't categorized yet. +Enable this to add a segment showing torrents with no tag in the group. Great for seeing what's still uncategorized. ## Tag Matching -Tags must match **exactly** — same capitalization, same spacing, same characters. If your qBittorrent tag is `High Priority` and you type `high priority` in the group, it won't match. +Tags are case-sensitive and must match exactly. If qBittorrent says `High Priority`, typing `high priority` won't work. !!! tip "Check your qBittorrent tags" Open qBittorrent and look at the tag list in the sidebar to see the exact tag names. Copy them character-for-character into Tracker Tracker. @@ -69,13 +69,13 @@ If you use [qbitmanage](https://github.com/StuffAnThings/qbit_manage) to automat ## Editing and Reordering -- **Rename a group** — double-click the group name. -- **Reorder tags** — drag the handle on the left side of each row. -- **Remove a tag** — click the X on the right. -- **Delete a group** — click Delete Group at the bottom (requires confirmation). +- **Rename** — double-click the group name +- **Reorder tags** — drag the handle on the left +- **Remove a tag** — click the X on the right +- **Delete a group** — click Delete Group at the bottom (requires confirmation) ## Good to Know -- Tag groups are global — they apply to all trackers, not just one. If a group's tags don't match any torrents on a particular tracker, the chart simply doesn't appear there. -- Changes in settings take effect on the next page load of a tracker detail page. -- Tag groups are included in backups and restored automatically. +- Tag groups are global — one group works across all trackers. +- Changes take effect when you reload a tracker page. +- Backups include tag groups and restore them automatically. diff --git a/docs/kb/docs/features/totp.md b/docs/kb/docs/features/totp.md deleted file mode 100644 index ad657533..00000000 --- a/docs/kb/docs/features/totp.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -title: Two-Factor Authentication (TOTP) -description: Add a time-based one-time password requirement to your login flow. ---- - -# Two-Factor Authentication (TOTP) - -You can add a second login step using any standard authenticator app. After entering your password, you'll be prompted for a 6-digit code from the app. - -Works with: Google Authenticator, Aegis, 1Password, Bitwarden, Authy, and any other app that supports standard TOTP. - -## Enabling 2FA - -1. Go to **Settings → Security**. -2. Click **Enable Two-Factor Authentication**. -3. Scan the QR code with your authenticator app. -4. Enter the 6-digit code shown in your app to confirm it worked. -5. Copy your backup codes and store them somewhere safe (see below). -6. Click **Confirm**. - -The app won't save anything until you enter a valid code in step 4. If the code is wrong or expired, just try again — nothing gets committed. - -## Backup Codes - -When you enable 2FA, you get 8 backup codes. Each one looks like this: - -```ascii -3A7F-B2C1 -``` - -**Each code works exactly once.** After you use one, it's gone. The remaining codes stay valid. - -Use a backup code at the TOTP prompt the same way you'd use a 6-digit code — there's a "Use a backup code" option on the login screen. - -!!! warning "Save your backup codes now" - If you lose your authenticator app and don't have backup codes, you cannot log in. There is no account recovery. Store the codes in a password manager or print them and keep them somewhere secure. - -## Logging In With 2FA - -After entering your password, you'll see a second screen asking for your code. Enter: - -- The current 6-digit code from your authenticator app, **or** -- One of your saved backup codes - -The app accepts codes from the previous and next 30-second window to account for slight clock drift. - -## Disabling 2FA - -Turning off 2FA requires both your password and a valid TOTP code (or a backup code). This prevents someone with brief access to your open browser session from removing your 2FA protection. - -1. Go to **Settings → Security**. -2. Click **Disable Two-Factor Authentication**. -3. Enter your password. -4. Enter a current TOTP code, or check "Use a backup code" and enter one. -5. Click **Confirm**. - -## Failed Login Attempts - -Failed TOTP attempts count toward the same lockout limit as failed password attempts. If you've configured a lockout threshold in settings, too many wrong codes will temporarily lock the account. - -## After a Backup Restore - -!!! warning "2FA may be disabled after restoring a backup" - If you restore a backup from a different Tracker Tracker instance — one that was set up with a different password — the 2FA secret can't be carried over. In that case, 2FA will be turned off automatically as part of the restore. - -The restore confirmation screen will tell you if this happened. You'll need to go back to **Settings → Security** and set up 2FA again. - -This only applies to cross-instance restores. Restoring a backup on the same instance you created it on is fine. diff --git a/docs/kb/docs/features/transit-papers.md b/docs/kb/docs/features/transit-papers.md index d6af957b..80838e50 100644 --- a/docs/kb/docs/features/transit-papers.md +++ b/docs/kb/docs/features/transit-papers.md @@ -10,32 +10,25 @@ description: Generate tamper-resistant proof-of-membership images for private tr !!! warning "Beta — Highly Experimental" Transit Papers are under active development. The report format, encoding scheme, and verification behavior may change between versions. Reports generated with one version are not guaranteed to verify correctly with a future version. Use at your own risk. -Transit Papers generate a tamper-resistant PNG image showing your stats on a single tracker. The image is designed to be shared with tracker moderators as proof of your membership and standing when applying to other trackers. - -Instead of a browser screenshot — which can be faked in seconds with inspect element or Photoshop — Transit Papers produce a server-rendered image with cryptographically linked visual elements. Editing any part of the image (the stats, the fractal seal, or the data strip) breaks the link between them, and the verification tool detects it. Each report is called a **Proof of Citizenship**. +Transit Papers generate tamper-resistant PNG images of your tracker stats to share with moderators when applying to new trackers. Unlike a screenshot (faked in 30 seconds), they use server-side rendering with cryptographically linked elements — edit any part and the links break. The verification tool catches tampering. These reports are **Proofs of Citizenship**. !!! warning "What Transit Papers are NOT" - Transit Papers are **not zero-trust cryptographic proof** that the stats are real. Tracker Tracker is self-hosted. You control the machine, the database, and the network. A technically motivated user could theoretically fabricate data before generation. - - What this system does is raise the cost of forgery from **trivial** (inspect element, 30 seconds) to **impractical** (clone the project, set up a database, fabricate internally consistent stats across 10+ mathematically related fields, understand the target tracker's API schema). - Transit Papers are a tool to assist a mod's judgment, not replace it. If a mod has direct access to your profile on the source tracker, that is always more trustworthy than any report. + They're **not cryptographic proof** your stats are real. Self-hosted means you control the machine, database, and network. A determined attacker could fake data before generation. But this system makes forgery impractical — faking a screenshot takes 30 seconds; faking a Transit Paper means cloning the project, setting up a database, fabricating internally consistent stats across 10+ math-linked fields, and matching the exact API schema of your target tracker. Much harder. Transit Papers help mods decide — they don't replace human judgment. Direct tracker profile access is always more trustworthy. --- ## Generating a Proof of Citizenship -Navigate to a tracker's detail page and generate a report. Tracker Tracker uses your most recent polled snapshot to render the report server-side and returns a PNG for download. +Go to a tracker's detail page and generate a report. Tracker Tracker renders your most recent snapshot server-side and returns a 1200x630 PNG with: -The report is a 1200x630 image containing: - -- **Tracker name and platform type** — which tracker and what software it runs -- **Your identity** — username, class/rank, and member-since date appear in a header section above the stats grid (when available). -- **Your stats** — uploaded bytes, downloaded bytes, ratio, buffer, seeding count, seedbonus, and hit & runs. Which fields appear depends on what the platform reports. -- **Fractal Seal** — a unique fractal image derived from your stats. Different stats produce a completely different fractal. This is both decorative and functional — it serves as the encryption key for the data strip. -- **Spirograph** — a decorative hypotrochoid pattern derived from the generation timestamp. Three layered curves, each 120 degrees apart on the color wheel. -- **Data Strip** — a horizontal colored barcode near the bottom of the image. Your stats are serialized, encrypted using a key derived from the fractal, and encoded as colored bands. -- **Footer** — generation timestamp, report version, and the full SHA-256 seed hash. +- **Tracker name and platform** — which tracker and its software +- **Your identity** — username, class/rank, join date (when available) +- **Your stats** — upload, download, ratio, buffer, seeding count, seedbonus, hit & runs (what's available varies by platform) +- **Fractal Seal** — a unique fractal derived from your stats. Different stats = different fractal. Serves as the encryption key for the data strip. +- **Spirograph** — a decorative hypotrochoid pattern from the generation timestamp +- **Data Strip** — a colored barcode near the bottom. Stats are serialized, encrypted with the fractal key, and encoded as colored bands. +- **Footer** — generation timestamp, report version, and full SHA-256 seed hash --- @@ -43,7 +36,7 @@ The report is a 1200x630 image containing: ### Built-in upload integrations (recommended) -Tracker Tracker can upload your Proof of Citizenship directly to an image host and give you a shareable URL. This is the most reliable method because the image is transferred losslessly. +Upload your Proof directly to an image host for a shareable URL. This is most reliable because the image transfers losslessly. | Host | Notes | | ------------- | ---------------------------------------------------------------------------------- | @@ -51,24 +44,25 @@ Tracker Tracker can upload your Proof of Citizenship directly to an image host a | **OnlyImage** | onlyimage.org. Accepted by OnlyEncodes and other trackers. | | **ImgBB** | Supports auto-delete timers if you want expiring links. May recompress large PNGs. | -Configure API keys in **Settings → General → Image Hosting**. See the [Image Hosting](image-hosting.md) docs for setup instructions. +Configure API keys in **Settings → General → Image Hosting**. ### Manual sharing -If you are not using an integration, share the original PNG file directly: +No integration? Share the original PNG directly: - Attach it to a forum PM or recruitment thread - Send via IRC DCC - Upload to any lossless image host manually (ptpimg.me, imgbox.com, catbox.moe) !!! danger "Do not screenshot the report" + The verification system reads pixel data from the image. A screenshot of the report is not the report. It will degrade verification or cause it to fail entirely. --- ## Compression and Image Quality -The data strip and fractal seal are verified at the pixel level. If the image passes through a lossy compression step (JPEG conversion, resize, re-encoding), pixel colors shift. This can cause verification to fail or require fuzzy recovery that reduces confidence. +The data strip and fractal seal are verified at the pixel level. Lossy compression (JPEG, resize, re-encoding) shifts pixel colors, breaking verification or triggering fuzzy recovery with lower confidence. ### Lossless (verification works perfectly) @@ -103,52 +97,51 @@ The verifier uses fuzzy recovery: it tries nearby perceptual hash keys until it | 4+ | Heavy compression or re-encoding. Result is valid but confidence is reduced. | | Failed | Image too degraded to recover. Request the original file or a lossless-hosted link. | -**For users:** Use a built-in upload integration or share the original file directly. +**For users:** Use a built-in integration or share the original file directly. -**For mods:** If verification shows more than 0 bit flips, consider asking the user to reshare via ptpimg or OnlyImage. +**For mods:** If verification shows bit flips, ask the user to reshare via ptpimg or OnlyImage. --- ## For Tracker Mods: Verifying a Report -The verification tool is a standalone static page — completely separate from any Tracker Tracker installation. It runs entirely in your browser. No account, no server connection, no data leaves your machine. You do not need to be a Tracker Tracker user to verify a report. - -Upload or drag-and-drop the PNG onto the verification page. +The verification tool is a standalone static page, completely separate from Tracker Tracker. It runs entirely in your browser with no account, server, or data leaving your machine. Just upload or drag-and-drop the PNG onto the verification page. ### What the verification page shows -- Whether the data strip was successfully decoded -- The decoded stats: tracker name, platform, username, class, upload, download, ratio, buffer, seeding count, seedbonus, hit & runs, join date -- A side-by-side comparison of the fractal extracted from the image and the fractal regenerated from the decoded stats -- A regenerated spirograph from the decoded timestamp -- How many bit corrections were needed (0 = lossless) +- Whether the data strip decoded successfully +- Decoded stats: tracker name, platform, username, class, upload, download, ratio, buffer, seeding count, seedbonus, hit & runs, join date +- Side-by-side fractal comparison: extracted from image vs. regenerated from decoded stats +- Regenerated spirograph from the timestamp +- Bit corrections needed (0 = lossless) - Overall verification status ### Verification results **Verified — Seal + Data Match** -: The stats in the strip match the fractal. The image has not been tampered with after generation. This is the best result. +: Stats in the strip match the fractal. No tampering after generation. Best result. **Data Decoded — Seal Mismatch** -: The strip decoded but the fractal does not match. The image may have been edited after generation, or it experienced heavy compression. Ask for the original file. +: Strip decoded but fractal doesn't match. The image was edited after generation or compressed heavily. Ask for the original. **Decode Failed** -: The image is too degraded or has been fundamentally altered. Cannot verify. +: Image too degraded or fundamentally altered. Cannot verify. !!! info "What verification does NOT tell you" - Verification confirms the image is internally consistent and has not been tampered with **after generation**. It does not confirm the stats are truthful — the user controls their instance and could have fabricated data before generating the report. Use it as one input alongside your own judgment. + + It confirms internal consistency and no tampering after generation. It doesn't confirm the stats are real — the user controls their instance. Use it as one input with your own judgment. --- ## How It Works -1. When you generate a report, Tracker Tracker reads your most recent polled snapshot from its database. -2. It serializes the stats into a compact binary format and hashes them to produce a unique fingerprint. -3. That fingerprint determines the appearance of the fractal seal. Different stats produce a completely different fractal. -4. The fractal image is used to derive an encryption key. -5. The binary stats are encrypted with that key, scrambled, and encoded into the colored bands of the data strip. -6. The fractal and strip are bound together: you cannot change one without breaking the other. -7. When a verifier uploads the image, the system extracts the fractal, derives the decryption key from it, decrypts the strip, recovers the stats, regenerates what the fractal _should_ look like from those stats, and checks that it matches what's actually in the image. +1. Generate a report. Tracker Tracker reads your most recent snapshot. +2. Stats serialize to compact binary, hashed for a fingerprint. +3. The fingerprint determines the fractal. Different stats = different fractal. +4. The fractal derives an encryption key. +5. Stats encrypt with that key, scramble, and encode into the data strip's colored bands. +6. Fractal and strip are linked — change one and the other breaks. +7. A verifier uploads the image. The system extracts the fractal, derives the decryption key, decrypts the strip, recovers the stats, regenerates the fractal from those stats, and compares it to the image. --- @@ -174,22 +167,23 @@ Upload or drag-and-drop the PNG onto the verification page. | User modifies the source code | Open source — code is public | Faker must reproduce pixel-perfect output from the full rendering pipeline | !!! note "Honest positioning" - This system raises forgery effort from trivial to impractical. It does not make forgery impossible. A determined attacker with technical skills who controls their instance can theoretically fabricate a valid report. The practical threat — someone trying to bluff their way into a tracker invite — is effectively blocked. + + This makes forgery impractical, not impossible. A skilled attacker with instance control could theoretically fake a report. The real threat — someone bluffing their way into a tracker invite — is effectively blocked. --- ## Privacy Considerations -- The report contains your username, tracker name, platform type, and stats. Once shared, you cannot control its distribution. -- Image host URLs (ptpimg, imgbox, etc.) are typically unguessable but publicly accessible if someone has the link. -- ImgBB supports auto-delete timers if you want the image to expire after sharing. -- The verification tool runs entirely in the browser. No images are uploaded to any server — processing happens locally in memory and nothing is stored or transmitted. +- Reports contain your username, tracker name, platform, and stats — once shared, you can't control distribution. +- Image host URLs are unguessable but publicly accessible with the link. +- ImgBB supports auto-deletion if you want. +- Verification runs in-browser only with no uploads or data transmission. --- ## Image Host Setup -Configure image hosting API keys in **Settings → General → Image Hosting**. See the [Image Hosting](image-hosting.md) page for full setup instructions. +Configure API keys in **Settings → General → Image Hosting**. | Host | Where to find your key | Notes | | --------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------- | @@ -201,4 +195,4 @@ Configure image hosting API keys in **Settings → General → Image Hosting**. ## How Verification Recovers From Compression -When an image has been mildly compressed (passed through Discord, re-saved, etc.), the fractal's visual fingerprint may shift by a few bits. The verifier compensates by testing nearby fingerprint variants until it finds one that successfully decrypts the data strip. A checksum embedded in the payload acts as a stop condition. This process is automatic and typically completes in under a second. +If an image gets mildly compressed, the fractal's fingerprint shifts by a few bits. The verifier tests nearby variants until one decrypts the data strip successfully. A checksum stops the search. It's automatic and usually takes under a second. diff --git a/docs/kb/docs/features/webhooks.md b/docs/kb/docs/features/webhooks.md index 8fbd1d99..43e12834 100644 --- a/docs/kb/docs/features/webhooks.md +++ b/docs/kb/docs/features/webhooks.md @@ -5,7 +5,7 @@ description: Get alerts in Discord when your ratio drops, a tracker goes down, o # Webhooks -Tracker Tracker can send you alerts when things happen on your trackers — ratio drops, hit-and-runs, outages, rank changes, and more. +Tracker Tracker sends you alerts when things change on your trackers — ratio drops, hit-and-runs, outages, rank changes, and more. ## Supported Platforms @@ -21,10 +21,10 @@ Tracker Tracker can send you alerts when things happen on your trackers — rati ### In Discord -1. Open the channel where you want alerts. -2. **Edit Channel → Integrations → Webhooks → New Webhook.** -3. Name it whatever you want (e.g., "Tracker Alerts"). -4. Copy the webhook URL. +1. Open the channel where you want alerts +2. **Edit Channel → Integrations → Webhooks → New Webhook** +3. Name it (i.e., "Tracker Alerts") +4. Copy the webhook URL Keep this URL private — anyone with it can post to your channel. @@ -32,11 +32,11 @@ Keep this URL private — anyone with it can post to your channel. ### In Tracker Tracker -1. Go to **Settings → Notifications**. -2. Click **Add Notification Target**. -3. Select **Discord**, paste the URL, and give the target a name. -4. Choose which events you want. -5. Save, then click **Test Webhook** to confirm it works. +1. Go to **Settings → Notifications** +2. Click **Add Notification Target** +3. Select **Discord**, paste the URL, and name the target +4. Choose which events to send +5. Save and click **Test Webhook** to confirm ![Test notification in Discord](../assets/images/webhooks-discord-test-notif.png) @@ -56,10 +56,10 @@ Each target subscribes to any combination of these events: | Rank change | Your user class changes | 7 days | | Anniversary | Membership hits 1 month, 6 months, then yearly | 7 days | -Cooldowns prevent spam — if a condition persists across multiple polls, you get one alert per cooldown period, not one per poll. +Cooldowns prevent spam. If a problem persists across multiple polls, you get one alert per cooldown period, not one for every single poll. !!! info "First-poll behavior" - Events that compare snapshots (ratio drop, hit-and-run) need at least two polls and won't fire on the first one. Events like "account warned" fire immediately if the condition is already true. + Comparison events (ratio drop, hit-and-run) need two polls before firing. State events like "account warned" fire immediately if true on the first poll. ## Thresholds @@ -72,25 +72,23 @@ Two thresholds can be adjusted per target: ## Scoping to Specific Trackers -By default, a target gets events from all your trackers. You can restrict it to specific ones — events from other trackers are ignored. - -Useful for separate channels per tracker, or if you only care about alerts for certain sites. +By default, a target receives events from all trackers. You can limit it to specific trackers instead — events from others are ignored. Great for one Discord channel per tracker, or if you only want alerts for certain sites. ## Multiple Targets -You can set up more than one target. Some ideas: +Set up as many targets as you need: -- **One channel per tracker** — scope each target to a single tracker. -- **Urgent vs. routine** — ratio danger and tracker down in one channel, rank changes and anniversaries in another. -- **Private vs. shared** — turn off "Include tracker name" on targets that post to channels other people can see. +- **One channel per tracker** — scope each target to a single tracker +- **Urgent vs. routine** — ratio danger and outages in one channel, rank changes and anniversaries in another +- **Private vs. shared** — disable "Include tracker name" on public channels ## Privacy -The **Include tracker name** toggle controls whether the tracker's name appears in messages. Turn it off if you share the channel. +Use the **Include tracker name** toggle to hide the tracker name in messages if you share the Discord channel. -If **Store usernames** is disabled in app settings, usernames are masked in notifications too. +If you disable **Store usernames** in app settings, usernames are masked in alerts too. -Webhook URLs are encrypted in the database and never appear in API responses or logs. +Webhook URLs stay encrypted in the database — they never show up in API responses or logs. ## What the Messages Look Like @@ -105,7 +103,7 @@ Notifications arrive as Discord embeds with a colored sidebar: ### Webhook shows "Failed" -Open the target card — the error under the status badge describes the problem. Common causes: +Open the target card to see the error under the status badge. Common causes: - **Webhook deleted in Discord.** Re-create it and update the URL in Tracker Tracker. - **Channel deleted.** Discord removes all webhooks when a channel is deleted. @@ -113,7 +111,7 @@ Open the target card — the error under the status badge describes the problem. ### Notifications stopped arriving -After 3 consecutive failures, delivery pauses briefly and resumes automatically. If Discord was temporarily unreachable, notifications catch up on the next poll. +After 3 failures, delivery pauses and then resumes on its own. If Discord was just temporarily down, you'll catch up on the next poll. ### Getting rate-limited @@ -125,8 +123,8 @@ Discord allows roughly 30 webhook messages per minute per channel. To reduce vol ### Test works but real notifications don't -The test button sends a sample message. Real notifications need: +Real alerts require: -1. An event to actually occur (your ratio has to drop, not just be low). -2. The cooldown window to have elapsed since the last alert of that type. -3. At least two polls for comparison-based events (ratio drop, hit-and-run). +1. An actual event (ratio must drop, not just be low) +2. The cooldown to have passed since the last alert +3. Two polls for comparison events (ratio drop, hit-and-run) diff --git a/docs/kb/docs/getting-started/docker-config.md b/docs/kb/docs/getting-started/docker-config.md index 4a79db4d..84352c39 100644 --- a/docs/kb/docs/getting-started/docker-config.md +++ b/docs/kb/docs/getting-started/docker-config.md @@ -5,7 +5,7 @@ description: Environment variables, volumes, ports, reverse proxy examples, and # Docker Configuration -Everything you need to customize how Tracker Tracker runs: environment variables, volume mounts, port mapping, reverse proxy setup, and how to update. +Customize Tracker Tracker via environment variables, volumes, ports, reverse proxies, and update strategies. --- @@ -13,64 +13,64 @@ Everything you need to customize how Tracker Tracker runs: environment variables ### Required -| Variable | Description | -| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| `SESSION_SECRET` | Protects your session cookies. Minimum 32 characters. Generate with `openssl rand -base64 48`. | -| `POSTGRES_PASSWORD` | Password for the bundled PostgreSQL container. Generate with `openssl rand -base64 24`. Not needed if you set `DATABASE_URL` directly. | +| Variable | Description | +|---------------------|---------------------------------------------------------------------------------------------------------------------------| +| `SESSION_SECRET` | Protects your session cookies. Minimum 32 characters. Generate with `openssl rand -base64 48`. | +| `POSTGRES_PASSWORD` | Password for the PostgreSQL container. Generate with `openssl rand -base64 24`. Skip this if you're using `DATABASE_URL`. | ### Optional -| Variable | Default | Description | -| ---------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `POSTGRES_USER` | `postgres` | PostgreSQL username. Must match in both the app and db services. | -| `POSTGRES_DB` | `tracker_tracker` | Database name. | -| `POSTGRES_HOST` | `tracker-tracker-db` | Hostname of the PostgreSQL server. Only change this if you're using an external database without `DATABASE_URL`. | -| `POSTGRES_PORT` | `5432` | PostgreSQL port. If you change this, uncomment the matching lines in `docker-compose.yml`. | -| `DATABASE_URL` | _(auto-built)_ | Full connection string. Set this to use an external Postgres instance instead of the `POSTGRES_*` variables. Format: `postgresql://user:password@host:5432/dbname` | -| `PORT` | `3000` | Port the app listens on inside the container. The host-side port mapping in `docker-compose.yml` follows this value. | -| `BASE_URL` | _(empty)_ | The public URL where your app is reachable, e.g. `https://trackertracker.example.com`. Used in backup file metadata and notification links. | -| `SECURE_COOKIES` | _(auto)_ | Set to `true` to mark session cookies as `Secure`. Auto-enabled when `BASE_URL` starts with `https://`. Only needed if you serve over HTTPS without setting `BASE_URL`. | -| `TZ` | `UTC` | Timezone for scheduled tasks and log timestamps. Uses standard tz database names, e.g. `America/Chicago`, `Europe/London`. | -| `LOG_LEVEL` | `info` | Log verbosity. Options: `error`, `warn`, `info`, `debug`. | -| `LOG_FILE` | _(none)_ | Absolute path inside the container to write logs to disk, e.g. `/data/logs/tracker-tracker.log`. | +| Variable | Default | Description | +|------------------|----------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `POSTGRES_USER` | `postgres` | PostgreSQL username. Match this in both the app and db services. | +| `POSTGRES_DB` | `tracker_tracker` | Database name. | +| `POSTGRES_HOST` | `tracker-tracker-db` | PostgreSQL hostname. Change only if using an external Postgres instance without `DATABASE_URL`. | +| `POSTGRES_PORT` | `5432` | PostgreSQL port. If you change it, uncomment the matching lines in `docker-compose.yml`. | +| `DATABASE_URL` | _(auto-built)_ | Full connection string. Use this instead of `POSTGRES_*` variables when pointing to an external Postgres. Format: `postgresql://user:password@host:5432/dbname` | +| `PORT` | `3000` | Container port. The host mapping in `docker-compose.yml` follows this. | +| `BASE_URL` | _(empty)_ | Your public app URL (e.g. `https://trackertracker.example.com`). Used for backup metadata and notification links. | +| `SECURE_COOKIES` | _(auto)_ | Set to `true` to mark session cookies as `Secure`. Auto-enabled when `BASE_URL` is HTTPS. Only set this if you serve HTTPS without `BASE_URL`. | +| `TZ` | `UTC` | Timezone for scheduled tasks and log timestamps (e.g. `America/Chicago`, `Europe/London`). | +| `LOG_LEVEL` | `info` | Log verbosity. Options: `error`, `warn`, `info`, `debug`. | +| `LOG_FILE` | _(none)_ | Write logs to disk at this container path (e.g. `/data/logs/tracker-tracker.log`). | !!! info "Settings vs environment variables" - Most day-to-day settings — polling interval, privacy mode, proxy config, backup schedule, lockout policy — live inside the app under **Settings**, not in environment variables. Environment variables are just for infrastructure stuff like database connections and ports. + Day-to-day options (polling interval, privacy mode, proxy config, backup schedule, lockout policy) live in the app under **Settings**. Environment variables control infrastructure: database, ports, logging. --- ## Volume mounts -| Host path | Container path | Purpose | -| ---------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------- | -| `./data` | `/data` | Application data directory. Contains `backups/` and `logs/` subdirectories. | -| `./data/backups` | `/data/backups` | Where scheduled backup files are written. | -| `./data/logs` | `/data/logs` | Log files when `LOG_FILE` is set. | -| `pgdata` (named volume) | `/var/lib/postgresql/data` | PostgreSQL data directory. Managed by Docker — don't put this on a network drive. | -| `./postgres/postgresql.conf` | `/etc/postgresql/postgresql.conf` | Custom PostgreSQL config. Included in the repo and required for the bundled database to start. | +| Host path | Container path | Purpose | +|------------------------------|-----------------------------------|---------------------------------------------------------------| +| `./data` | `/data` | App data (`backups/` and `logs/`). | +| `./data/backups` | `/data/backups` | Scheduled backups. | +| `./data/logs` | `/data/logs` | Logs when you set `LOG_FILE`. | +| `pgdata` (named volume) | `/var/lib/postgresql/data` | PostgreSQL data. Docker manages it—don't use a network drive. | +| `./postgres/postgresql.conf` | `/etc/postgresql/postgresql.conf` | Custom PostgreSQL config. Required for the bundled database. | -!!! warning "Back up the pgdata volume" - The `pgdata` named volume holds your entire database. Use the built-in backup feature (Settings → Backups) for app-level backups, and separately snapshot the Docker volume or use `pg_dump` if you want a database-level backup. +!!! warning "Back up pgdata" + This volume holds your entire database. Use the built-in backup (Settings → Backups) for app snapshots. For database-level backups, snapshot the Docker volume or run `pg_dump` separately. --- ## Port configuration -By default the app binds to port `3000` on your host: +By default the app binds to port `3000`: ```yaml ports: - "${PORT:-3000}:3000" ``` -To use a different port, set `PORT` in `.env`: +Use a different port? Set it in `.env`: ```ini title=".env" PORT=8080 ``` -!!! tip "Behind a reverse proxy?" - If Tracker Tracker sits behind Nginx, Caddy, or Traefik, you don't need to expose port 3000 to the outside world at all. Remove the `ports:` block from `docker-compose.yml` and let the reverse proxy talk to the container over the Docker network directly. +!!! tip "Running behind a reverse proxy" + With Nginx, Caddy, or Traefik? Skip the `ports:` block and let your proxy reach the container via Docker's network. --- @@ -78,107 +78,99 @@ PORT=8080 === "Nginx" - ```nginx title="/etc/nginx/sites-available/tracker-tracker" - server { - listen 80; - server_name trackertracker.example.com; - return 301 https://$host$request_uri; +```nginx title="/etc/nginx/sites-available/tracker-tracker" +server { + listen 80; + server_name trackertracker.example.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name trackertracker.example.com; + + ssl_certificate /etc/letsencrypt/live/trackertracker.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/trackertracker.example.com/privkey.pem; + + # Required for live polling status updates + proxy_buffering off; + proxy_cache off; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } - - server { - listen 443 ssl http2; - server_name trackertracker.example.com; - - ssl_certificate /etc/letsencrypt/live/trackertracker.example.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/trackertracker.example.com/privkey.pem; - - # Required for live polling status updates - proxy_buffering off; - proxy_cache off; - - location / { - proxy_pass http://127.0.0.1:3000; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - } - ``` +} +``` === "Caddy" - ```caddy title="Caddyfile" - trackertracker.example.com { - reverse_proxy localhost:3000 - } - ``` +=== "Traefik" - Caddy handles TLS automatically via Let's Encrypt. That's the whole config. +Add labels to the app service in `docker-compose.yml`: + +```yaml title="docker-compose.yml (app service labels)" +services: + tracker-tracker-app: + image: ghcr.io/jordanlambrecht/tracker-tracker:latest + restart: unless-stopped + container_name: tracker-tracker-app + labels: + - "traefik.enable=true" + - "traefik.http.routers.tracker-tracker.rule=Host(`trackertracker.example.com`)" + - "traefik.http.routers.tracker-tracker.entrypoints=websecure" + - "traefik.http.routers.tracker-tracker.tls.certresolver=letsencrypt" + - "traefik.http.services.tracker-tracker.loadbalancer.server.port=3000" + # Remove the ports: block when using Traefik + networks: + - traefik_proxy + - default +``` -=== "Traefik" +Assumes Traefik is already running with a `websecure` entrypoint and `letsencrypt` resolver. - Add labels to the app service in `docker-compose.yml`: - - ```yaml title="docker-compose.yml (app service labels)" - services: - tracker-tracker-app: - image: ghcr.io/jordanlambrecht/tracker-tracker:latest - restart: unless-stopped - container_name: tracker-tracker-app - labels: - - "traefik.enable=true" - - "traefik.http.routers.tracker-tracker.rule=Host(`trackertracker.example.com`)" - - "traefik.http.routers.tracker-tracker.entrypoints=websecure" - - "traefik.http.routers.tracker-tracker.tls.certresolver=letsencrypt" - - "traefik.http.services.tracker-tracker.loadbalancer.server.port=3000" - # Remove the ports: block when using Traefik - networks: - - traefik_proxy - - default - ``` - - This assumes Traefik is already running with a `websecure` entrypoint and a `letsencrypt` certificate resolver. - -!!! info "Set BASE_URL when using a reverse proxy" - Set `BASE_URL=https://trackertracker.example.com` in `.env`. This enables secure session cookies automatically and ensures backup files and notification links use your public address. +!!! info "Set BASE_URL with a reverse proxy" + Add `BASE_URL=https://trackertracker.example.com` in `.env`. This auto-enables secure cookies and ensures backups and notifications use your public URL. --- ## Health check -The container has a built-in Docker health check: +A built-in health check comes with the container: ```dockerfile HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1 ``` -The `/api/health` endpoint returns `200 OK` when the app is up and the database connection is healthy. The `depends_on` condition in `docker-compose.yml` waits for PostgreSQL to be ready before starting the app container. +The `/api/health` endpoint returns `200 OK` when both the app and database are healthy. Docker Compose waits for Postgres before starting the app. --- -## Updating to a new version +## Updates ```bash docker compose pull && docker compose up -d ``` -The database schema updates automatically on startup. No manual steps required. +The database schema updates automatically. No manual steps needed. -!!! tip "Check the changelog first" - Read the [CHANGELOG](https://github.com/jordanlambrecht/tracker-tracker/blob/main/CHANGELOG.md) before pulling a new image — especially for major version bumps, which may include breaking changes to backup formats or environment variables. +!!! tip "Read the changelog first" + Check the [CHANGELOG](https://github.com/jordanlambrecht/tracker-tracker/blob/main/CHANGELOG.md) before upgrading, especially major versions—they may break backups or environment variables. -To pin to a specific version and update deliberately: +Pin a version by editing `docker-compose.yml`: ```bash -# In docker-compose.yml, change: +# Change: image: ghcr.io/jordanlambrecht/tracker-tracker:latest -# to: +# To: image: ghcr.io/jordanlambrecht/tracker-tracker:2.1.1 ``` -Then run `docker compose pull && docker compose up -d` to apply it. +Then run `docker compose pull && docker compose up -d`. diff --git a/docs/kb/docs/getting-started/first-setup.md b/docs/kb/docs/getting-started/first-setup.md index c52958fb..48ce28c2 100644 --- a/docs/kb/docs/getting-started/first-setup.md +++ b/docs/kb/docs/getting-started/first-setup.md @@ -5,43 +5,24 @@ description: Creating your account and getting oriented after your first login. # First Setup -Once the stack is running, there are a few one-time steps before you can start adding trackers. +Once your stack is up, there are a few one-time things to do before you add trackers. ## Creating your account -Open [http://localhost:3000](http://localhost:3000) in your browser. Tracker Tracker is a single-user app, so on first visit you're redirected to `/setup`. +Go to [http://localhost:3000](http://localhost:3000). Since this is single-user, you'll land on `/setup` on first visit. -Enter a username and password on the **Create an account** form. +Fill in a username and password on the **Create an account** form. Your password does two things: -Your password does two things: +1. **Logs you in**—you'll need it every time you access the app. +2. **Encrypts your API tokens**—all tracker tokens are encrypted at rest using your password. Lose the password? You can't recover the tokens, but you can re-enter them manually. -1. **Logs you in.** You'll type it every time you access the app. -2. **Protects your stored API tokens.** All tracker API tokens are encrypted at rest using a key derived from your password. If you lose your password, those tokens can't be recovered — but you can re-enter them manually. +!!! warning "Use a strong password" + There's no recovery if you forget it—you'll have to reset the database. Use a password manager. -!!! warning "Choose strong credentials" - There is no recovery mechanism. If you forget your password, you'll need to reset the database and start fresh. Keep it in a password manager. +!!! info "Your password never leaves your machine" + It's hashed on the server before storage. The raw password is never written to disk. -!!! info "Your password stays on your machine" - It's hashed on the server before being stored. The raw password is never saved anywhere. - -Click **Create Account**. You'll be logged in and land on the dashboard. - ---- - -## First look at the dashboard - -The dashboard is mostly empty until you add your first tracker — that's expected. - -The left sidebar has three sections: - -- **Tracker list** — each tracker you add shows up here with a pulse indicator showing its current health. -- **Fleet stats** — combined upload, download, and ratio across all your active trackers. -- **Navigation** — links to Settings and the download client panel (if you've configured one). - -The main area shows the tracker overview grid, charts, and leaderboard. Charts fill in automatically as polling history builds up over time. - -!!! tip "Polling starts right away" - The moment you add a tracker, the app polls it and records a snapshot. Stats start charting from that first poll. The default polling interval is 60 minutes — you can change it in **Settings → General**. +Click **Create Account** and you're in. The dashboard starts empty—that's normal. --- diff --git a/docs/kb/docs/getting-started/installation.md b/docs/kb/docs/getting-started/installation.md index 072783e7..a3073705 100644 --- a/docs/kb/docs/getting-started/installation.md +++ b/docs/kb/docs/getting-started/installation.md @@ -5,17 +5,17 @@ description: How to install Tracker Tracker using Docker Compose or Docker Run. # Installation -Tracker Tracker runs as a Docker image. The easiest way to get it running is with Docker Compose. +Tracker Tracker runs in Docker. Use Docker Compose to get it up and running. ## Prerequisites - [Docker](https://docs.docker.com/get-docker/) 24+ - [Docker Compose v2](https://docs.docker.com/compose/install/) (the `docker compose` plugin, not the legacy `docker-compose` binary) -Nothing else needs to be installed on your host. +That's it — nothing else to install. !!! info "Architecture support" - The image supports **linux/amd64** and **linux/arm64**. It runs on x86-64 servers and ARM machines like Raspberry Pi 4/5 or Apple Silicon in Linux VMs — Docker picks the right version automatically. + We build for **linux/amd64** and **linux/arm64**, so you're covered on x86-64 servers, Raspberry Pi 4/5, and Apple Silicon in Linux VMs. Docker grabs the right one automatically. --- @@ -32,18 +32,18 @@ curl -LO https://raw.githubusercontent.com/jordanlambrecht/tracker-tracker/main/ curl -L https://raw.githubusercontent.com/jordanlambrecht/tracker-tracker/main/.env.example -o .env ``` -Or create the files manually using the contents shown in the next steps. +Or create them manually by copying the examples below. ## Step 3 — Generate secrets -You need two secrets before starting the stack. Run each command and paste the output into `.env`: +Generate two secrets and paste them into `.env`: ```bash openssl rand -base64 24 # → POSTGRES_PASSWORD openssl rand -base64 48 # → SESSION_SECRET ``` -Open `.env` and fill in the values: +Then open `.env` and add them: ```ini title=".env" SESSION_SECRET= @@ -52,64 +52,62 @@ POSTGRES_USER=postgres TZ=America/Chicago ``` -!!! warning "Don't reuse these values" - `SESSION_SECRET` protects your session cookies. `POSTGRES_PASSWORD` protects your database. Generate fresh values — never copy the placeholder text from `.env.example`. +!!! warning "Fresh secrets every time" + Generate new ones each time. `SESSION_SECRET` protects your cookies, `POSTGRES_PASSWORD` protects your database. Don't copy the `.env.example` placeholder. ## Step 4 — Start the stack === "Docker Compose (recommended)" - ```bash - docker compose up -d - ``` - - The app waits for the database to be ready before starting, then sets up the database schema automatically. First boot takes about 15-20 seconds. +```bash +docker compose up -d +``` - Watch the startup logs: +The app waits for the database to be ready, then sets up the schema automatically. First boot takes about 15-20 seconds. Peek at the logs if you want to watch: - ```bash - docker compose logs -f tracker-tracker-app - ``` +```bash +docker compose logs -f tracker-tracker-app +``` === "Docker Run (standalone)" - If you already have PostgreSQL running somewhere else, you can start just the app container: +Running Postgres elsewhere? Start the app container: - ```bash - docker run -d \ - --name tracker-tracker-app \ - --restart unless-stopped \ - -p 3000:3000 \ - -v ./data:/data \ - -e DATABASE_URL="postgresql://postgres:yourpassword@your-postgres-host:5432/tracker_tracker" \ - -e SESSION_SECRET="your-session-secret-minimum-32-chars" \ - -e TZ="America/Chicago" \ - ghcr.io/jordanlambrecht/tracker-tracker:latest - ``` +```bash +docker run -d \ + --name tracker-tracker-app \ + --restart unless-stopped \ + -p 3000:3000 \ + -v ./data:/data \ + -e DATABASE_URL="postgresql://postgres:yourpassword@your-postgres-host:5432/tracker_tracker" \ + -e SESSION_SECRET="your-session-secret-minimum-32-chars" \ + -e TZ="America/Chicago" \ + ghcr.io/jordanlambrecht/tracker-tracker:latest +``` - Point `DATABASE_URL` at your existing Postgres instance. The app creates its own schema on startup if it doesn't exist yet. +Point `DATABASE_URL` to your Postgres instance. The app will create its schema on startup if needed. -## Step 5 — Verify it is running +## Step 5 — Verify it's running ```bash docker compose ps ``` -Both containers should show as running: +Both containers should be running: -``` +```md NAME STATUS tracker-tracker-app running tracker-tracker-db running (healthy) ``` -You can also hit the health endpoint: +Or test the health endpoint: ```bash curl -s http://localhost:3000/api/health ``` -A `200 OK` response means the app is up and connected to the database. +A `200 OK` means the app is up and talking to the database. ## Step 6 — Open the app @@ -121,14 +119,14 @@ On first visit you'll be redirected to `/setup` to create your account. See [Fir ## Image registries -The same image is on two registries — either works: +Both registries have the same image — use whichever you prefer: | Registry | Image | | ------------------------- | ------------------------------------------------ | | GitHub Container Registry | `ghcr.io/jordanlambrecht/tracker-tracker:latest` | | Docker Hub | `jordyjordy/tracker-tracker:latest` | -Pin to a specific version if you want predictable updates: +Pin a specific version if you want predictable updates: ```bash ghcr.io/jordanlambrecht/tracker-tracker:2.1.1 @@ -140,7 +138,7 @@ Check the [CHANGELOG](https://github.com/jordanlambrecht/tracker-tracker/blob/ma ## Using an external database -If you already run PostgreSQL, remove the `tracker-tracker-db` service and the `depends_on` block from `docker-compose.yml`, then set `DATABASE_URL` directly instead of the `POSTGRES_*` variables: +Using an external Postgres instance? Remove the `tracker-tracker-db` service and `depends_on` from `docker-compose.yml`, then set `DATABASE_URL`: ```ini title=".env" DATABASE_URL=postgresql://myuser:mypassword@192.168.1.10:5432/tracker_tracker @@ -149,4 +147,4 @@ TZ=America/Chicago ``` !!! tip - You only need to create the database itself beforehand. The app handles the rest on first startup — no manual SQL required. + Create the database name ahead of time. The app handles the rest on startup—no SQL scripts needed. diff --git a/docs/kb/docs/index.md b/docs/kb/docs/index.md index 6734c0b2..e173fc9e 100644 --- a/docs/kb/docs/index.md +++ b/docs/kb/docs/index.md @@ -4,17 +4,20 @@ Self-hosted dashboard for monitoring your private tracker stats over time. ## What is Tracker Tracker? -Tracker Tracker connects to your private tracker accounts via their APIs and records your stats (upload, download, ratio, buffer, seed count, etc.) over time. It supports UNIT3D, Gazelle, and GazelleGames platforms. +Tracker Tracker connects to your private tracker accounts and records your stats (upload, download, ratio, buffer, seed count, bonus points, etc.) over time. It polls tracker APIs on a schedule, stores the results, and charts everything so you see trends across days, weeks, and months. + +Supports UNIT3D, Gazelle, GGn, Nebulance, MAM, and AvistaZ networks. Over 40 trackers come pre-configured. ## Quick Links -- **Getting Started** Start with the [Installation Guide](getting-started/installation.md) -- **Adding your first tracker** See [Adding a Tracker](trackers/adding-a-tracker.md) -- **Something not working?** Check [Troubleshooting](troubleshooting/common-errors.md) +- [Installation guide](getting-started/installation.md) — get running in a few minutes with Docker +- [Adding a tracker](trackers/adding-a-tracker.md) — connect your first tracker +- [Troubleshooting](troubleshooting.md) — something not working? ## Features -- **All your trackers in one place** — Upload, download, ratio, buffer, rank, and more across every site you're on -- **Historical charts** — See how your stats change over days, weeks, and months -- **qBittorrent dashboard** — Your torrents, seeds, and transfer speeds right next to your tracker stats -- **Discord alerts** — Get notified when your ratio drops, a tracker goes down, or you hit a rank milestone +- **All your trackers in one place** — upload, download, ratio, buffer, rank, bonus points across all your sites +- **Historical charts**—watch your stats evolve over days, weeks, and months +- **qBittorrent integration** — per-tracker torrent details, seeding counts, cross-seed stats, and live speeds +- **Tag group visualizations** — turn qBittorrent tags into donut charts, bar charts, and treemaps +- **Discord notifications** — alerts when your ratio drops, a tracker goes down, you hit a buffer milestone, or your rank shifts diff --git a/docs/kb/docs/reference/platform-differences.md b/docs/kb/docs/reference/platform-differences.md index 3a7b25f2..833abba9 100644 --- a/docs/kb/docs/reference/platform-differences.md +++ b/docs/kb/docs/reference/platform-differences.md @@ -5,74 +5,75 @@ description: Stat availability and behavior differences across UNIT3D, Gazelle, # Platform Differences -Tracker Tracker supports multiple tracker platforms: **UNIT3D**, **Gazelle**, **GGn**, **Nebulance**, and **MAM** (MyAnonaMouse). Each platform exposes different stats and uses a different authentication method. This page tells you what to expect when adding a tracker of each type. +Tracker Tracker works with **UNIT3D**, **Gazelle**, **GGn**, **Nebulance**, **MAM** (MyAnonaMouse), and **AvistaZ**. Each has different stats and authentication methods. --- ## Authentication -How you authenticate with each platform's API depends on the platform type. In all cases, Tracker Tracker handles this for you — you just paste your API token when adding the tracker. +Tracker Tracker handles auth behind the scenes — just paste your token when adding a tracker. -| Platform | How the token is sent | -| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **UNIT3D** | Appended as a query parameter on every request (`?api_token=TOKEN`). HTTPS is required to prevent the token from being exposed in server logs. | -| **Gazelle** | Sent as an HTTP `Authorization` header (`Authorization: token TOKEN`). Some Gazelle forks accept the token without the `token ` prefix — Tracker Tracker handles both. | -| **GGn** | Appended as a query parameter (`?key=TOKEN`), similar to UNIT3D but using a different parameter name. | -| **MAM** | Sent as a `Cookie: mam_id=VALUE` header. Uses a session cookie from MAM's Security Settings page, not a traditional API key. Session cookies rotate monthly. | +| Platform | How the token is sent | +|-------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **UNIT3D** | Appended as a query parameter on every request (`?api_token=TOKEN`). HTTPS is required to prevent the token from being exposed in server logs. | +| **Gazelle** | Sent as an HTTP `Authorization` header (`Authorization: token TOKEN`). Some Gazelle forks accept the token without the `token` prefix — Tracker Tracker handles both. | +| **GGn** | Appended as a query parameter (`?key=TOKEN`), similar to UNIT3D but using a different parameter name. | +| **MAM** | Sent as a `Cookie: mam_id=VALUE` header. Uses a session cookie from MAM's Security Settings page, not a traditional API key. Session cookies rotate monthly. | +| **AvistaZ** | Browser cookies (`cf_clearance` + session cookies) sent as a `Cookie` header, with the matching `User-Agent`. Uses HTML scraping instead of a JSON API. Cookies expire when Cloudflare clearance lapses. | --- ## Stat Availability -The table below shows which stats Tracker Tracker can collect from each platform. A note in the cell means the stat is available but with caveats. - -| Stat | UNIT3D | Gazelle | GGn | MAM | -| ------------------------- | ------------------------ | -------------------------------- | -------------------------------- | ----------------------------------- | -| Upload / Download / Ratio | Yes | Yes | Yes | Yes (raw bytes + formatted strings) | -| Buffer | Yes (tracker-calculated) | Approximate (calculated locally) | Approximate (calculated locally) | Approximate (calculated locally) | -| Seeding count | Yes | Some forks only | Paranoia-dependent | Yes (sum of snatch_summary seeding) | -| Leeching count | Yes | Some forks only | Paranoia-dependent | Yes | -| Seedbonus / Bonus Points | Yes | Yes (most forks) | Yes (called "gold") | Yes | -| Required Ratio | No | Yes | Yes | No | -| Hit & Runs | Yes | No | Partial (may be null) | Yes (inactive unsatisfied HnRs) | -| Freeleech Tokens | No | Some forks only | No | Yes (called "wedges") | -| Warned status | No | Some sites only | Yes | No | -| Class / Rank | Yes | Yes | Yes | Yes | -| Join date | No | Some sites only | Yes | No | -| Last access date | No | Some sites only | Yes | No | -| Share Score | No | No | Yes | No | -| Donor status | No | Some sites only | Yes | No (VIP status + expiry available) | -| Snatched count | No | Some sites only | Yes | Yes (via snatch_summary categories) | -| Community / rank data | No | Some sites only | Yes | No | -| Upload / download buffs | No | No | Yes | No | -| Avatar | No | Some sites only | No | No | +Notes in cells mean the stat exists but with limitations. + +| Stat | UNIT3D | Gazelle | GGn | MAM | AvistaZ | +|---------------------------|--------------------------|----------------------------------|----------------------------------|-------------------------------------|-----------------------------------| +| Upload / Download / Ratio | Yes | Yes | Yes | Yes (raw bytes + formatted strings) | Yes (HTML scraped, decimal units) | +| Buffer | Yes (tracker-calculated) | Approximate (calculated locally) | Approximate (calculated locally) | Approximate (calculated locally) | Yes (tracker-calculated) | +| Seeding count | Yes | Some forks only | Paranoia-dependent | Yes (sum of snatch_summary seeding) | Yes | +| Leeching count | Yes | Some forks only | Paranoia-dependent | Yes | Yes | +| Seedbonus / Bonus Points | Yes | Yes (most forks) | Yes (called "gold") | Yes | Yes | +| Required Ratio | No | Yes | Yes | No | No | +| Hit & Runs | Yes | No | Partial (may be null) | Yes (inactive unsatisfied HnRs) | Yes | +| Freeleech Tokens | No | Some forks only | No | Yes (called "wedges") | No | +| Warned status | No | Some sites only | Yes | No | No | +| Class / Rank | Yes | Yes | Yes | Yes | Yes | +| Join date | No | Some sites only | Yes | No | Yes | +| Last access date | No | Some sites only | Yes | No | Yes | +| Share Score | No | No | Yes | No | No | +| Donor status | No | Some sites only | Yes | No (VIP status + expiry available) | Yes | +| Snatched count | No | Some sites only | Yes | Yes (via snatch_summary categories) | No | +| Community / rank data | No | Some sites only | Yes | No | No | +| Upload / download buffs | No | No | Yes | No | No | +| Avatar | No | Some sites only | No | No | No | ### Notes on specific cells -**Buffer (Gazelle and GGn):** The approximate buffer shown is your uploaded total minus your downloaded total. This tells you whether you are in surplus or deficit, but it does not account for your required ratio the way UNIT3D's server-calculated value does. +**Buffer (Gazelle and GGn):** Approximate buffer = upload minus download. It shows surplus or deficit, but doesn't account for required ratio like UNIT3D's server-side calculation. -**Seeding / Leeching on GGn:** GGn's paranoia setting controls what information is visible on public profiles. The API responses for your own account are not affected — Tracker Tracker always polls as you, so seeding and leeching counts are available unless GGn changes how it reports them. +**Seeding / Leeching on GGn:** GGn's paranoia setting controls public profiles, but not your own account. Since we poll as you, seeding and leeching counts work unless GGn changes them. -**Gazelle "some forks only":** The Gazelle codebase has been forked many times. Fields like seeding count, freeleech tokens, and extended profile data are not present on every site. See [Gazelle Fork Variations](#gazelle-fork-variations) below. +**Gazelle "some forks only":** The Gazelle codebase has many forks with varying field support. See [Gazelle Fork Variations](#gazelle-fork-variations). -**Warned / Join date / Last access on Gazelle:** These require an extended profile call that not all Gazelle sites support. Tracker Tracker fetches it where available. +**Warned / Join date / Last access on Gazelle:** These need an extended profile call that not all sites support. We fetch them when available. --- ## GGn Polling -GGn requires two API calls per poll cycle instead of one. The first call fetches your username and user ID. The second call fetches all of your stats using that ID. +GGn requires two API calls per poll: one to get your username and user ID, then one for stats. -After the first successful poll, Tracker Tracker caches your GGn user ID. Subsequent polls go directly to the stats call, skipping the first step. +We cache your user ID after the first poll, so later polls skip straight to stats. --- ## Gazelle Fork Variations -The Gazelle codebase has been forked many times, and field names are not consistent across sites. Here is what Tracker Tracker knows about the sites it supports: +Field names vary across Gazelle forks. Here's what we track: | Site | Seedbonus field | Freeleech Tokens | Seeding count in basic response | -| -------------------- | --------------- | ---------------- | ------------------------------- | +|----------------------|-----------------|------------------|---------------------------------| | Redacted (RED) | `bonusPoints` | Sometimes | No | | Orpheus (OPS) | `bonusPoints` | Sometimes | No | | BroadcasTheNet (BTN) | Varies | No | No | @@ -85,17 +86,17 @@ GGn is a Gazelle fork but uses its own dedicated adapter due to significant API ## Platform-Specific Extras -Beyond the core stats, each platform surfaces additional information that Tracker Tracker stores and displays where relevant. +Each platform offers extras beyond core stats that we store and display. ### Gazelle (extended profile) -When the extended profile call is available, Tracker Tracker also collects: +When available, we also fetch: - Donor status - Account enabled / paranoia level - Community rank positions (upload, download, requests, posts, overall) - Community activity totals (posts, comments, snatched, bounty, invites) -- Unread messages and notification counts +- Unread message and notification counts - Gift tokens and merit tokens (some forks) ### GGn @@ -112,7 +113,7 @@ GGn's full user profile includes: ### MAM (MyAnonaMouse) -MAM uses a single `/jsonLoad.php` endpoint with `?snatch_summary` to return everything in one call. MAM-specific extras include: +We use a single `/jsonLoad.php` endpoint with `?snatch_summary` that returns everything at once. Extras include: - VIP status and expiry date - Connectivity status (connectable/offline) @@ -122,4 +123,22 @@ MAM uses a single `/jsonLoad.php` endpoint with `?snatch_summary` to return ever - Recently deleted torrent count - FL Wedge count (freeleech tokens) -**Authentication note:** MAM uses a `mam_id` session cookie rather than a traditional API key. The cookie is obtained from MAM's Security Settings page (User Preferences → Security). Session cookies rotate monthly, so users will need to update their token periodically. +**Authentication note:** MAM uses a `mam_id` session cookie, not an API key. Get it from **User Preferences → Security**. Cookies rotate monthly, so refresh periodically. + +### AvistaZ Network + +AvistaZ uses HTML scraping instead of a JSON API. The profile page provides: + +- Donor status and VIP expiry date +- Invite count +- Account permission flags (can download, can upload) +- Total upload and download torrent counts +- Reseed request count +- Two-factor authentication status +- Bonus point earning rate per hour (from the bonus page, enrichment call) + +**Authentication note:** AvistaZ uses browser cookies, not an API key. Paste your cookies (include Cloudflare `cf_clearance`), and we capture the User-Agent automatically. Refresh when Cloudflare clearance expires. + +**Sites in the network:** AvistaZ, AnimeZ, PrivateHD, CinemaZ, ExoticaZ — all share the same platform and HTML structure. + +**Minimum rank:** Newbie accounts have restricted profiles. You need **Member** rank or above (5 GB upload, ratio ≥ 1.0, 7+ days old) to use the adapter. diff --git a/docs/kb/docs/reference/settings.md b/docs/kb/docs/reference/settings.md index ad296585..e28612b4 100644 --- a/docs/kb/docs/reference/settings.md +++ b/docs/kb/docs/reference/settings.md @@ -5,89 +5,83 @@ description: Complete reference for every configurable setting in Tracker Tracke # Settings Reference -Every setting available in the Tracker Tracker settings interface is listed here, grouped by tab. +Every setting, grouped by tab. --- ## General -| Setting | Default | What it does | -| --------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Tracker Poll Interval** | 60 minutes | How often Tracker Tracker checks all of your active trackers. The minimum is 15 minutes; the maximum is 1440 minutes (24 hours). All trackers are polled together in each cycle — you cannot set a different interval per tracker. | -| **Snapshot Retention** | Unlimited | How many days of polling history to keep. Leave blank to keep data forever. Set a number (between 7 and 3650) to automatically delete old snapshots. Pruning runs at the end of each poll cycle. | -| **Display Username** | Enabled | The name shown in the Tracker Tracker interface. This is your local label — it has nothing to do with your usernames on individual trackers. | -| **Store Tracker Usernames** | Enabled | When enabled, your username on each tracker is saved with each snapshot and shown in the UI. When disabled, usernames are masked before being saved and redacted before being shown — even if you had them stored previously. Turning this off does not delete usernames that were already saved. | +Controls polling frequency and data retention. All trackers poll together at the configured interval (no per-tracker settings). **Display Username** is your local app label. When **Store Tracker Usernames** is off, they're masked in the UI but existing data isn't deleted. + +| Setting | Default | Notes | +|------------------------|-----------|-----------------------------------------------------------------------| +| **Snapshot Retention** | Unlimited | Set 7–3650 days to auto-delete old snapshots; leave blank for forever | --- ## Security -| Setting | Default | What it does | -| ------------------------------------ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Session Timeout** | None | How long you can be idle before being automatically logged out. Leave blank to stay logged in until you log out manually. | -| **Two-Factor Authentication (TOTP)** | Disabled | Adds a second login step using a time-based one-time code from any authenticator app (Google Authenticator, Aegis, etc.). When you enable TOTP, a set of one-time backup codes is also generated — save these somewhere safe. | -| **Lockout** | Enabled | When enabled, too many failed login attempts will temporarily lock the account. | -| **Lockout Threshold** | 5 attempts | How many consecutive failed login attempts (password or TOTP) trigger a lockout. | -| **Lockout Duration** | 15 minutes | How long the account stays locked after the threshold is hit. The lock clears automatically when the time is up. | +Manages login security, lockouts, and two-factor authentication. **Session Timeout** defaults to infinite (never logged out by idle). **Lockout** protects against brute force — it auto-clears after the duration expires. + +| Setting | Default | Notes | +|---------------------|---------|--------------------------------------------------------------------------------| +| **Session Timeout** | None | Leave blank to stay logged in forever; otherwise set minutes until auto-logout | + +### Two-Factor Authentication + +Enable TOTP (time-based one-time password) with any standard authenticator app (Google Authenticator, Aegis, 1Password, Bitwarden, Authy, etc). + +**Setup:** + +1. Go to **Settings → Security** +2. Click **Enable Two-Factor Authentication** +3. Scan the QR code with your authenticator app +4. Enter the 6-digit code from your app to confirm +5. **Save your 8 backup codes** in a password manager or secure location +6. Click **Confirm** + +On login, enter either the current 6-digit code from your app or one of your backup codes. Each backup code works once, then expires. + +**Disabling:** Go to **Settings → Security** and click **Disable Two-Factor Authentication**. You'll need to enter your password and a valid TOTP or backup code. + +!!! warning "No authenticator app and no backup codes means no login — there is no account recovery." + + Store backup codes immediately and keep them safe. --- ## Proxy -These settings configure a single outbound proxy for tracker requests. Individual trackers can opt in to use this proxy via their own settings. +Configures a single outbound proxy for tracker requests. Individual trackers can opt in per their own settings. -| Setting | Default | What it does | -| ------------------ | -------- | -------------------------------------------------------------------------------------------------------------- | -| **Proxy Enabled** | Disabled | Master switch. When off, all tracker requests go out directly, even if individual trackers have proxy enabled. | -| **Proxy Type** | `socks5` | The proxy protocol. Options: `socks5`, `http`, `https`. | -| **Proxy Host** | — | Hostname or IP address of the proxy server. | -| **Proxy Port** | 1080 | Port the proxy listens on. | -| **Proxy Username** | — | Username for proxy authentication. Leave blank if your proxy does not require a login. | -| **Proxy Password** | — | Password for proxy authentication. Stored encrypted at rest. | +All settings are self-explanatory; **Proxy Password** is stored encrypted. --- ## Notifications -Notification targets are configured individually. Each target is an independent delivery destination (a Discord webhook, a Gotify server, etc.). The settings below apply per target. - -| Setting | Default | What it does | -| ------------------------------ | ------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| **Target Name** | — | A label for this target, e.g. "Discord #alerts". | -| **Target Type** | — | The delivery platform: `discord`, `gotify`, `telegram`, `slack`, or `email`. Each type has its own credential fields. | -| **Enabled** | Enabled | Turn a target off temporarily without deleting it. | -| **Include Tracker Name** | Enabled | When on, notification messages include the name of the tracker that triggered them. Turn off for extra privacy. | -| **Scope** | All trackers | Restrict this target to specific trackers. Leave blank to receive notifications from any tracker. | -| **Notify on Ratio Drop** | Disabled | Fires when a tracker's ratio drops by more than the configured delta. | -| **Notify on Hit & Run** | Disabled | Fires when a new hit-and-run is detected. | -| **Notify on Tracker Down** | Disabled | Fires when a tracker fails to respond during a poll cycle. | -| **Notify on Buffer Milestone** | Disabled | Fires when your uploaded buffer crosses a configured size threshold. | -| **Notify on Warning** | Disabled | Fires when your account enters warned status on a tracker. | -| **Notify on Ratio Danger** | Disabled | Fires when your ratio falls into a critical zone (typically below the tracker's required ratio). | -| **Notify on Zero Seeding** | Disabled | Fires when your seeding count drops to zero. | -| **Notify on Rank Change** | Disabled | Fires when your class or rank changes on a tracker. | -| **Notify on Anniversary** | Disabled | Fires at membership milestones: 1 month, 6 months, then each year after. | -| **Ratio Drop Delta** | Application default | How large a ratio drop must be to trigger a notification. Overrides the application default for this target only. | -| **Buffer Milestone Threshold** | Application default | The buffer size (in bytes) that triggers a buffer milestone notification. Overrides the application default for this target only. | +Each target is an independent delivery destination (Discord, Gotify, Telegram, Slack, email). Configure what events trigger notifications per target and whether to include tracker names. + +| Setting | Default | Notes | +|--------------------------------|---------------------|-----------------------------------------------------------------------------| +| **Ratio Drop Delta** | Application default | Override the app-wide ratio drop threshold for this target only | +| **Buffer Milestone Threshold** | Application default | Override the app-wide buffer milestone size (in bytes) for this target only | --- ## Backups -| Setting | Default | What it does | -| -------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Scheduled Backups** | Disabled | Turns on automatic backups. When disabled, you can still export backups manually at any time. | -| **Backup Frequency** | Daily | How often scheduled backups run: `daily`, `weekly`, or `monthly`. Scheduled backups always run at 03:00 server time. | -| **Backup Retention Count** | 14 | Maximum number of scheduled backup files to keep on disk. When this limit is exceeded, the oldest file is deleted. This does not affect manual exports, which are downloaded to your browser and not stored on the server. | -| **Backup Encryption** | Disabled | When enabled, backup files are encrypted with an additional password layer and saved with the `.ttbak` extension. | -| **Backup Password** | — | The password used to encrypt and decrypt backup files. Only relevant when backup encryption is on. | -| **Backup Storage Path** | — | The folder on the server where scheduled backup files are written. The path must be writable by the application process. | +Controls automatic backup scheduling, encryption, and retention. Backups always run at 03:00 server time. + +| Setting | Default | Notes | +|----------------------------|---------|-----------------------------------------------------------------| +| **Backup Retention Count** | 14 | Number of backups to keep (1–365); oldest deletes when exceeded | --- ## Notes -- **Encrypted storage** — Your proxy password, backup password, API tokens, and download client credentials are all encrypted at rest. Changing your master password re-encrypts everything automatically. -- **Restoring a backup** — Your master password and its associated encryption salt are never included in a backup and are never overwritten when you restore one. Your session stays valid after a restore. -- **Lockout and restores** — Restoring a backup always clears any active lockout, regardless of what was in the backup file. -- **Secure cookies** — Session cookies are marked `Secure` (HTTPS-only) when `BASE_URL` starts with `https://` or `SECURE_COOKIES=true` is set. If you access the app over plain HTTP, cookies are not marked `Secure` and this is expected. +- **Encrypted storage** — Proxy password, backup password, API tokens, and credentials are encrypted at rest. Changing your master password re-encrypts everything. +- **Restoring a backup** — Your master password and encryption salt are never backed up and never overwritten on restore. Sessions stay valid. +- **Lockout and restores** — Backup restore always clears active lockout. +- **Secure cookies** — Session cookies are marked `Secure` when `BASE_URL` starts with `https://` or `SECURE_COOKIES=true`. Plain HTTP doesn't mark them secure. diff --git a/docs/kb/docs/reference/stats-explained.md b/docs/kb/docs/reference/stats-explained.md index fbac0423..b8604097 100644 --- a/docs/kb/docs/reference/stats-explained.md +++ b/docs/kb/docs/reference/stats-explained.md @@ -5,7 +5,7 @@ description: Plain-language explanations of every stat Tracker Tracker collects # Stats Explained -At each poll interval, Tracker Tracker fetches a snapshot of your stats from each tracker. This page explains what each stat means and which platforms support it. +We save a snapshot on every poll. Here's what each stat means. --- @@ -13,13 +13,13 @@ At each poll interval, Tracker Tracker fetches a snapshot of your stats from eac ### Uploaded -The total amount of data you have contributed to the swarm across your entire account history. This number only ever goes up. Upload credit is the primary currency on private trackers — it is the basis for your ratio and your buffer. +Total data you've contributed since signup. It only goes up. Upload is the main currency on private trackers — it drives ratio and buffer. ### Downloaded -The total amount of data your client has received from the swarm. Like uploaded, this is a lifetime counter. A few trackers run occasional "download forgiveness" events that can reduce this number, but that is rare. +Total data you've received since signup. Like upload, it's a lifetime counter. Some trackers run rare "download forgiveness" events that reduce it. -A large download total is not a problem on its own. What matters is your ratio relative to it. +A large download isn't a problem — your ratio relative to it is what matters. --- @@ -27,32 +27,32 @@ A large download total is not a problem on its own. What matters is your ratio r ### Ratio -Your ratio is your uploaded total divided by your downloaded total. If you have uploaded 400 GiB and downloaded 200 GiB, your ratio is 2.00. +Upload divided by download. Upload 400 GiB and download 200 GiB, you get 2.00. -Most trackers require a minimum ratio. Falling below it can restrict your ability to download or result in a warning. +Most trackers enforce a minimum. Fall below it and you lose download access or get warned. -A ratio of `∞` means you have uploaded data but downloaded nothing. This typically happens when you seed your own uploads. +A ratio of `∞` means you uploaded but never downloaded — i.e., you seeded your own uploads. ### Required Ratio -The minimum ratio your account must maintain, set by the tracker. A required ratio of `0.60` means you must have uploaded at least 60% of what you have downloaded. +The minimum ratio you must maintain. A requirement of `0.60` means you must upload at least 60% of your download. -Some trackers have a grace period for new members — no requirement for the first few gigabytes, then the requirement kicks in. +Some trackers give new members grace — no requirement for the first few GiB. -**Availability:** Not available on UNIT3D. Available on Gazelle and GGn. +**Availability:** Not on UNIT3D. Available on Gazelle and GGn. --- ## Buffer -Your buffer is the gap between where your ratio is now and where it would need to drop before you hit the tracker's required minimum. A larger buffer means you can grab more content before ratio becomes a concern. +How far below the required minimum you could fall. A bigger buffer means you can grab more without hitting your ratio floor. -How buffer is calculated depends on the platform: +Calculation varies: -- **UNIT3D** — The tracker calculates buffer server-side and returns it directly. This is the most accurate value. -- **Gazelle and GGn** — Tracker Tracker calculates buffer as uploaded minus downloaded (your net upload surplus). This is a simplified figure; it does not factor in your required ratio. +- **UNIT3D** — Server-side calculation. Most accurate. +- **Gazelle and GGn** — Upload minus download (net surplus). Doesn't account for your required ratio. -A negative buffer means you are already in deficit — your downloaded total exceeds your uploaded total. +A negative buffer means you're already in deficit. --- @@ -60,41 +60,41 @@ A negative buffer means you are already in deficit — your downloaded total exc ### Seeding Count -The number of torrents your client is currently seeding (uploading to others). Seeding is how you build upload credit over time. Some trackers enforce minimum seeding requirements — for example, requiring you to seed each download for a set amount of time. +How many torrents you're currently uploading. Seeding builds upload credit. Some trackers require minimum seeding — i.e., seed each download for a set period. -**Availability:** Available on UNIT3D. Available on some Gazelle forks. On GGn, this is tied to your account's paranoia setting — if you have high paranoia, this field may not be visible even to the API. +**Availability:** On UNIT3D. On some Gazelle forks. On GGn, it depends on your paranoia setting — high paranoia hides it from the API. ### Leeching Count -The number of torrents your client is currently downloading. This should normally be low or zero when you are not actively grabbing anything. +How many torrents you're currently downloading. Usually zero when you're not grabbing. -**Availability:** Same as seeding count. +**Availability:** Same as seeding. --- ## Seedbonus / Gold -A site currency earned by continuously seeding torrents. The earn rate usually depends on how long you have been seeding, how large the torrent is, and how many other seeders are active. You can typically spend this currency on freeleech tokens, upload credit, or store items. +A site currency earned by seeding. Earn rate depends on seed time, torrent size, and how many other seeders are active. Spend it on freeleech tokens, upload credit, or store items. **Platform notes:** -- **UNIT3D** — Called "seedbonus" or "Bonus Points" depending on the site. -- **Gazelle** — Called "Bonus Points" or "BP" on most forks. -- **GGn** — Uses a distinct `gold` currency. Gold is earned through seeding and spent within GGn's own economy (achievements, buffs, store items). +- **UNIT3D** — Called "seedbonus" or "Bonus Points". +- **Gazelle** — Called "Bonus Points" or "BP". +- **GGn** — Uses a separate `gold` currency, spent in GGn's economy (achievements, buffs, store). --- ## Hit & Runs -A hit-and-run happens when you download a torrent and disconnect from the swarm before seeding it back to a 1:1 ratio, or before meeting a minimum seed time requirement. Trackers track H&Rs to identify members who consume content without contributing. +A hit-and-run is downloading and bailing before seeding to 1:1 or meeting minimum seed time. Trackers penalize members who consume without contributing. -Accumulating H&Rs typically leads to warnings or download restrictions. +Too many H&Rs lead to warnings or download loss. **Availability:** -- **UNIT3D** — Available. Shows 0 when you have no H&Rs. -- **Gazelle** — Not available. Tracker Tracker shows nothing for Gazelle sites. -- **GGn** — Available, but may be `null` if the tracker does not surface this data for your account. +- **UNIT3D** — Available. Shows 0 when you have none. +- **Gazelle** — Not available. +- **GGn** — Available, but may be `null` if the tracker doesn't expose it. --- @@ -102,38 +102,38 @@ Accumulating H&Rs typically leads to warnings or download restrictions. ### Warned -Whether your account is currently under a warning from tracker staff. Warnings are issued for rule violations such as hit-and-runs, ratio problems, or conduct issues. A warned account may have restricted download access. +Whether you're under a staff warning. Warnings come from H&Rs, ratio drops, or conduct. Warned accounts may lose download. **Availability:** -- **UNIT3D** — Not available via the API. -- **Gazelle** — Available on sites that support the extended user profile call. Without it, Tracker Tracker defaults to showing no warning. -- **GGn** — Available directly from your user profile. +- **UNIT3D** — Not exposed via API. +- **Gazelle** — Available on sites that support the extended user profile call. Otherwise defaults to no warning. +- **GGn** — Available from your profile. ### Username -Your account username on the tracker. Whether this is saved and displayed depends on the "Store Tracker Usernames" setting. See [Settings Reference](./settings.md) for details. +Your tracker username. Visibility depends on the "Store Tracker Usernames" setting. See [Settings Reference](./settings.md). ### Class / Rank -Your membership class on the tracker — things like "Member", "Power User", "Elite", "VIP", or "Donor". Trackers promote users based on combinations of ratio, total upload, seedbonus, account age, and community contributions. +Your member class — "Member", "Power User", "Elite", "VIP", "Donor", etc. Promotion is based on ratio, upload, seedbonus, age, and contributions. -Tracker Tracker records whatever class string the tracker returns. It does not compare or rank classes across different trackers. +We record what the tracker returns. No cross-tracker comparison. --- ## Freeleech Tokens -Tokens you can apply to individual torrents so that downloading them does not count against your downloaded total. Using a freeleech token lets you grab content without hurting your ratio. +Apply these to torrents so downloads don't count against ratio. Grab content without penalty. **Availability:** - **UNIT3D** — Not available. -- **Gazelle** — Available on some forks. Not all Gazelle-based sites expose this field. -- **GGn** — Not available. GGn uses its gold economy instead of separate freeleech tokens. +- **Gazelle** — Available on some forks. Not all expose it. +- **GGn** — Not available. Uses gold economy instead. --- ## Share Score -A composite score specific to GGn that combines upload activity, seeding time, and other factors into a single number. Not available on UNIT3D or Gazelle sites. +A GGn-specific score combining upload activity, seeding time, and other factors. diff --git a/docs/kb/docs/trackers/adding-a-tracker.md b/docs/kb/docs/trackers/adding-a-tracker.md index d4481e91..3686cfc7 100644 --- a/docs/kb/docs/trackers/adding-a-tracker.md +++ b/docs/kb/docs/trackers/adding-a-tracker.md @@ -5,15 +5,15 @@ description: How to add a private tracker, find your API token, and understand w # Adding a Tracker -Tracker Tracker supports 40+ trackers across four platforms: UNIT3D, Gazelle, GGn, and Nebulance. Most are in the built-in registry — pick one from the list and the URL and platform fill in automatically. +Tracker Tracker works with 40+ trackers. Pick one from the list below. -## Opening the Add Tracker dialog +## Open the Add Tracker dialog Click the **+** button next to "Trackers" in the sidebar, or go to `/trackers/new`. ![Add Tracker dialog](../assets/images/adding-a-tracker-dialog.png) -## The tracker registry +## Tracker registry === "UNIT3D" @@ -31,6 +31,8 @@ Click the **+** button next to "Trackers" in the sidebar, or go to `/trackers/ne | SkipTheCommercials | STC | | Seedpool | SP | | Upload.cx | | + | DarkPeers | DP | + | Luminarr | LUME | === "Gazelle" @@ -60,125 +62,183 @@ Click the **+** button next to "Trackers" in the sidebar, or go to `/trackers/ne | Anthelion | ANT | | Nebulance | NBL | +=== "AvistaZ" + + | Name | Abbreviation | + |---|---| + | AvistaZ | AvZ | + | AnimeZ | AnZ | + | CinemaZ | CZ | + | ExoticaZ | ExZ | + | PrivateHD | PHD | + +=== "DigitalCore" + + | Name | Abbreviation | + |---|---| + | DigitalCore | DC | + !!! note "Draft entries" - Some trackers in the registry are marked as drafts — dashed border, "Stats tracking not yet supported." You can pin them as quicklinks but no stats will be polled. + Some trackers show a dashed border saying "Stats tracking not yet supported." You can pin them as quicklinks, but they won't poll yet. -Trackers you've already added are hidden from the list automatically. +Added trackers hide from the list automatically. ## Required fields ### Base URL -The full HTTPS address for the tracker. +The tracker's full HTTPS address. ### API token -Where to find it depends on the platform: +Location varies by platform: === "UNIT3D" - Go to your account settings. Look for `Settings → Security → API Token` or `Settings → API`. + Go to **Settings → Security → API Token** or **Settings → API** in your account. - The token is a long alphanumeric string — copy the whole thing. + Copy the entire alphanumeric string. !!! tip "Token rotation" Some UNIT3D trackers regenerate your token when you change your password. If polls stop working after a password change, grab a fresh token. === "Gazelle" - - **RED / OPS** — `Settings → Access Settings → API Keys`. Create a new key. Read-only is sufficient. - - **BTN / PTP / AB / others** — check `Settings → Security`, `Settings → API`, or your profile page. + - **RED / OPS** — **Settings → Access Settings → API Keys**. Create a new key (read-only is fine). + - **BTN / PTP / AB / others** — Check **Settings → Security**, **Settings → API**, or your profile. - Gazelle keys are usually shown only once when created. Copy it immediately. + Gazelle shows keys only once when created, so copy it right away. !!! tip "Scoped keys" - Some Gazelle forks let you create keys with limited permissions. Tracker Tracker only reads your stats — read-only works fine. + Some Gazelle forks support limited-permission keys. Since Tracker Tracker only reads your stats, read-only access works great. === "GGn" - Go to `Settings → Access Settings → API Key`. Copy the full key. + Go to **Settings → Access Settings → API Key** and copy it. - GGn keys don't expire on their own, but they can be regenerated from your settings. + GGn keys don't expire automatically, but you can regenerate them anytime from your settings. -!!! warning "Keep your token private" - Your API token acts like a password for your account. Tracker Tracker encrypts it before storing it. +=== "AvistaZ" -## Optional fields + AvistaZ uses browser cookies instead of API keys. -### Proxy + 1. Open the tracker in your browser and log in + 2. Open DevTools (F12 or Cmd+Option+I) + 3. Go to the **Network** tab + 4. Refresh the page + 5. Click any request to the tracker's domain + 6. Find the `Cookie` header in **Request Headers** + 7. **Right-click** the Cookie header and select **Copy Value** + + ![Right-click Copy Value in Firefox DevTools](../assets/images/avistaz-cookie-copy-value.png) + + You'll also need your **username** on that tracker. + + Paste both into the Add Tracker dialog. The User-Agent gets captured automatically. + + !!! danger "Do not select and copy the Cookie value directly" + Firefox (and some Chromium browsers) truncate long header values in the display, replacing the end with `…`. If you select the text and copy it, you'll get the truncated version with the ellipsis character embedded. This causes a "Tracker test failed" error. -If the tracker needs a proxy (e.g., for geo-restrictions), toggle **Use Proxy** on the tracker's settings page. See [Proxy Support](../features/proxies.md) for setup. + Always use **right-click → Copy Value** to get the full, untruncated cookie string. -## What happens after you save + !!! warning "Cookie expiration" + The Cloudflare `cf_clearance` cookie expires periodically. When polling fails, refresh it by repeating the steps above. -1. The tracker is saved and an immediate poll runs. -2. The **PulseDot** on the tracker card shows the result: - - Breathing cyan — poll succeeded - - Amber — warning or partial data - - Red — poll failed (bad token, network error, etc.) -3. The tracker appears in the dashboard and sidebar. + !!! warning "Newbie rank not supported" + AvistaZ restricts Newbie accounts to limited site access. You need **Member** rank (5 GB upload, ratio ≥ 1.0, 7+ days old) before the profile page shows the data Tracker Tracker needs. Wait until promotion before adding the tracker. + +=== "DigitalCore" + + DigitalCore uses session cookies instead of API keys. + + 1. Open [digitalcore.club](https://digitalcore.club) in your browser and log in + 2. Open DevTools (F12 or Cmd+Option+I) + 3. Go to the **Network** tab + 4. Refresh the page + 5. Click any request to `digitalcore.club` + 6. Find the `Cookie` header in **Request Headers** and copy its full value + + The string will contain `uid=...` and `pass=...` among other values. Paste the entire thing into the Add Tracker dialog. Tracker Tracker extracts what it needs automatically. + + ![Cookie header in DevTools](../assets/images/digitalcore-cookie.png) + + !!! tip "Alternative: Application tab method" + Go to **Application** → **Cookies** → `digitalcore.club` and copy the `uid` and `pass` values. Paste them as `uid=12345; pass=abc123...`. + + !!! warning "Cookie expiration" + Session cookies expire when you log out or after extended inactivity. If polling fails with "Session expired," log back into DigitalCore and re-copy the cookie values. + + !!! info "DigitalCore's API key won't work" + DigitalCore has an API key feature, but it only allows access to torrent search endpoints. User stats require session cookies, which is why Tracker Tracker uses the cookie approach. + +!!! warning "Keep your token private" + Your API token works like a password. Tracker Tracker encrypts it before storing it. + +## Optional fields + +### Proxy -![PulseDot states showing healthy, warning, and error](../assets/images/pulsedots-states.png) +If the tracker needs a proxy (i.e., for geo-restrictions), toggle **Use Proxy** on the tracker's settings page. See [Proxy Support](../features/proxies.md) for setup. -!!! tip "Red dot right after adding?" - Check your API token. The most common cause is a copy-paste error or a token that was rotated after you copied it. +!!! tip "Getting a red dot?" + Check your API token — most often it's a copy-paste error or a token that rotated after you saved it. -## Polling manually +## Poll manually -The **Poll Now** button on any tracker's detail page runs an immediate fetch outside the normal schedule. Use it after updating a token or testing connectivity. +The **Poll Now** button on any tracker's detail page fetches stats immediately. Use it after updating a token or testing connectivity. -The global schedule (default: every 60 minutes) runs all trackers on a shared timer. Manual polls don't change it. +Manual polls don't interfere with the global schedule (default: every 60 minutes). --- -## What's tracked per platform +## What stats each platform provides -Not every platform exposes the same stats. Here's what you'll see: +Different platforms expose different stats. Here's what you'll get: -| Stat | UNIT3D | Gazelle | GGn | Notes | -| ---------------- | ------ | ------- | ------- | ---------------------------------------------------------------------------- | -| Upload | Yes | Yes | Yes | | -| Download | Yes | Yes | Yes | | -| Ratio | Yes | Yes | Yes | GGn shows extra decimal precision | -| Buffer | Yes | Yes | Yes | UNIT3D returns this directly; others calculate it from upload minus download | -| Seeding count | Yes | Partial | Partial | Some Gazelle forks and GGn may return 0 even when you're seeding | -| Leeching count | Yes | Partial | Partial | Same as above | -| Bonus points | Yes | Yes | Yes | GGn calls this "gold" — it maps automatically | -| Hit & Runs | Yes | No | Partial | GGn shows unknown for Elite Gamer+ (HNR immunity) | -| Required ratio | No | Yes | Yes | Not in the UNIT3D API | -| Warned status | No | Partial | Yes | Most Gazelle trackers default to false; RED has extended data | -| Freeleech tokens | No | Partial | No | Not all Gazelle forks expose this | +| Stat | UNIT3D | Gazelle | GGn | AvistaZ | DigitalCore | Notes | +| ---------------- | ------ | ------- | ------- | ------- | ----------- | ---------------------------------------------------------------------------- | +| Upload | Yes | Yes | Yes | Yes | Yes | | +| Download | Yes | Yes | Yes | Yes | Yes | | +| Ratio | Yes | Yes | Yes | Yes | Yes | GGn shows extra decimal precision | +| Buffer | Yes | Yes | Yes | Yes | Yes | UNIT3D returns this directly; others calculate it from upload minus download | +| Seeding count | Yes | Partial | Partial | Yes | Yes | Some Gazelle forks and GGn may return 0 even when you're seeding | +| Leeching count | Yes | Partial | Partial | Yes | Yes | Same as above | +| Bonus points | Yes | Yes | Yes | Yes | Yes | GGn calls this "gold," DigitalCore calls it "bonuspoang" | +| Hit & Runs | Yes | No | Partial | Yes | Yes | GGn shows unknown for Elite Gamer+ (HNR immunity) | +| Required ratio | No | Yes | Yes | No | No | Not in the UNIT3D or DigitalCore API | +| Warned status | No | Partial | Yes | No | Yes | Most Gazelle trackers default to false; RED has extended data | +| Freeleech tokens | No | Partial | No | No | No | Not all Gazelle forks expose this | -### Gazelle: enriched data on RED +### Gazelle: extra data on RED -REDacted (and Phoenix Project) fetch additional data beyond the standard stats — including warned status, join date, avatar, and more detailed seeding/leeching counts. If you're on RED, you'll see richer data than on other Gazelle trackers. +REDacted and Phoenix Project return additional fields — warned status, join date, avatar, and detailed seeding/leeching counts — giving you richer info than other Gazelle trackers. ### GGn quirks -- **Seeding/leeching can show 0** — GGn's API doesn't always return these counts. Not a polling error. -- **Hit & Runs shows unknown** for Elite Gamer and above — those classes are HNR-immune, so GGn returns null. -- **Gold, not seedbonus** — GGn's bonus currency is called "gold." Tracker Tracker maps it to the same field as bonus points on other trackers. +- **Seeding/leeching can show 0** — GGn's API doesn't always expose these counts. That's normal. +- **Hit & Runs shows unknown** for Elite Gamer and above — those classes are HNR-immune. +- **Gold, not seedbonus** — GGn calls bonus currency "gold," which we map to the bonus points field. --- -## Common issues +## Troubleshooting ### Token not working -Make sure you copied the full token. UNIT3D tokens are typically 60-80 characters. Gazelle keys are shown only once when created. +Make sure you copied the full token. UNIT3D tokens are usually 60-80 characters. Gazelle shows keys only once when you create them. ### Poll fails with 401 -Your token has expired or been regenerated. Copy the current token from the tracker's settings and update it in Tracker Tracker. +Your token expired or was regenerated. Get a fresh token from the tracker's settings and update it. -### Bonus points show as unavailable +### Bonus points show unavailable -A small number of heavily customized Gazelle forks don't include bonus points in their API. Nothing to configure — the tracker doesn't expose it. +Some heavily customized Gazelle forks don't include bonus points in their API. The tracker just doesn't expose it. -### Seeding count is always 0 +### Seeding count always shows 0 -Some Gazelle forks and GGn don't include seeding counts in their standard API response. If you're actively seeding and see 0, it's a platform limitation. +Some Gazelle forks and GGn don't expose seeding counts via their API. If you're seeding but see 0, it's a platform limitation. -### Warned status is always false +### Warned status always shows false -Expected for most Gazelle trackers. Only RED and Phoenix Project provide this data. +That's expected for most Gazelle trackers. Only RED and Phoenix Project provide this. diff --git a/docs/kb/docs/troubleshooting.md b/docs/kb/docs/troubleshooting.md new file mode 100644 index 00000000..3e1afb6a --- /dev/null +++ b/docs/kb/docs/troubleshooting.md @@ -0,0 +1,390 @@ +--- +title: Troubleshooting +description: A reference for error messages shown in the Poll Error Banner and download client status, with causes and solutions. +--- + +# Common Error Messages + +Error strings in the Tracker Tracker interface, with causes and fixes. + +--- + +## Understanding Tracker Health + +The **PulseDot** next to each tracker name indicates polling status: + +| State | Color | Meaning | +| ---------- | ----- | ---------------------------------------------------------------------------------- | +| `healthy` | Cyan | Ratio ≥ 2.0 — polling normally | +| `warning` | Amber | Ratio between 1.0 and 2.0, or ratio is fine but zero active seeds | +| `critical` | Red | Ratio < 1.0, or the tracker has warned your account | +| `error` | Red | The last poll failed, but polling has not paused yet | +| `paused` | Red | Polling has been automatically suspended after repeated failures | +| `offline` | Gray | No snapshot data exists yet (tracker was just added, or all snapshots were pruned) | + +--- + +## Ratio or Stats Not Updating + +Stats only change when Tracker Tracker captures a new snapshot. If your ratio, upload, or seeding count looks stale, work through this checklist. + +### Step 1 — Check the poll interval + +Go to **Settings → General** and check **Tracker Poll Interval** (15–1440 minutes). + +If it's 240, stats update every 4 hours. That's normal. + +!!! info "Fastest update rate" + 15 minutes is the fastest. Stats won't update more often, even with page reloads. + +### Step 2 — Check if polling is running + +Look at the PulseDot next to the tracker name. If it's `paused` or `error`, fix that first before worrying about stale stats. See [Automatic Poll Pausing](#automatic-poll-pausing) below for guidance. + +### Step 3 — Trigger an immediate poll + +Don't wait for the next scheduled poll. + +1. Open the tracker's detail page. +2. Click **Poll Now**. +3. The page refreshes with new stats if it works. + +If Poll Now returns an error, the Poll Error Banner will show the reason. + +### Step 4 — Check the chart time range + +Charts show data over your selected range. A narrow range hides changes. + +- Find the **day range selector** in the right sidebar on Data & Analytics. +- Try a broader range (30 or 90 days) to see historical data. +- If only today is selected, you'll only see today's snapshots. + +### Step 5 — Confirm snapshots are recording + +If Poll Now works but charts don't move, hard-refresh first (`Cmd+Shift+R` on Mac, `Ctrl+Shift+R` on Windows/Linux). + +Then check the **Last Polled** timestamp on the tracker detail page — it should match your most recent poll. + +If Last Polled updated but the chart didn't, the API returned the same values. That's normal. + +--- + +## Automatic Poll Pausing + +After **4 consecutive failed polls**, the tracker automatically pauses to prevent hammering a broken tracker. When this happens: + +- The PulseDot switches to `paused` +- The **Poll Error Banner** appears at the top of the tracker detail page with: + - Red "Polling Paused" heading + - Pause timestamp + - Last error (e.g., `Authentication failed`, `Host not found`) + - **Resume Polling** button + +!!! warning "Verify the cause before resuming" + Fix the underlying problem first. If you resume without fixing it, the tracker will fail again and re-pause immediately. + +--- + +## Resuming a Paused Tracker + +1. Open the tracker detail page. +2. Find the **Poll Error Banner** at the top with the last error below "Polling Paused". +3. Fix the problem (see specific error sections below). +4. Click **Resume Polling**, then **Poll Now** to verify immediately. + +--- + +## Tracker poll errors + +These appear in the **Poll Error Banner** on the tracker detail page. + +--- + +### `Authentication failed` + +**Cause:** The tracker rejected the token (HTTP 401/403 or explicit "Unauthorized"/"Forbidden"). + +**Common reasons:** + +- Token was regenerated on the tracker site. +- Token was entered wrong (whitespace, partial copy). +- Account API access was revoked or restricted. +- Tracker requires an IP allowlist. + +!!! success "Solution" + + 1. Log into the tracker and find your API token (usually under "Security", "API", or "Edit Profile"). + 2. Copy it carefully. + 3. In Tracker Tracker, edit the tracker settings and paste the new token. + 4. Save, then click **Resume Polling**, then **Poll Now**. + +--- + +### `Host not found` + +**Cause:** DNS resolution failed for the hostname. + +!!! success "Solution" + + 1. Check the base URL for typos. + 2. Test: `nslookup ` from your Docker host. + 3. If using VPN or custom DNS, add `dns:` to `docker-compose.yml` if needed. + 4. Update the base URL if the domain changed. + +--- + +### `Host unreachable` + +**Cause:** Hostname resolved but unreachable at the network layer. Route missing or firewall blocking. + +!!! success "Solution" + + 1. Check if the tracker loads in your browser. + 2. Verify VPN or firewall routing is up. + 3. Confirm no egress firewall on Docker host is blocking outbound. + +--- + +### `Connection refused` + +**Cause:** Host reached but connection refused. Usually wrong port or the tracker's down. + +!!! success "Solution" + + 1. Check the base URL port if the tracker uses a non-standard one. + 2. Verify the scheme (`https://` vs `http://`). + 3. Confirm the tracker loads in a browser. + +--- + +### `Connection reset` + +**Cause:** Connection established then terminated by the host. Often SSL/TLS mismatch or invalid certificate. + +!!! success "Solution" + + 1. Use `https://` if required. + 2. Self-signed certificates aren't supported. + +--- + +### `Request timed out` + +**Cause:** API doesn't respond within 15 seconds. May happen during maintenance or heavy load. + +!!! success "Solution" + + 1. Wait a few minutes and try **Poll Now** again. + 2. If persistent, check if a proxy is adding latency. + 3. The 15-second timeout can't be changed. + +--- + +### `IP temporarily banned by tracker` + +**Cause:** Tracker's rate-limiting blocked your IP. Poll interval too short or automated requests treated as abuse. + +!!! success "Solution" + + 1. Wait for the ban to expire — typically minutes to hours. + 2. Increase **Poll Interval** in **Settings → General** to 60 minutes (or higher for sensitive trackers). + 3. Resume polling after the ban clears. + +--- + +### `Proxy connection failed` + +**Cause:** **Use Proxy** is enabled and the proxy is unreachable or erroring. We don't bypass required proxies. + +!!! success "Solution" + + 1. Check **Settings → Proxy** — verify host, port, type, username, password. + 2. Confirm the proxy is running and reachable. + 3. Or disable **Use Proxy** in tracker settings. + +--- + +### Stale error banner (no pause) + +If you see a **Last Error** banner without "Polling Paused", a recent poll failed but not enough to trigger a pause. It clears on the next success. + +!!! info "No action if the last poll succeeded" + The banner shows the most recent error even if later polls recovered. If the PulseDot is `healthy`, `warning`, or `critical`, polling already recovered. + +--- + +### `API returned ` + +**Cause:** API returned an unexpected status. E.g., 429 = rate limit, 500/503 = server error. + +!!! success "Solution" + + - **429:** Increase poll interval and wait. + - **500 / 503:** Tracker having issues — wait and retry. + - **Other:** Check tracker's status page or forums. + +--- + +### `Poll failed` (generic) + +**Cause:** Unexpected error not matching known patterns. + +!!! success "Solution" + + Check logs: `docker compose logs app` for a line with `Poll failed for tracker` + raw error. + +--- + +## Download client errors + +These appear on individual client cards in **Settings → Download Clients**. + +--- + +### `Authentication failed` (qBittorrent) + +**Cause:** Wrong username or password. + +!!! success "Solution" + + 1. Confirm qBittorrent credentials in its Web UI settings. + 2. Update in **Settings → Download Clients**. + 3. Use **Test Connection** to verify. + +--- + +### `Session expired` (qBittorrent — auto-handled) + +**Cause:** Session expired (HTTP 403). We re-authenticate automatically on next request. + +!!! info "No action needed" + Handled transparently. Persistent errors are more likely wrong credentials. + +--- + +### `Connection refused` (qBittorrent) + +**Cause:** Web UI not accessible at the configured host/port. + +!!! success "Solution" + + 1. Check host and port match qBittorrent's Web UI config. + 2. Confirm qBittorrent is running with Web UI enabled. + 3. If on a different container, confirm the port is reachable. + 4. Verify SSL toggle matches qBittorrent's scheme. + +--- + +### `Host not found` (qBittorrent) + +**Cause:** DNS resolution failed. + +!!! success "Solution" + + Use the container name (if in same Docker stack) or an IP instead of a hostname. + +--- + +### `Request timed out` (qBittorrent) + +**Cause:** qBittorrent doesn't respond within 15 seconds. May happen during heavy indexing. + +!!! success "Solution" + + Wait and retry. If persistent, check qBittorrent's CPU and memory. + +--- + +## Authentication and session errors (Tracker Tracker login) + +--- + +### HTTP 429 — Too many failed login attempts + +**Cause:** Auto-lockout triggered after too many failed attempts. + +!!! success "Solution" + + Wait for the lockout to expire (time shown on login page). If you can't wait, connect to the database and clear `lockedUntil` in the `appSettings` table. + +--- + +### Login succeeds but redirects back to login + +**Cause:** Session cookies marked `Secure` but app accessed over plain HTTP. Browsers discard `Secure` cookies on HTTP. + +!!! success "Solution" + + If using `http://`, no action needed on recent versions. If serving over HTTPS via proxy, set `BASE_URL=https://your-domain.com` in `.env`. + +--- + +### TOTP — Invalid code + +**Cause:** Code incorrect or expired (valid 30s). Clock drift invalidates codes. + +!!! success "Solution" + + 1. Sync your device clock (NTP). Even 30-60s drift breaks codes. + 2. Wait for the next code and retry. + 3. Or use a backup code (`XXXX-XXXX` format, one-time use). + +--- + +### TOTP — Lost authenticator / no backup codes + +**Cause:** No access to authenticator app and no backup codes. + +!!! warning "Database access required" + + No in-app recovery. Connect to PostgreSQL and run: + + ```sql + UPDATE app_settings SET totp_secret = NULL, totp_backup_codes = NULL; + ``` + + Then log in and reconfigure TOTP. + +--- + +## Errors when adding or editing a tracker + +--- + +### `Tracker test failed` (AvistaZ / cookie-based trackers) + +**Cause:** Cookie string contains a non-ASCII character (usually `…` from browser truncation). + +Firefox and some Chromium browsers truncate long header values in their DevTools display, replacing the end with a `…` (ellipsis) character. If you select and copy the displayed text instead of using "Copy Value", the truncated string gets pasted into the dialog. HTTP headers can't contain non-ASCII characters, so the connection fails. + +!!! success "Solution" + + 1. Open DevTools → **Network** tab → click any request. + 2. Find the **Cookie** header in Request Headers. + 3. **Right-click** the Cookie header → **Copy Value** (do not select the text manually). + 4. Paste the full value into the Add Tracker dialog. + + ![Right-click Copy Value in Firefox DevTools](assets/images/avistaz-cookie-copy-value.png) + +!!! tip "How to tell if your cookies are truncated" + The Add Tracker dialog shows a yellow warning if it detects a `…` character in the cookie string. If you see this warning, re-copy using right-click → Copy Value. + +--- + +### `baseUrl must use https:// or http://` + +**Cause:** URL uses unsupported scheme (e.g. `ftp://`) or missing scheme. + +!!! success "Solution" + + Prefix with `https://` or `http://`. + +--- + +### `baseUrl must not target localhost or a private network address` + +**Cause:** URL resolves to a private IP or localhost. We block these for security. + +!!! info "By design" + + Tracker URLs must be public. Internal or self-hosted trackers can't be added. diff --git a/docs/kb/docs/troubleshooting/common-errors.md b/docs/kb/docs/troubleshooting/common-errors.md deleted file mode 100644 index bdb14e44..00000000 --- a/docs/kb/docs/troubleshooting/common-errors.md +++ /dev/null @@ -1,218 +0,0 @@ ---- -title: Common Error Messages -description: A reference for error messages shown in the Poll Error Banner and download client status, with causes and solutions. ---- - -# Common Error Messages - -This page maps the error strings shown in the Tracker Tracker interface to their likely causes and fixes. - ---- - -## Tracker poll errors - -These appear in the **Poll Error Banner** on the tracker detail page, under the "Last Error" or "Polling Paused" heading. - ---- - -### `Authentication failed` - -**Cause:** The tracker rejected the API token. This covers HTTP 401 and 403 responses, as well as explicit "Unauthorized" or "Forbidden" messages from the tracker API. - -**Common reasons:** - -- The API token was regenerated on the tracker site and the one stored in Tracker Tracker is no longer valid. -- The token was entered incorrectly (extra whitespace, partial copy). -- Your account's API access was revoked or restricted. -- The tracker requires a specific IP allowlist for API access. - -!!! success "Solution" 1. Log into the tracker website and go to your profile or security settings. 2. Regenerate or copy your current API token. 3. Open the tracker settings in Tracker Tracker and replace the token. 4. Click **Resume Polling** on the tracker detail page, then **Poll Now** to verify. - ---- - -### `Host not found` - -**Cause:** DNS resolution failed for the tracker's hostname. The system could not translate the domain name to an IP address. - -!!! success "Solution" 1. Check the tracker's base URL for typos (e.g. `tracker.exmaple.com` instead of `tracker.example.com`). 2. Test DNS from your Docker host: `nslookup `. 3. If you use a VPN or custom DNS, confirm DNS is accessible inside the container. Add a `dns:` directive to `docker-compose.yml` if needed. 4. If the tracker's domain has changed, update the base URL in tracker settings. - ---- - -### `Host unreachable` - -**Cause:** The hostname resolved but the host could not be reached at the network layer. The route to the IP does not exist, or a firewall is silently dropping packets. - -!!! success "Solution" 1. Check whether the tracker is accessible from your browser. 2. If you use a VPN or firewall to route tracker traffic, verify those are up. 3. Confirm there is no egress firewall on your Docker host blocking outbound connections. - ---- - -### `Connection refused` - -**Cause:** The host was reached but actively refused the connection. The most common cause is a wrong port — nothing is listening there — or the tracker's web server is down. - -!!! success "Solution" 1. Verify the base URL includes the correct port if the tracker uses a non-standard one. 2. Check the scheme (`https://` vs `http://`) — a mismatch will produce a refused or reset connection. 3. Confirm the tracker site is responding normally in a browser. - ---- - -### `Connection reset` - -**Cause:** The connection was established and then immediately terminated by the remote host. Often caused by SSL/TLS mismatches, invalid certificates, or the remote server closing the connection unexpectedly. - -!!! success "Solution" 1. Confirm the scheme in the base URL matches what the tracker requires (`https://`). 2. Self-signed certificates are not currently supported — the TLS handshake will fail. - ---- - -### `Request timed out` - -**Cause:** The tracker API accepted the connection but did not return a response within 15 seconds. This can happen during maintenance windows or when the API is under heavy load. - -!!! success "Solution" 1. Wait a few minutes and use **Poll Now** to retry manually. 2. If timeouts are persistent, check whether a proxy is adding latency. 3. The 15-second timeout is fixed and cannot be changed. - ---- - -### `IP temporarily banned by tracker` - -**Cause:** The tracker's rate-limiting or abuse detection blocked your IP. This can happen if the poll interval is too short, or if the tracker treats automated requests as abusive. - -!!! success "Solution" 1. Wait for the ban to expire — this is enforced on the tracker side and typically lasts minutes to hours depending on the site's policy. 2. Once the ban clears, go to **Settings → General** and increase the poll interval. 60 minutes is the recommended default; some trackers enforce stricter limits. 3. Resume polling only after the ban has likely expired. 4. Do **not** set the poll interval below 30 minutes on trackers known to be sensitive to API traffic. - ---- - -### `Proxy connection failed` - -**Cause:** The tracker has **Use Proxy** enabled, and the proxy server was unreachable or returned an error. Tracker Tracker will not silently bypass a required proxy and make a direct connection. - -!!! success "Solution" 1. Go to **Settings → Proxy** and verify the proxy host, port, type, username, and password. 2. Confirm the proxy server is running and reachable from the Docker container. 3. To disable the proxy for a specific tracker, edit the tracker settings and turn off **Use Proxy**. - ---- - -### `API returned ` - -**Cause:** The tracker's API responded with an unexpected HTTP status code. For example, `API returned 429` indicates HTTP-level rate limiting; `API returned 500` indicates a tracker-side server error. - -!!! success "Solution" - **429:** Reduce the poll interval and wait for the tracker to clear the rate limit. - **500 / 503:** The tracker API is having issues — wait and retry later. - **Other codes:** Check the tracker's status page or community forums. - ---- - -### `Poll failed` (generic) - -**Cause:** An unexpected error occurred that did not match any of the known patterns above. - -!!! success "Solution" - Check the Tracker Tracker server logs (`docker compose logs app`) for the full error. Look for a line containing `Poll failed for tracker` followed by the raw error string. - ---- - -## Download client errors - -These appear on the **Download Clients** panel in Settings, on individual client cards. - ---- - -### `Authentication failed` (qBittorrent) - -**Cause:** qBittorrent rejected the username/password combination. - -!!! success "Solution" 1. In qBittorrent's Web UI settings, confirm the username and password. 2. Update the credentials in **Settings → Download Clients** (edit the client card). 3. Use the **Test Connection** button after saving to verify. - ---- - -### `Session expired` (qBittorrent — auto-handled) - -**Cause:** qBittorrent returned a 403 on an authenticated request, indicating the session has expired. Tracker Tracker handles this automatically by re-authenticating on the next request. - -!!! info "No action needed" - Session expiry is handled transparently. If you see persistent errors in the **Download Clients** panel, the issue is more likely wrong credentials rather than session expiry. - ---- - -### `Connection refused` (qBittorrent) - -**Cause:** The qBittorrent Web UI is not accessible at the configured host and port. - -!!! success "Solution" 1. Verify the host and port match the qBittorrent Web UI configuration. 2. Confirm qBittorrent is running with the Web UI enabled. 3. If qBittorrent is on a different machine or container, confirm the port is reachable from the Tracker Tracker container. 4. Check the SSL toggle — a mismatch between qBittorrent's actual scheme and what Tracker Tracker expects will cause failures. - ---- - -### `Host not found` (qBittorrent) - -**Cause:** DNS resolution failed for the qBittorrent host. - -!!! success "Solution" - Use the container name (if qBittorrent is in the same Docker Compose stack) or an IP address instead of a hostname that depends on external DNS. - ---- - -### `Request timed out` (qBittorrent) - -**Cause:** qBittorrent accepted the connection but did not respond within 15 seconds. This can happen during heavy indexing operations. - -!!! success "Solution" - Wait for qBittorrent to finish processing and retry. If timeouts are persistent, check qBittorrent's CPU and memory usage. - ---- - -## Authentication and session errors (Tracker Tracker login) - ---- - -### HTTP 429 — Too many failed login attempts - -**Cause:** The auto-lockout feature triggered. After a configurable number of consecutive failed login attempts, the app blocks further attempts until the lockout duration expires. - -!!! success "Solution" - Wait for the lockout duration to expire. The remaining time is shown on the login page. If you are locked out and cannot wait, you will need to connect to the database directly and clear the `lockedUntil` field in the `appSettings` table. - ---- - -### Login succeeds but redirects back to login - -**Cause:** The server logs show "Login successful" but the browser keeps returning to the login screen. This happens when session cookies are marked `Secure` but the app is accessed over plain HTTP — browsers silently discard `Secure` cookies on non-HTTPS connections. - -!!! success "Solution" - If you access Tracker Tracker over plain HTTP (e.g. `http://192.168.1.x:3000`), no action is needed on recent versions — cookies default to non-secure. If you are on an older version, update to the latest image. If you serve over HTTPS via a reverse proxy, set `BASE_URL=https://your-domain.com` in `.env` to enable secure cookies. - ---- - -### TOTP — Invalid code - -**Cause:** The 6-digit code was incorrect or expired. TOTP codes are valid for 30 seconds. Clock drift between your authenticator app and the server can cause valid-looking codes to fail. - -!!! success "Solution" 1. Ensure your device's clock is synchronized (NTP). Even a 30-60 second drift can invalidate codes. 2. Wait for your authenticator to cycle to the next code and try again. 3. If your clock is correct and codes still fail, use a **backup code** from when you set up TOTP. Backup codes are one-time use and follow the format `XXXX-XXXX`. - ---- - -### TOTP — Lost authenticator / no backup codes - -**Cause:** You no longer have access to the authenticator app and have no backup codes. - -!!! warning "Database access required" - There is no in-app recovery path for this situation. You will need to connect to the PostgreSQL database directly and run: - - ```sql - UPDATE app_settings SET totp_secret = NULL, totp_backup_codes = NULL; - ``` - - TOTP is considered disabled when `totp_secret` is `NULL` — there is no separate enabled/disabled flag. After clearing those columns, log in with your password and reconfigure TOTP. - ---- - -## Errors when adding or editing a tracker - ---- - -### `baseUrl must use https:// or http://` - -**Cause:** The URL you entered uses an unsupported scheme (e.g. `ftp://`) or is a bare hostname without a scheme. - -!!! success "Solution" - Prefix the URL with `https://` or `http://`. - ---- - -### `baseUrl must not target localhost or a private network address` - -**Cause:** The URL resolves to a private IP range (192.168.x.x, 10.x.x.x, 172.16-31.x.x) or localhost. Tracker Tracker blocks these to prevent your internal network from being probed through the app. - -!!! info "By design" - Tracker URLs must be publicly routable hostnames. Internal or self-hosted trackers only accessible via private IPs cannot be added. diff --git a/docs/kb/docs/troubleshooting/ratio-not-updating.md b/docs/kb/docs/troubleshooting/ratio-not-updating.md deleted file mode 100644 index a63bbccd..00000000 --- a/docs/kb/docs/troubleshooting/ratio-not-updating.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -title: Ratio or Stats Not Updating -description: Why your ratio or other stats appear stale, and how to verify that polling is working correctly. ---- - -# Ratio or Stats Not Updating - -Stats only change when Tracker Tracker records a new snapshot. If your ratio, upload totals, or seeding count look stale, work through the checklist below. - ---- - -## Step 1 — Check the poll interval setting - -Go to **Settings → General** and find the **Tracker Poll Interval** field. The value is in minutes. The valid range is 15 to 1440 (one day). - -If this is set to 240, stats will only update every 4 hours. That is expected behavior, not a bug. - -!!! info "Minimum update rate" - Setting the interval to 15 minutes is the fastest update rate available. Stats will not update more frequently than this regardless of how often you reload the page. - ---- - -## Step 2 — Verify polling is actually running - -Look at the PulseDot next to the tracker name. - -| PulseDot state | What it means for polling | -| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `healthy` / `warning` / `critical` | Polling is running. Stats are current as of the last snapshot. | -| `error` | The last poll failed. The next poll cycle will retry automatically. | -| `paused` | Polling is suspended. No new snapshots will be recorded until you resume. See [Tracker Showing as Offline or Paused](tracker-offline.md). | -| `offline` | No snapshot data exists yet. The tracker may not have been polled. | - -If the PulseDot is `paused` or `error`, fix the underlying issue before investigating stale stats. - ---- - -## Step 3 — Use Poll Now to trigger an immediate update - -You do not need to wait for the next scheduled poll. - -1. Open the tracker's detail page. -2. Click the **Poll Now** button near the tracker name. -3. The page will refresh with updated stats if the poll succeeded. - -If Poll Now returns an error, the Poll Error Banner will show the reason. See [Tracker Showing as Offline or Paused](tracker-offline.md) for error-specific guidance. - ---- - -## Step 4 — Check the chart time range - -The charts on the tracker detail page show data over a selected time range. A narrow range can make it look like nothing has changed even when new snapshots exist. - -- Find the **day range selector** in the right sidebar on the Data & Analytics tab. -- Try a broader range (30 days or 90 days) to confirm historical data is present. -- The default view shows the most recent data. If only today is selected, you will only see today's snapshots. - ---- - -## Step 5 — Confirm snapshots are being recorded - -If Poll Now succeeds but the charts still do not move, try a hard-refresh first. - -1. Hard-refresh the page (`Cmd+Shift+R` on macOS, `Ctrl+Shift+R` on Windows/Linux). -2. Check the **Last Polled** timestamp on the tracker detail page — it should match the time of the most recent poll. -3. If the Last Polled time updated but the chart did not change, the tracker API returned the same values as the previous snapshot. This is normal — the snapshot is still recorded, the values just did not change. - ---- - -## Edge case — Tracker API returns cached data - -Some trackers cache their API responses server-side for several minutes. If you poll twice within that cache window, both snapshots will contain identical values. This shows up as a flat line on the chart. - -There is no workaround for this. The data reflects exactly what the tracker API reported. A longer poll interval (30-60 minutes) is less likely to land inside the tracker's cache window. - ---- - -## Edge case — Snapshot retention pruning - -If you have configured a **Snapshot Retention** period in Settings → General, snapshots older than that window are automatically deleted after each poll cycle. - -If retention is set to 7 days and you are looking at a 30-day chart, the older portion of the chart will be empty. That data was pruned. - -!!! tip "Retention defaults" - If retention is not configured, snapshots are kept forever. If you see gaps in older data, check the retention setting in Settings → General. diff --git a/docs/kb/docs/troubleshooting/tracker-offline.md b/docs/kb/docs/troubleshooting/tracker-offline.md deleted file mode 100644 index e1dd772d..00000000 --- a/docs/kb/docs/troubleshooting/tracker-offline.md +++ /dev/null @@ -1,126 +0,0 @@ ---- -title: Tracker Showing as Offline or Paused -description: What the PulseDot states mean, how automatic poll pausing works, and how to get a paused tracker polling again. ---- - -# Tracker Showing as Offline or Paused - -## Understanding PulseDot states - -The PulseDot next to each tracker name is the first place to look. It reflects the tracker's current health based on its last known data and any recent poll errors. - -| State | Color | Meaning | -| ---------- | ----- | ---------------------------------------------------------------------------------- | -| `healthy` | Cyan | Ratio ≥ 2.0 — polling normally | -| `warning` | Amber | Ratio between 1.0 and 2.0, or ratio is fine but zero active seeds | -| `critical` | Red | Ratio < 1.0, or the tracker has warned your account | -| `error` | Red | The last poll failed, but polling has not paused yet | -| `paused` | Red | Polling has been automatically suspended after repeated failures | -| `offline` | Gray | No snapshot data exists yet (tracker was just added, or all snapshots were pruned) | - -The `paused` and `error` states are what you will see when a tracker goes offline or becomes unreachable. - ---- - -## Automatic poll pausing - -After **4 consecutive failed polls**, the tracker is automatically paused. This prevents repeated failed requests to a broken or unreachable tracker. - -When this happens: - -- The PulseDot switches to the `paused` state. -- The **Poll Error Banner** appears at the top of the tracker's detail page showing: - - The heading **"Polling Paused"** in red - - The timestamp when polling was paused - - The last recorded error (e.g. `Authentication failed`, `Host not found`) - - A **Resume Polling** button - -Paused trackers are skipped entirely until you manually resume them. - -!!! warning "Verify the cause before resuming" - The banner reads: _"Polling was paused after repeated failures. Verify your API key is correct before resuming."_ If you resume without fixing the underlying problem, the tracker will fail again immediately and re-pause within the same poll cycle. - ---- - -## Resuming a paused tracker - -1. Open the tracker's detail page. -2. Find the **Poll Error Banner** near the top. The last error is listed below the "Polling Paused" heading. -3. Fix the underlying problem (see sections below). -4. Click **Resume Polling**. - -After resuming, click **Poll Now** to verify the fix works immediately rather than waiting for the next scheduled poll. - ---- - -## Common causes and solutions - -### Invalid or expired API key - -This is the most common cause. Your tracker API key may have been regenerated, revoked, or entered incorrectly. - -**Symptom:** The Poll Error Banner shows `Authentication failed`. - -!!! success "Solution" 1. Log into your tracker's website and go to your profile or security settings. 2. Find your API token (often under "Security", "API", or "Edit Profile"). 3. Copy the current token. 4. In Tracker Tracker, open the tracker's settings (edit icon on the tracker detail page or tracker list). 5. Paste the new token into the API Token field and save. 6. Click **Resume Polling**, then **Poll Now** to verify. - ---- - -### Proxy misconfiguration - -If a tracker has **Use Proxy** enabled, Tracker Tracker will not fall back to a direct connection. If the proxy is unreachable or misconfigured, every poll fails. - -**Symptom:** The Poll Error Banner shows `Proxy connection failed`. The tracker has "Use Proxy" toggled on in its settings. - -!!! success "Solution" 1. Go to **Settings → Proxy** and verify the proxy host, port, type (SOCKS5/HTTP/HTTPS), and credentials. 2. Confirm the proxy server is running and reachable from your Docker host. 3. If you want to bypass the proxy for this tracker, edit the tracker settings and disable **Use Proxy**. 4. Resume polling after fixing. - ---- - -### DNS resolution failure - -The tracker's hostname cannot be resolved. This may be a temporary DNS outage, a typo in the base URL, or a DNS issue inside Docker. - -**Symptom:** The Poll Error Banner shows `Host not found`. - -!!! success "Solution" 1. Check the tracker's base URL for typos in the hostname. 2. From your Docker host, test resolution: `nslookup tracker.example.com`. 3. If you use custom DNS or a VPN, confirm DNS is available inside the container network. You may need to add a `dns:` entry to your `docker-compose.yml`. 4. If the tracker's domain has changed, update the base URL in the tracker settings. - ---- - -### Tracker is genuinely unreachable - -The hostname resolved but the connection was refused or the host was unreachable. - -**Symptom:** The Poll Error Banner shows `Connection refused` or `Host unreachable`. - -!!! success "Solution" 1. - Check whether the tracker site loads in your browser. 2. If the tracker is down, wait for it to recover, then resume polling. 3. If you route tracker traffic through a VPN, confirm the VPN is up. 4. Verify the base URL uses the correct scheme (`https://` vs `http://`) and the right port if the tracker uses a non-standard one. - ---- - -### Request timeout - -The connection was established but the tracker API did not respond within 15 seconds. - -**Symptom:** The Poll Error Banner shows `Request timed out`. - -!!! success "Solution" 1. - This is often transient. Try **Poll Now** again after a few minutes. 2. If timeouts are persistent, check whether a proxy is adding significant latency. 3. If the tracker's API is consistently slow, there is no configurable timeout override. - ---- - -### Rate limiting - -Some trackers block repeated requests from a single IP if polls come in too frequently. - -**Symptom:** The Poll Error Banner shows `IP temporarily banned by tracker`. - -!!! success "Solution" 1. - Go to **Settings → General** and increase the **Poll Interval**. The minimum is 15 minutes, but 60 minutes (the default) is recommended for most trackers. 2. Wait for the IP ban to expire on the tracker side — this varies by site, typically minutes to hours. 3. Resume polling only after the ban has likely cleared. - ---- - -### Stale error banner (no pause) - -If polling has not paused but you still see a **Last Error** banner — without the "Polling Paused" heading — it means a recent poll failed but not enough times to trigger a pause. The banner clears automatically on the next successful poll. - -!!! info "No action needed if the last poll succeeded" - The Last Error banner always shows the most recent error, even if subsequent polls recovered. If the PulseDot is `healthy`, `warning`, or `critical` (not `error` or `paused`), polling has already recovered on its own. diff --git a/docs/kb/mkdocs.yml b/docs/kb/mkdocs.yml index 8efeb1fc..de871829 100644 --- a/docs/kb/mkdocs.yml +++ b/docs/kb/mkdocs.yml @@ -46,22 +46,18 @@ nav: - Adding a Tracker: trackers/adding-a-tracker.md - Features: - Proxy Support: features/proxies.md - - Two-Factor Auth (TOTP): features/totp.md - Backups & Restore: features/backups.md - Download Clients: features/download-clients.md - Tag Groups: features/tag-groups.md - qbitmanage Integration: features/qbitmanage.md - Webhooks: features/webhooks.md + - Transit Papers: features/transit-papers.md - Reference: - Settings Guide: reference/settings.md - Stats Explained: reference/stats-explained.md - Platform Differences: reference/platform-differences.md - - Troubleshooting: - - Tracker Shows Offline: troubleshooting/tracker-offline.md - - Ratio Not Updating: troubleshooting/ratio-not-updating.md - - Common Errors: troubleshooting/common-errors.md + - Troubleshooting: troubleshooting.md - Contributing: - - Overview: contributing/index.md - Adding a Tracker: contributing/adding-a-tracker.md - Bento Grid Slot System: contributing/slot-system.md - Tracker API Responses: diff --git a/next.config.ts b/next.config.ts index 1c32ce31..4d74bd3f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -29,6 +29,7 @@ const nextConfig: NextConfig = { env: { NEXT_PUBLIC_APP_VERSION: process.env.npm_package_version ?? "0.0.0", }, + allowedDevOrigins: ["*.local", "*.lan", "192.168.*.*", "10.*.*.*"], devIndicators: { position: "bottom-right", }, diff --git a/package.json b/package.json index 37acf9ce..0de0cd24 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "private-tracker-tracker", - "version": "2.6.0", + "version": "2.8.7", "description": "Self-hosted dashboard for monitoring private tracker stats over time", "license": "GPL-3.0", "repository": { @@ -8,7 +8,7 @@ "url": "https://github.com/jordanlambrecht/tracker-tracker.git" }, "private": true, - "packageManager": "pnpm@10.32.1", + "packageManager": "pnpm@10.33.0", "engines": { "node": ">=22" }, @@ -36,6 +36,7 @@ "release:patch": "commit-and-tag-version --release-as patch", "release:minor": "commit-and-tag-version --release-as minor", "release:major": "commit-and-tag-version --release-as major", + "changelog:regen": "node scripts/regen-changelog.cjs", "knip": "knip", "knip:filter": "knip --reporter json --no-exit-code | bash scripts/knip-filter.sh", "db:push": "drizzle-kit push", @@ -48,54 +49,58 @@ "@dnd-kit/utilities": "^3.2.2", "@formkit/auto-animate": "^0.9.0", "@tailwindcss/typography": "^0.5.19", - "@tanstack/react-query": "^5.95.2", + "@tanstack/react-query": "^5.97.0", "argon2": "^0.44.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-kit": "^0.31.10", - "drizzle-orm": "^0.45.1", + "drizzle-orm": "^0.45.2", "echarts": "^6.0.0", "echarts-for-react": "^3.0.6", "echarts-gl": "^2.0.9", "emoji-picker-react": "^4.18.0", - "https-proxy-agent": "^8.0.0", + "https-proxy-agent": "^9.0.0", "jose": "^6.2.2", - "next": "16.2.1", + "next": "16.2.2", "node-cron": "^4.2.1", + "node-html-parser": "^7.1.0", "otpauth": "^9.5.0", "pino": "^10.3.1", "pino-pretty": "^13.1.3", - "postgres": "^3.4.8", + "postgres": "^3.4.9", "qrcode.react": "^4.2.0", "react": "19.2.4", "react-colorful": "^5.6.1", "react-dom": "19.2.4", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", - "socks-proxy-agent": "^9.0.0" + "socks-proxy-agent": "^10.0.0" }, "devDependencies": { - "@biomejs/biome": "^2.4.9", + "@biomejs/biome": "^2.4.11", "@commitlint/cli": "^20.5.0", "@commitlint/config-conventional": "^20.5.0", "@tailwindcss/postcss": "^4.2.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/node": "^25.5.0", + "@types/jsdom": "^28.0.1", + "@types/node": "^25.5.2", "@types/node-cron": "^3.0.11", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "commit-and-tag-version": "^12.7.1", - "dotenv": "^17.3.1", + "conventional-changelog-cli": "^5.0.0", + "conventional-changelog-conventionalcommits": "^9.3.1", + "dotenv": "^17.4.1", "husky": "^9.1.7", - "jsdom": "^29.0.1", - "knip": "^6.0.6", - "postcss": "^8.5.8", + "jsdom": "^29.0.2", + "knip": "^6.3.1", + "postcss": "^8.5.9", "prettier": "^3.8.1", "tailwindcss": "^4.2.2", "tsx": "^4.21.0", "typescript": "^6.0.2", - "vitest": "^4.1.2" + "vitest": "^4.1.4" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1afbeee..aabfe6a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,8 +27,8 @@ importers: specifier: ^0.5.19 version: 0.5.19(tailwindcss@4.2.2) '@tanstack/react-query': - specifier: ^5.95.2 - version: 5.95.2(react@19.2.4) + specifier: ^5.97.0 + version: 5.97.0(react@19.2.4) argon2: specifier: ^0.44.0 version: 0.44.0 @@ -42,8 +42,8 @@ importers: specifier: ^0.31.10 version: 0.31.10 drizzle-orm: - specifier: ^0.45.1 - version: 0.45.1(postgres@3.4.8) + specifier: ^0.45.2 + version: 0.45.2(postgres@3.4.9) echarts: specifier: ^6.0.0 version: 6.0.0 @@ -57,17 +57,20 @@ importers: specifier: ^4.18.0 version: 4.18.0(react@19.2.4) https-proxy-agent: - specifier: ^8.0.0 - version: 8.0.0 + specifier: ^9.0.0 + version: 9.0.0 jose: specifier: ^6.2.2 version: 6.2.2 next: - specifier: 16.2.1 - version: 16.2.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 16.2.2 + version: 16.2.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) node-cron: specifier: ^4.2.1 version: 4.2.1 + node-html-parser: + specifier: ^7.1.0 + version: 7.1.0 otpauth: specifier: ^9.5.0 version: 9.5.0 @@ -78,8 +81,8 @@ importers: specifier: ^13.1.3 version: 13.1.3 postgres: - specifier: ^3.4.8 - version: 3.4.8 + specifier: ^3.4.9 + version: 3.4.9 qrcode.react: specifier: ^4.2.0 version: 4.2.0(react@19.2.4) @@ -99,15 +102,15 @@ importers: specifier: ^4.0.1 version: 4.0.1 socks-proxy-agent: - specifier: ^9.0.0 - version: 9.0.0 + specifier: ^10.0.0 + version: 10.0.0 devDependencies: '@biomejs/biome': - specifier: ^2.4.9 - version: 2.4.9 + specifier: ^2.4.11 + version: 2.4.11 '@commitlint/cli': specifier: ^20.5.0 - version: 20.5.0(@types/node@25.5.0)(conventional-commits-parser@6.3.0)(typescript@6.0.2) + version: 20.5.0(@types/node@25.5.2)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@6.0.2) '@commitlint/config-conventional': specifier: ^20.5.0 version: 20.5.0 @@ -123,9 +126,12 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) + '@types/jsdom': + specifier: ^28.0.1 + version: 28.0.1 '@types/node': - specifier: ^25.5.0 - version: 25.5.0 + specifier: ^25.5.2 + version: 25.5.2 '@types/node-cron': specifier: ^3.0.11 version: 3.0.11 @@ -138,21 +144,27 @@ importers: commit-and-tag-version: specifier: ^12.7.1 version: 12.7.1 + conventional-changelog-cli: + specifier: ^5.0.0 + version: 5.0.0(conventional-commits-filter@5.0.0) + conventional-changelog-conventionalcommits: + specifier: ^9.3.1 + version: 9.3.1 dotenv: - specifier: ^17.3.1 - version: 17.3.1 + specifier: ^17.4.1 + version: 17.4.1 husky: specifier: ^9.1.7 version: 9.1.7 jsdom: - specifier: ^29.0.1 - version: 29.0.1(@noble/hashes@2.0.1) + specifier: ^29.0.2 + version: 29.0.2(@noble/hashes@2.0.1) knip: - specifier: ^6.0.6 - version: 6.0.6 + specifier: ^6.3.1 + version: 6.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.2) postcss: - specifier: ^8.5.8 - version: 8.5.8 + specifier: ^8.5.9 + version: 8.5.9 prettier: specifier: ^3.8.1 version: 3.8.1 @@ -166,8 +178,8 @@ importers: specifier: ^6.0.2 version: 6.0.2 vitest: - specifier: ^4.1.2 - version: 4.1.2(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@2.0.1))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + specifier: ^4.1.4 + version: 4.1.4(@types/node@25.5.2)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) packages: @@ -178,12 +190,12 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@asamuzakjp/css-color@5.0.1': - resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} + '@asamuzakjp/css-color@5.1.9': + resolution: {integrity: sha512-zd9c/Wdso6v1U7v6w3i/hbAr4K7NaSHImdpvmLt+Y9ea5BhilnIGNkfhOJ7FEIuPipAnE9tZeDOll05WDT0kgg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - '@asamuzakjp/dom-selector@7.0.4': - resolution: {integrity: sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==} + '@asamuzakjp/dom-selector@7.0.9': + resolution: {integrity: sha512-r3ElRr7y8ucyN2KdICwGsmj19RoN13CLCa/pvGydghWK6ZzeKQ+TcDjVdtEZz2ElpndM5jXw//B9CEee0mWnVg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} '@asamuzakjp/nwsapi@2.3.9': @@ -201,59 +213,59 @@ packages: resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} - '@biomejs/biome@2.4.9': - resolution: {integrity: sha512-wvZW92FrwitTcacvCBT8xdAbfbxWfDLwjYMmU3djjqQTh7Ni4ZdiWIT/x5VcZ+RQuxiKzIOzi5D+dcyJDFZMsA==} + '@biomejs/biome@2.4.11': + resolution: {integrity: sha512-nWxHX8tf3Opb/qRgZpBbsTOqOodkbrkJ7S+JxJAruxOReaDPPmPuLBAGQ8vigyUgo0QBB+oQltNEAvalLcjggA==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.4.9': - resolution: {integrity: sha512-d5G8Gf2RpH5pYwiHLPA+UpG3G9TLQu4WM+VK6sfL7K68AmhcEQ9r+nkj/DvR/GYhYox6twsHUtmWWWIKfcfQQA==} + '@biomejs/cli-darwin-arm64@2.4.11': + resolution: {integrity: sha512-wOt+ed+L2dgZanWyL6i29qlXMc088N11optzpo10peayObBaAshbTcxKUchzEMp9QSY8rh5h6VfAFE3WTS1rqg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.4.9': - resolution: {integrity: sha512-LNCLNgqDMG7BLdc3a8aY/dwKPK7+R8/JXJoXjCvZh2gx8KseqBdFDKbhrr7HCWF8SzNhbTaALhTBoh/I6rf9lA==} + '@biomejs/cli-darwin-x64@2.4.11': + resolution: {integrity: sha512-gZ6zR8XmZlExfi/Pz/PffmdpWOQ8Qhy7oBztgkR8/ylSRyLwfRPSadmiVCV8WQ8PoJ2MWUy2fgID9zmtgUUJmw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.4.9': - resolution: {integrity: sha512-8RCww5xnPn2wpK4L/QDGDOW0dq80uVWfppPxHIUg6mOs9B6gRmqPp32h1Ls3T8GnW8Wo5A8u7vpTwz4fExN+sw==} + '@biomejs/cli-linux-arm64-musl@2.4.11': + resolution: {integrity: sha512-+Sbo1OAmlegtdwqFE8iOxFIWLh1B3OEgsuZfBpyyN/kWuqZ8dx9ZEes6zVnDMo+zRHF2wLynRVhoQmV7ohxl2Q==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [musl] - '@biomejs/cli-linux-arm64@2.4.9': - resolution: {integrity: sha512-4adnkAUi6K4C/emPRgYznMOcLlUqZdXWM6aIui4VP4LraE764g6Q4YguygnAUoxKjKIXIWPteKMgRbN0wsgwcg==} + '@biomejs/cli-linux-arm64@2.4.11': + resolution: {integrity: sha512-avdJaEElXrKceK0va9FkJ4P5ci3N01TGkc6ni3P8l3BElqbOz42Wg2IyX3gbh0ZLEd4HVKEIrmuVu/AMuSeFFA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [glibc] - '@biomejs/cli-linux-x64-musl@2.4.9': - resolution: {integrity: sha512-5TD+WS9v5vzXKzjetF0hgoaNFHMcpQeBUwKKVi3JbG1e9UCrFuUK3Gt185fyTzvRdwYkJJEMqglRPjmesmVv4A==} + '@biomejs/cli-linux-x64-musl@2.4.11': + resolution: {integrity: sha512-bexd2IklK7ZgPhrz6jXzpIL6dEAH9MlJU1xGTrypx+FICxrXUp4CqtwfiuoDKse+UlgAlWtzML3jrMqeEAHEhA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [musl] - '@biomejs/cli-linux-x64@2.4.9': - resolution: {integrity: sha512-L10na7POF0Ks/cgLFNF1ZvIe+X4onLkTi5oP9hY+Rh60Q+7fWzKDDCeGyiHUFf1nGIa9dQOOUPGe2MyYg8nMSQ==} + '@biomejs/cli-linux-x64@2.4.11': + resolution: {integrity: sha512-TagWV0iomp5LnEnxWFg4nQO+e52Fow349vaX0Q/PIcX6Zhk4GGBgp3qqZ8PVkpC+cuehRctMf3+6+FgQ8jCEFQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [glibc] - '@biomejs/cli-win32-arm64@2.4.9': - resolution: {integrity: sha512-aDZr0RBC3sMGJOU10BvG7eZIlWLK/i51HRIfScE2lVhfts2dQTreowLiJJd+UYg/tHKxS470IbzpuKmd0MiD6g==} + '@biomejs/cli-win32-arm64@2.4.11': + resolution: {integrity: sha512-RJhaTnY8byzxDt4bDVb7AFPHkPcjOPK3xBip4ZRTrN3TEfyhjLRm3r3mqknqydgVTB74XG8l4jMLwEACEeihVg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.4.9': - resolution: {integrity: sha512-NS4g/2G9SoQ4ktKtz31pvyc/rmgzlcIDCGU/zWbmHJAqx6gcRj2gj5Q/guXhoWTzCUaQZDIqiCQXHS7BcGYc0w==} + '@biomejs/cli-win32-x64@2.4.11': + resolution: {integrity: sha512-A8D3JM/00C2KQgUV3oj8Ba15EHEYwebAGCy5Sf9GAjr5Y3+kJIYOiESoqRDeuRZueuMdCsbLZIUqmPhpYXJE9A==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -331,12 +343,12 @@ packages: resolution: {integrity: sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==} engines: {node: '>=v18'} - '@conventional-changelog/git-client@2.6.0': - resolution: {integrity: sha512-T+uPDciKf0/ioNNDpMGc8FDsehJClZP0yR3Q5MN6wE/Y/1QZ7F+80OgznnTCOlMEG4AV0LvH2UJi3C/nBnaBUg==} + '@conventional-changelog/git-client@2.7.0': + resolution: {integrity: sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==} engines: {node: '>=18'} peerDependencies: conventional-commits-filter: ^5.0.0 - conventional-commits-parser: ^6.3.0 + conventional-commits-parser: ^6.4.0 peerDependenciesMeta: conventional-commits-filter: optional: true @@ -407,8 +419,8 @@ packages: '@emnapi/core@1.9.1': resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} - '@emnapi/runtime@1.9.1': - resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==} + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} '@emnapi/wasi-threads@1.2.0': resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} @@ -424,158 +436,158 @@ packages: resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} deprecated: 'Merged into tsx: https://tsx.is' - '@esbuild/aix-ppc64@0.27.4': - resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.4': - resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.4': - resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.4': - resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.4': - resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.4': - resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.4': - resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.4': - resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.27.4': - resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.4': - resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.4': - resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.4': - resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.4': - resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.4': - resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.4': - resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.27.4': - resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.4': - resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.4': - resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.4': - resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.4': - resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.4': - resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.4': - resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.4': - resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.4': - resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.4': - resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.27.4': - resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -596,6 +608,10 @@ packages: resolution: {integrity: sha512-H9XAx3hc0BQHY6l+IFSWHDySypcXsvsuLhgYLUGywmJ5pswRVQJUHpOsobnLYp2ZUaUlKiKDrgWWhosOwAEM8Q==} engines: {node: '>=6.9.0'} + '@hutson/parse-repository-url@5.0.0': + resolution: {integrity: sha512-e5+YUKENATs1JgYHMzTr2MW/NDcXGfYFAuOQU8gJgF/kEh4EqKgfGrfLI67bMD4tbhZVlkigz/9YYwWcbOFthg==} + engines: {node: '>=10.13.0'} + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -765,60 +781,63 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@napi-rs/wasm-runtime@1.1.1': - resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@napi-rs/wasm-runtime@1.1.3': + resolution: {integrity: sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 - '@next/env@16.2.1': - resolution: {integrity: sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==} + '@next/env@16.2.2': + resolution: {integrity: sha512-LqSGz5+xGk9EL/iBDr2yo/CgNQV6cFsNhRR2xhSXYh7B/hb4nePCxlmDvGEKG30NMHDFf0raqSyOZiQrO7BkHQ==} - '@next/swc-darwin-arm64@16.2.1': - resolution: {integrity: sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==} + '@next/swc-darwin-arm64@16.2.2': + resolution: {integrity: sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.1': - resolution: {integrity: sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==} + '@next/swc-darwin-x64@16.2.2': + resolution: {integrity: sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.1': - resolution: {integrity: sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==} + '@next/swc-linux-arm64-gnu@16.2.2': + resolution: {integrity: sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.2.1': - resolution: {integrity: sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==} + '@next/swc-linux-arm64-musl@16.2.2': + resolution: {integrity: sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.2.1': - resolution: {integrity: sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==} + '@next/swc-linux-x64-gnu@16.2.2': + resolution: {integrity: sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.2.1': - resolution: {integrity: sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==} + '@next/swc-linux-x64-musl@16.2.2': + resolution: {integrity: sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.2.1': - resolution: {integrity: sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==} + '@next/swc-win32-arm64-msvc@16.2.2': + resolution: {integrity: sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.1': - resolution: {integrity: sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==} + '@next/swc-win32-x64-msvc@16.2.2': + resolution: {integrity: sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -839,135 +858,135 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@oxc-parser/binding-android-arm-eabi@0.120.0': - resolution: {integrity: sha512-WU3qtINx802wOl8RxAF1v0VvmC2O4D9M8Sv486nLeQ7iPHVmncYZrtBhB4SYyX+XZxj2PNnCcN+PW21jHgiOxg==} + '@oxc-parser/binding-android-arm-eabi@0.121.0': + resolution: {integrity: sha512-n07FQcySwOlzap424/PLMtOkbS7xOu8nsJduKL8P3COGHKgKoDYXwoAHCbChfgFpHnviehrLWIPX0lKGtbEk/A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxc-parser/binding-android-arm64@0.120.0': - resolution: {integrity: sha512-SEf80EHdhlbjZEgzeWm0ZA/br4GKMenDW3QB/gtyeTV1gStvvZeFi40ioHDZvds2m4Z9J1bUAUL8yn1/+A6iGg==} + '@oxc-parser/binding-android-arm64@0.121.0': + resolution: {integrity: sha512-/Dd1xIXboYAicw+twT2utxPD7bL8qh7d3ej0qvaYIMj3/EgIrGR+tSnjCUkiCT6g6uTC0neSS4JY8LxhdSU/sA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxc-parser/binding-darwin-arm64@0.120.0': - resolution: {integrity: sha512-xVrrbCai8R8CUIBu3CjryutQnEYhZqs1maIqDvtUCFZb8vY33H7uh9mHpL3a0JBIKoBUKjPH8+rzyAeXnS2d6A==} + '@oxc-parser/binding-darwin-arm64@0.121.0': + resolution: {integrity: sha512-A0jNEvv7QMtCO1yk205t3DWU9sWUjQ2KNF0hSVO5W9R9r/R1BIvzG01UQAfmtC0dQm7sCrs5puixurKSfr2bRQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxc-parser/binding-darwin-x64@0.120.0': - resolution: {integrity: sha512-xyHBbnJ6mydnQUH7MAcafOkkrNzQC6T+LXgDH/3InEq2BWl/g424IMRiJVSpVqGjB+p2bd0h0WRR8iIwzjU7rw==} + '@oxc-parser/binding-darwin-x64@0.121.0': + resolution: {integrity: sha512-SsHzipdxTKUs3I9EOAPmnIimEeJOemqRlRDOp9LIj+96wtxZejF51gNibmoGq8KoqbT1ssAI5po/E3J+vEtXGA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxc-parser/binding-freebsd-x64@0.120.0': - resolution: {integrity: sha512-UMnVRllquXUYTeNfFKmxTTEdZ/ix1nLl0ducDzMSREoWYGVIHnOOxoKMWlCOvRr9Wk/HZqo2rh1jeumbPGPV9A==} + '@oxc-parser/binding-freebsd-x64@0.121.0': + resolution: {integrity: sha512-v1APOTkCp+RWOIDAHRoaeW/UoaHF15a60E8eUL6kUQXh+i4K7PBwq2Wi7jm8p0ymID5/m/oC1w3W31Z/+r7HQw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxc-parser/binding-linux-arm-gnueabihf@0.120.0': - resolution: {integrity: sha512-tkvn2CQ7QdcsMnpfiX3fd3wA3EFsWKYlcQzq9cFw/xc89Al7W6Y4O0FgLVkVQpo0Tnq/qtE1XfkJOnRRA9S/NA==} + '@oxc-parser/binding-linux-arm-gnueabihf@0.121.0': + resolution: {integrity: sha512-PmqPQuqHZyFVWA4ycr0eu4VnTMmq9laOHZd+8R359w6kzuNZPvmmunmNJ8ybkm769A0nCoVp3TJ6dUz7B3FYIQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm-musleabihf@0.120.0': - resolution: {integrity: sha512-WN5y135Ic42gQDk9grbwY9++fDhqf8knN6fnP+0WALlAUh4odY/BDK1nfTJRSfpJD9P3r1BwU0m3pW2DU89whQ==} + '@oxc-parser/binding-linux-arm-musleabihf@0.121.0': + resolution: {integrity: sha512-vF24htj+MOH+Q7y9A8NuC6pUZu8t/C2Fr/kDOi2OcNf28oogr2xadBPXAbml802E8wRAVfbta6YLDQTearz+jw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm64-gnu@0.120.0': - resolution: {integrity: sha512-1GgQBCcXvFMw99EPdMy+4NZ3aYyXsxjf9kbUUg8HuAy3ZBXzOry5KfFEzT9nqmgZI1cuetvApkiJBZLAPo8uaw==} + '@oxc-parser/binding-linux-arm64-gnu@0.121.0': + resolution: {integrity: sha512-wjH8cIG2Lu/3d64iZpbYr73hREMgKAfu7fqpXjgM2S16y2zhTfDIp8EQjxO8vlDtKP5Rc7waZW72lh8nZtWrpA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-arm64-musl@0.120.0': - resolution: {integrity: sha512-gmMQ70gsPdDBgpcErvJEoWNBr7bJooSLlvOBVBSGfOzlP5NvJ3bFvnUeZZ9d+dPrqSngtonf7nyzWUTUj/U+lw==} + '@oxc-parser/binding-linux-arm64-musl@0.121.0': + resolution: {integrity: sha512-qT663J/W8yQFw3dtscbEi9LKJevr20V7uWs2MPGTnvNZ3rm8anhhE16gXGpxDOHeg9raySaSHKhd4IGa3YZvuw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-ppc64-gnu@0.120.0': - resolution: {integrity: sha512-T/kZuU0ajop0xhzVMwH5r3srC9Nqup5HaIo+3uFjIN5uPxa0LvSxC1ZqP4aQGJVW5G0z8/nCkjIfSMS91P/wzw==} + '@oxc-parser/binding-linux-ppc64-gnu@0.121.0': + resolution: {integrity: sha512-mYNe4NhVvDBbPkAP8JaVS8lC1dsoJZWH5WCjpw5E+sjhk1R08wt3NnXYUzum7tIiWPfgQxbCMcoxgeemFASbRw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-gnu@0.120.0': - resolution: {integrity: sha512-vn21KXLAXzaI3N5CZWlBr1iWeXLl9QFIMor7S1hUjUGTeUuWCoE6JZB040/ZNDwf+JXPX8Ao9KbmJq9FMC2iGw==} + '@oxc-parser/binding-linux-riscv64-gnu@0.121.0': + resolution: {integrity: sha512-+QiFoGxhAbaI/amqX567784cDyyuZIpinBrJNxUzb+/L2aBRX67mN6Jv40pqduHf15yYByI+K5gUEygCuv0z9w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-musl@0.120.0': - resolution: {integrity: sha512-SUbUxlar007LTGmSLGIC5x/WJvwhdX+PwNzFJ9f/nOzZOrCFbOT4ikt7pJIRg1tXVsEfzk5mWpGO1NFiSs4PIw==} + '@oxc-parser/binding-linux-riscv64-musl@0.121.0': + resolution: {integrity: sha512-9ykEgyTa5JD/Uhv2sttbKnCfl2PieUfOjyxJC/oDL2UO0qtXOtjPLl7H8Kaj5G7p3hIvFgu3YWvAxvE0sqY+hQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-s390x-gnu@0.120.0': - resolution: {integrity: sha512-hYiPJTxyfJY2+lMBFk3p2bo0R9GN+TtpPFlRqVchL1qvLG+pznstramHNvJlw9AjaoRUHwp9IKR7UZQnRPGjgQ==} + '@oxc-parser/binding-linux-s390x-gnu@0.121.0': + resolution: {integrity: sha512-DB1EW5VHZdc1lIRjOI3bW/wV6R6y0xlfvdVrqj6kKi7Ayu2U3UqUBdq9KviVkcUGd5Oq+dROqvUEEFRXGAM7EQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-gnu@0.120.0': - resolution: {integrity: sha512-q+5jSVZkprJCIy3dzJpApat0InJaoxQLsJuD6DkX8hrUS61z2lHQ1Fe9L2+TYbKHXCLWbL0zXe7ovkIdopBGMQ==} + '@oxc-parser/binding-linux-x64-gnu@0.121.0': + resolution: {integrity: sha512-s4lfobX9p4kPTclvMiH3gcQUd88VlnkMTF6n2MTMDAyX5FPNRhhRSFZK05Ykhf8Zy5NibV4PbGR6DnK7FGNN6A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-musl@0.120.0': - resolution: {integrity: sha512-D9QDDZNnH24e7X4ftSa6ar/2hCavETfW3uk0zgcMIrZNy459O5deTbWrjGzZiVrSWigGtlQwzs2McBP0QsfV1w==} + '@oxc-parser/binding-linux-x64-musl@0.121.0': + resolution: {integrity: sha512-P9KlyTpuBuMi3NRGpJO8MicuGZfOoqZVRP1WjOecwx8yk4L/+mrCRNc5egSi0byhuReblBF2oVoDSMgV9Bj4Hw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxc-parser/binding-openharmony-arm64@0.120.0': - resolution: {integrity: sha512-TBU8ZwOUWAOUWVfmI16CYWbvh4uQb9zHnGBHsw5Cp2JUVG044OIY1CSHODLifqzQIMTXvDvLzcL89GGdUIqNrA==} + '@oxc-parser/binding-openharmony-arm64@0.121.0': + resolution: {integrity: sha512-R+4jrWOfF2OAPPhj3Eb3U5CaKNAH9/btMveMULIrcNW/hjfysFQlF8wE0GaVBr81dWz8JLgQlsxwctoL78JwXw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxc-parser/binding-wasm32-wasi@0.120.0': - resolution: {integrity: sha512-WG/FOZgDJCpJnuF3ToG/K28rcOmSY7FmFmfBKYb2fmLyhDzPpUldFGV7/Fz4ru0Iz/v4KPmf8xVgO8N3lO4KHA==} + '@oxc-parser/binding-wasm32-wasi@0.121.0': + resolution: {integrity: sha512-5TFISkPTymKvsmIlKasPVTPuWxzCcrT8pM+p77+mtQbIZDd1UC8zww4CJcRI46kolmgrEX6QpKO8AvWMVZ+ifw==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@oxc-parser/binding-win32-arm64-msvc@0.120.0': - resolution: {integrity: sha512-1T0HKGcsz/BKo77t7+89L8Qvu4f9DoleKWHp3C5sJEcbCjDOLx3m9m722bWZTY+hANlUEs+yjlK+lBFsA+vrVQ==} + '@oxc-parser/binding-win32-arm64-msvc@0.121.0': + resolution: {integrity: sha512-V0pxh4mql4XTt3aiEtRNUeBAUFOw5jzZNxPABLaOKAWrVzSr9+XUaB095lY7jqMf5t8vkfh8NManGB28zanYKw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxc-parser/binding-win32-ia32-msvc@0.120.0': - resolution: {integrity: sha512-L7vfLzbOXsjBXV0rv/6Y3Jd9BRjPeCivINZAqrSyAOZN3moCopDN+Psq9ZrGNZtJzP8946MtlRFZ0Als0wBCOw==} + '@oxc-parser/binding-win32-ia32-msvc@0.121.0': + resolution: {integrity: sha512-4Ob1qvYMPnlF2N9rdmKdkQFdrq16QVcQwBsO8yiPZXof0fHKFF+LmQV501XFbi7lHyrKm8rlJRfQ/M8bZZPVLw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxc-parser/binding-win32-x64-msvc@0.120.0': - resolution: {integrity: sha512-ys+upfqNtSu58huAhJMBKl3XCkGzyVFBlMlGPzHeFKgpFF/OdgNs1MMf8oaJIbgMH8ZxgGF7qfue39eJohmKIg==} + '@oxc-parser/binding-win32-x64-msvc@0.121.0': + resolution: {integrity: sha512-BOp1KCzdboB1tPqoCPXgntgFs0jjeSyOXHzgxVFR7B/qfr3F8r4YDacHkTOUNXtDgM8YwKnkf3rE5gwALYX7NA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@oxc-project/types@0.120.0': - resolution: {integrity: sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==} + '@oxc-project/types@0.121.0': + resolution: {integrity: sha512-CGtOARQb9tyv7ECgdAlFxi0Fv7lmzvmlm2rpD/RdijOO9rfk/JvB1CjT8EnoD+tjna/IYgKKw3IV7objRb+aYw==} '@oxc-resolver/binding-android-arm-eabi@11.19.1': resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==} @@ -1084,141 +1103,141 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@rollup/rollup-android-arm-eabi@4.60.0': - resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==} + '@rollup/rollup-android-arm-eabi@4.60.1': + resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.60.0': - resolution: {integrity: sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==} + '@rollup/rollup-android-arm64@4.60.1': + resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.60.0': - resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==} + '@rollup/rollup-darwin-arm64@4.60.1': + resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.60.0': - resolution: {integrity: sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==} + '@rollup/rollup-darwin-x64@4.60.1': + resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.60.0': - resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==} + '@rollup/rollup-freebsd-arm64@4.60.1': + resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.60.0': - resolution: {integrity: sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==} + '@rollup/rollup-freebsd-x64@4.60.1': + resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.60.0': - resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} cpu: [arm] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.60.0': - resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} cpu: [arm] os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.60.0': - resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} + '@rollup/rollup-linux-arm64-gnu@4.60.1': + resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.60.0': - resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} + '@rollup/rollup-linux-arm64-musl@4.60.1': + resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.60.0': - resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} + '@rollup/rollup-linux-loong64-gnu@4.60.1': + resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} cpu: [loong64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.60.0': - resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} + '@rollup/rollup-linux-loong64-musl@4.60.1': + resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} cpu: [loong64] os: [linux] libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.60.0': - resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} cpu: [ppc64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.60.0': - resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} + '@rollup/rollup-linux-ppc64-musl@4.60.1': + resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} cpu: [ppc64] os: [linux] libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.60.0': - resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} cpu: [riscv64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.60.0': - resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} + '@rollup/rollup-linux-riscv64-musl@4.60.1': + resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} cpu: [riscv64] os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.60.0': - resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} + '@rollup/rollup-linux-s390x-gnu@4.60.1': + resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} cpu: [s390x] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.60.0': - resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} + '@rollup/rollup-linux-x64-gnu@4.60.1': + resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.60.0': - resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} + '@rollup/rollup-linux-x64-musl@4.60.1': + resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.60.0': - resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} + '@rollup/rollup-openbsd-x64@4.60.1': + resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.60.0': - resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==} + '@rollup/rollup-openharmony-arm64@4.60.1': + resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.60.0': - resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==} + '@rollup/rollup-win32-arm64-msvc@4.60.1': + resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.60.0': - resolution: {integrity: sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==} + '@rollup/rollup-win32-ia32-msvc@4.60.1': + resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.60.0': - resolution: {integrity: sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==} + '@rollup/rollup-win32-x64-gnu@4.60.1': + resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.60.0': - resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==} + '@rollup/rollup-win32-x64-msvc@4.60.1': + resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} cpu: [x64] os: [win32] @@ -1333,11 +1352,11 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' - '@tanstack/query-core@5.95.2': - resolution: {integrity: sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==} + '@tanstack/query-core@5.97.0': + resolution: {integrity: sha512-QdpLP5VzVMgo4VtaPppRA2W04UFjIqX+bxke/ZJhE5cfd5UPkRzqIAJQt9uXkQJjqE8LBOMbKv7f8HCsZltXlg==} - '@tanstack/react-query@5.95.2': - resolution: {integrity: sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==} + '@tanstack/react-query@5.97.0': + resolution: {integrity: sha512-y4So4eGcQoK2WVMAcDNZE9ofB/p5v1OlKvtc1F3uqHwrtifobT7q+ZnXk2mRkc8E84HKYSlAE9z6HXl2V0+ySQ==} peerDependencies: react: ^18 || ^19 @@ -1394,6 +1413,9 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/jsdom@28.0.1': + resolution: {integrity: sha512-GJq2QE4TAZ5ajSoCasn5DOFm8u1mI3tIFvM5tIq3W5U/RTB6gsHwc6Yhpl91X9VSDOUVblgXmG+2+sSvFQrdlw==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -1406,8 +1428,8 @@ packages: '@types/node-cron@3.0.11': resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==} - '@types/node@25.5.0': - resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@types/node@25.5.2': + resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==} '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -1420,6 +1442,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -1429,11 +1454,11 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - '@vitest/expect@4.1.2': - resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==} + '@vitest/expect@4.1.4': + resolution: {integrity: sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==} - '@vitest/mocker@4.1.2': - resolution: {integrity: sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==} + '@vitest/mocker@4.1.4': + resolution: {integrity: sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -1443,20 +1468,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.2': - resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==} + '@vitest/pretty-format@4.1.4': + resolution: {integrity: sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==} - '@vitest/runner@4.1.2': - resolution: {integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==} + '@vitest/runner@4.1.4': + resolution: {integrity: sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==} - '@vitest/snapshot@4.1.2': - resolution: {integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==} + '@vitest/snapshot@4.1.4': + resolution: {integrity: sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==} - '@vitest/spy@4.1.2': - resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==} + '@vitest/spy@4.1.4': + resolution: {integrity: sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==} - '@vitest/utils@4.1.2': - resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==} + '@vitest/utils@4.1.4': + resolution: {integrity: sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==} JSONStream@1.3.5: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} @@ -1465,9 +1490,9 @@ packages: add-stream@1.0.0: resolution: {integrity: sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ==} - agent-base@8.0.0: - resolution: {integrity: sha512-QT8i0hCz6C/KQ+KTAbSNwCHDGdmUJl2tp2ZpNlGSWCfhUNVbYG2WLE3MdZGBAgXPV4GAvjGMxo+C1hroyxmZEg==} - engines: {node: '>= 14'} + agent-base@9.0.0: + resolution: {integrity: sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==} + engines: {node: '>= 20'} ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} @@ -1523,16 +1548,19 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - baseline-browser-mapping@2.10.11: - resolution: {integrity: sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==} + baseline-browser-mapping@2.10.17: + resolution: {integrity: sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==} engines: {node: '>=6.0.0'} hasBin: true bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@1.1.13: + resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -1553,8 +1581,8 @@ packages: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} - caniuse-lite@1.0.30001781: - resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} + caniuse-lite@1.0.30001787: + resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -1637,18 +1665,32 @@ packages: resolution: {integrity: sha512-6qLgrBF4gueoC7AFVHu51nHL9pF9FRjXrH+ceVf7WmAfH3gs+gEYOkvxhjMPjZu57I4AGUGoNTY8V7Hrgf1uqg==} engines: {node: '>=14'} - conventional-changelog-angular@8.3.0: - resolution: {integrity: sha512-DOuBwYSqWzfwuRByY9O4oOIvDlkUCTDzfbOgcSbkY+imXXj+4tmrEFao3K+FxemClYfYnZzsvudbwrhje9VHDA==} + conventional-changelog-angular@8.3.1: + resolution: {integrity: sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==} engines: {node: '>=18'} conventional-changelog-atom@3.0.0: resolution: {integrity: sha512-pnN5bWpH+iTUWU3FaYdw5lJmfWeqSyrUkG+wyHBI9tC1dLNnHkbAOg1SzTQ7zBqiFrfo55h40VsGXWMdopwc5g==} engines: {node: '>=14'} + conventional-changelog-atom@5.1.0: + resolution: {integrity: sha512-fw7GpI9jHNCWGBnTsPRI452ypQbNupGwsjrXfozvRNE0c92pJRpoj9rXfzDKUYJcsmk0H4XKaQjhjelwI9z27w==} + engines: {node: '>=18'} + + conventional-changelog-cli@5.0.0: + resolution: {integrity: sha512-9Y8fucJe18/6ef6ZlyIlT2YQUbczvoQZZuYmDLaGvcSBP+M6h+LAvf7ON7waRxKJemcCII8Yqu5/8HEfskTxJQ==} + engines: {node: '>=18'} + deprecated: This package is no longer maintained. Please use the conventional-changelog package instead. + hasBin: true + conventional-changelog-codemirror@3.0.0: resolution: {integrity: sha512-wzchZt9HEaAZrenZAUUHMCFcuYzGoZ1wG/kTRMICxsnW5AXohYMRxnyecP9ob42Gvn5TilhC0q66AtTPRSNMfw==} engines: {node: '>=14'} + conventional-changelog-codemirror@5.1.0: + resolution: {integrity: sha512-iXhy63YczB+yWA9DrsYbquSYLvWKsK9M3WC+xQPEm8cOn4oXzKpmTp2uH3qi7+i10oTcGJTvq9lsBpZmMADaNg==} + engines: {node: '>=18'} + conventional-changelog-config-spec@2.1.0: resolution: {integrity: sha512-IpVePh16EbbB02V+UA+HQnnPIohgXvJRxHcS5+Uwk4AT5LjzCZJm5sp/yqs5C6KZJ1jMsV4paEV13BN1pvDuxQ==} @@ -1656,58 +1698,103 @@ packages: resolution: {integrity: sha512-3cS3GEtR78zTfMzk0AizXKKIdN4OvSh7ibNz6/DPbhWWQu7LqE/8+/GqSodV+sywUR2gpJAdP/1JFf4XtN7Zpw==} engines: {node: '>=14'} - conventional-changelog-conventionalcommits@9.3.0: - resolution: {integrity: sha512-kYFx6gAyjSIMwNtASkI3ZE99U1fuVDJr0yTYgVy+I2QG46zNZfl2her+0+eoviG82c5WQvW1jMt1eOQTeJLodA==} + conventional-changelog-conventionalcommits@8.0.0: + resolution: {integrity: sha512-eOvlTO6OcySPyyyk8pKz2dP4jjElYunj9hn9/s0OB+gapTO8zwS9UQWrZ1pmF2hFs3vw1xhonOLGcGjy/zgsuA==} + engines: {node: '>=18'} + + conventional-changelog-conventionalcommits@9.3.1: + resolution: {integrity: sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==} engines: {node: '>=18'} conventional-changelog-core@5.0.2: resolution: {integrity: sha512-RhQOcDweXNWvlRwUDCpaqXzbZemKPKncCWZG50Alth72WITVd6nhVk9MJ6w1k9PFNBcZ3YwkdkChE+8+ZwtUug==} engines: {node: '>=14'} + conventional-changelog-core@8.0.0: + resolution: {integrity: sha512-EATUx5y9xewpEe10UEGNpbSHRC6cVZgO+hXQjofMqpy+gFIrcGvH3Fl6yk2VFKh7m+ffenup2N7SZJYpyD9evw==} + engines: {node: '>=18'} + conventional-changelog-ember@3.0.0: resolution: {integrity: sha512-7PYthCoSxIS98vWhVcSphMYM322OxptpKAuHYdVspryI0ooLDehRXWeRWgN+zWSBXKl/pwdgAg8IpLNSM1/61A==} engines: {node: '>=14'} + conventional-changelog-ember@5.1.0: + resolution: {integrity: sha512-XNcgGcdJt7wh341BBML0CI8DKpqE5lKD1WahzFHGZFvKTzJr1rZW976cw7beqKLOBbzdrH9ZIkE/s2TfbOuM3g==} + engines: {node: '>=18'} + conventional-changelog-eslint@4.0.0: resolution: {integrity: sha512-nEZ9byP89hIU0dMx37JXQkE1IpMmqKtsaR24X7aM3L6Yy/uAtbb+ogqthuNYJkeO1HyvK7JsX84z8649hvp43Q==} engines: {node: '>=14'} + conventional-changelog-eslint@6.1.0: + resolution: {integrity: sha512-beWr3qzuEMN9gznMWa8PhTVfGkGXoq+XnUzViNXg5KygrgV728ZRqZngz3uPhz5+ayUhPrpNFYqIE0qHWz9NAw==} + engines: {node: '>=18'} + conventional-changelog-express@3.0.0: resolution: {integrity: sha512-HqxihpUMfIuxvlPvC6HltA4ZktQEUan/v3XQ77+/zbu8No/fqK3rxSZaYeHYant7zRxQNIIli7S+qLS9tX9zQA==} engines: {node: '>=14'} + conventional-changelog-express@5.1.0: + resolution: {integrity: sha512-g/s9eLohrefYTSNQaB6+k0ONbiVx41YOKBbIOIM3ST/NtedAgppCJnrpKXVN9sOmpPkN4vjFwURlfvpEDUjoeg==} + engines: {node: '>=18'} + conventional-changelog-jquery@4.0.0: resolution: {integrity: sha512-TTIN5CyzRMf8PUwyy4IOLmLV2DFmPtasKN+x7EQKzwSX8086XYwo+NeaeA3VUT8bvKaIy5z/JoWUvi7huUOgaw==} engines: {node: '>=14'} + conventional-changelog-jquery@6.1.0: + resolution: {integrity: sha512-/sFhULybhFrMg+qc8MHHHSj7kTVMfx5C7rSM6Z9EjduVoAQJdGRq/wpv/SWPMQ+KPNSYHqDLwm/x2Z5hOcYvqQ==} + engines: {node: '>=18'} + conventional-changelog-jshint@3.0.0: resolution: {integrity: sha512-bQof4byF4q+n+dwFRkJ/jGf9dCNUv4/kCDcjeCizBvfF81TeimPZBB6fT4HYbXgxxfxWXNl/i+J6T0nI4by6DA==} engines: {node: '>=14'} + conventional-changelog-jshint@5.2.0: + resolution: {integrity: sha512-OaatyvHXP1fjI7Mx0b1IkmhbhTsVHsytnsQSkOj4rhGbFMoTcfvbwm/vAtCzRMXOxojK1EDMBBmBj1pM9KNy/Q==} + engines: {node: '>=18'} + conventional-changelog-preset-loader@3.0.0: resolution: {integrity: sha512-qy9XbdSLmVnwnvzEisjxdDiLA4OmV3o8db+Zdg4WiFw14fP3B6XNz98X0swPPpkTd/pc1K7+adKgEDM1JCUMiA==} engines: {node: '>=14'} + conventional-changelog-preset-loader@5.0.0: + resolution: {integrity: sha512-SetDSntXLk8Jh1NOAl1Gu5uLiCNSYenB5tm0YVeZKePRIgDW9lQImromTwLa3c/Gae298tsgOM+/CYT9XAl0NA==} + engines: {node: '>=18'} + conventional-changelog-writer@6.0.1: resolution: {integrity: sha512-359t9aHorPw+U+nHzUXHS5ZnPBOizRxfQsWT5ZDHBfvfxQOAik+yfuhKXG66CN5LEWPpMNnIMHUTCKeYNprvHQ==} engines: {node: '>=14'} hasBin: true + conventional-changelog-writer@8.4.0: + resolution: {integrity: sha512-HHBFkk1EECxxmCi4CTu091iuDpQv5/OavuCUAuZmrkWpmYfyD816nom1CvtfXJ/uYfAAjavgHvXHX291tSLK8g==} + engines: {node: '>=18'} + hasBin: true + conventional-changelog@4.0.0: resolution: {integrity: sha512-JbZjwE1PzxQCvm+HUTIr+pbSekS8qdOZzMakdFyPtdkEWwFvwEJYONzjgMm0txCb2yBcIcfKDmg8xtCKTdecNQ==} engines: {node: '>=14'} + conventional-changelog@6.0.0: + resolution: {integrity: sha512-tuUH8H/19VjtD9Ig7l6TQRh+Z0Yt0NZ6w/cCkkyzUbGQTnUEmKfGtkC9gGfVgCfOL1Rzno5NgNF4KY8vR+Jo3w==} + engines: {node: '>=18'} + conventional-commits-filter@3.0.0: resolution: {integrity: sha512-1ymej8b5LouPx9Ox0Dw/qAO2dVdfpRFq28e5Y0jJEU8ZrLdy0vOSkkIInwmxErFGhg6SALro60ZrwYFVTUDo4Q==} engines: {node: '>=14'} + conventional-commits-filter@5.0.0: + resolution: {integrity: sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==} + engines: {node: '>=18'} + conventional-commits-parser@4.0.0: resolution: {integrity: sha512-WRv5j1FsVM5FISJkoYMR6tPk07fkKT0UodruX4je86V4owk451yjXAKzKAPOs9l7y59E2viHUS9eQ+dfUA9NSg==} engines: {node: '>=14'} hasBin: true - conventional-commits-parser@6.3.0: - resolution: {integrity: sha512-RfOq/Cqy9xV9bOA8N+ZH6DlrDR+5S3Mi0B5kACEjESpE+AviIpAptx9a9cFpWCCvgRtWT+0BbUw+e1BZfts9jg==} + conventional-commits-parser@6.4.0: + resolution: {integrity: sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==} engines: {node: '>=18'} hasBin: true @@ -1722,8 +1809,8 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - cosmiconfig-typescript-loader@6.2.0: - resolution: {integrity: sha512-GEN39v7TgdxgIoNcdkRE3uiAzQt3UXLyHbRHD6YoL048XAeOomyxaP+Hh/+2C6C2wYjxJ2onhJcsQp+L4YEkVQ==} + cosmiconfig-typescript-loader@6.3.0: + resolution: {integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==} engines: {node: '>=v18'} peerDependencies: '@types/node': '*' @@ -1748,10 +1835,17 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} 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'} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -1825,12 +1919,25 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dot-prop@5.3.0: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} - dotenv@17.3.1: - resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} + dotenv@17.4.1: + resolution: {integrity: sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==} engines: {node: '>=12'} dotgitignore@2.1.0: @@ -1841,8 +1948,8 @@ packages: resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} hasBin: true - drizzle-orm@0.45.1: - resolution: {integrity: sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==} + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} peerDependencies: '@aws-sdk/client-rds-data': '>=3' '@cloudflare/workers-types': '>=4' @@ -1963,6 +2070,10 @@ packages: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} + 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'} @@ -1977,8 +2088,8 @@ packages: es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} - esbuild@0.27.4: - resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} engines: {node: '>=18'} hasBin: true @@ -2026,8 +2137,8 @@ packages: fast-xml-builder@1.1.4: resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} - fast-xml-parser@5.5.9: - resolution: {integrity: sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==} + fast-xml-parser@5.5.11: + resolution: {integrity: sha512-QL0eb0YbSTVWF6tTf1+LEMSgtCEjBYPpnAjoLC8SscESlAjXEIRJ7cHtLG0pLeDFaZLa4VKZLArtA/60ZS7vyA==} hasBin: true fastq@1.20.1: @@ -2053,6 +2164,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-up-simple@1.0.1: + resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} + engines: {node: '>=18'} + find-up@2.1.0: resolution: {integrity: sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==} engines: {node: '>=4'} @@ -2118,6 +2233,11 @@ packages: deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. hasBin: true + git-semver-tags@8.0.1: + resolution: {integrity: sha512-zMbamckSNdlT4U48IMFa2Cn6FTzM+2yF6/gEmStPJI8PiLxd/bT6dw10+mc6u5Qe4fhrc/y9nU290FWjQhAV7g==} + engines: {node: '>=18'} + hasBin: true + gitconfiglocal@1.0.0: resolution: {integrity: sha512-spLUXeTAVHxDtKsJc8FkFVgFtMdEN9qPGpL23VfSHx4fP4+Ds097IXLvymbnDH8FnmxX5Nr9bPw3A+AQ6mWEaQ==} @@ -2155,6 +2275,10 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} @@ -2165,6 +2289,10 @@ packages: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} + hosted-git-info@7.0.2: + resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} + engines: {node: ^16.14.0 || >=18.0.0} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -2172,9 +2300,9 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} - https-proxy-agent@8.0.0: - resolution: {integrity: sha512-YYeW+iCnAS3xhvj2dvVoWgsbca3RfQy/IlaNHHOtDmU0jMqPI9euIq3Y9BJETdxk16h9NHHCKqp/KB9nIMStCQ==} - engines: {node: '>= 14'} + https-proxy-agent@9.0.0: + resolution: {integrity: sha512-/MVmHp58WkOypgFhCLk4fzpPcFQvTJ/e6LBI7irpIO2HfxUbpmYoHF+KzipzJpxxzJu7aJNWQ0xojJ/dzV2G5g==} + engines: {node: '>= 20'} husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} @@ -2192,6 +2320,10 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -2287,8 +2419,8 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true - jsdom@29.0.1: - resolution: {integrity: sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==} + jsdom@29.0.2: + resolution: {integrity: sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==} engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} peerDependencies: canvas: ^3.0.0 @@ -2316,8 +2448,8 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} - knip@6.0.6: - resolution: {integrity: sha512-PA+r1mTDLHH3eShlffn2ZDyH1hHvmgDj7JsTP3JKuhV/jZTyHbRkGcOd+uaSxfJZmcZyOE5zw3naP33WllTIlA==} + knip@6.3.1: + resolution: {integrity: sha512-22kLJloVcOVOAudCxlFOC0ICAMme7dKsS7pVTEnrmyKGpswb8ieznvAiSKUeFVDJhb01ect6dkDc1Ha1g1sPpg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -2442,8 +2574,11 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} - lru-cache@11.2.7: - resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.3.3: + resolution: {integrity: sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==} engines: {node: 20 || >=22} lru-cache@6.0.0: @@ -2645,8 +2780,8 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - next@16.2.1: - resolution: {integrity: sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==} + next@16.2.2: + resolution: {integrity: sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -2678,6 +2813,9 @@ packages: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true + node-html-parser@7.1.0: + resolution: {integrity: sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==} + normalize-package-data@2.5.0: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} @@ -2685,6 +2823,13 @@ packages: resolution: {integrity: sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==} engines: {node: '>=10'} + normalize-package-data@6.0.2: + resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} + engines: {node: ^16.14.0 || >=18.0.0} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -2698,8 +2843,8 @@ packages: otpauth@9.5.0: resolution: {integrity: sha512-Ldhc6UYl4baR5toGr8nfKC+L/b8/RgHKoIixAebgoNGzUUCET02g04rMEZ2ZsPfeVQhMHcuaOgb28nwMr81zCA==} - oxc-parser@0.120.0: - resolution: {integrity: sha512-WyPWZlcIm+Fkte63FGfgFB8mAAk33aH9h5N9lphXVOHSXEBFFsmYdOBedVKly363aWABjZdaj/m9lBfEY4wt+w==} + oxc-parser@0.121.0: + resolution: {integrity: sha512-ek9o58+SCv6AV7nchiAcUJy1DNE2CC5WRdBcO0mF+W4oRjNQfPO7b3pLjTHSFECpHkKGOZSQxx3hk8viIL5YCg==} engines: {node: ^20.19.0 || >=22.12.0} oxc-resolver@11.19.1: @@ -2756,6 +2901,13 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.0: resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} @@ -2767,8 +2919,8 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-expression-matcher@1.2.0: - resolution: {integrity: sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==} + path-expression-matcher@1.4.0: + resolution: {integrity: sha512-s4DQMxIdhj3jLFWd9LxHOplj4p9yQ4ffMGowFf3cpEgrrJjEhN0V5nxw4Ye1EViAGDoL4/1AeO6qHpqYPOzE4Q==} engines: {node: '>=14.0.0'} path-key@3.1.1: @@ -2826,12 +2978,12 @@ packages: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.8: - resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + postcss@8.5.9: + resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} engines: {node: ^10 || ^12 || >=14} - postgres@3.4.8: - resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==} + postgres@3.4.9: + resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} engines: {node: '>=12'} prettier@3.8.1: @@ -2898,6 +3050,10 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} + read-package-up@11.0.0: + resolution: {integrity: sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==} + engines: {node: '>=18'} + read-pkg-up@3.0.0: resolution: {integrity: sha512-YFzFrVvpC6frF1sz8psoHDBGF7fLPc+llq/8NB43oagqWkx8ar5zYtsTORtOjw9W2RHLpWP+zTWwBvf1bCmcSw==} engines: {node: '>=4'} @@ -2914,6 +3070,10 @@ packages: resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==} engines: {node: '>=8'} + read-pkg@9.0.1: + resolution: {integrity: sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==} + engines: {node: '>=18'} + readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -2969,8 +3129,8 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rollup@4.60.0: - resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} + rollup@4.60.1: + resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -3032,9 +3192,9 @@ packages: resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} - socks-proxy-agent@9.0.0: - resolution: {integrity: sha512-fFlbMlfsXhK02ZB8aZY7Hwxh/IHBV9b1Oq9bvBk6tkFWXvdAxUgA0wbw/NYR5liU3Y5+KI6U4FH3kYJt9QYv0w==} - engines: {node: '>= 14'} + socks-proxy-agent@10.0.0: + resolution: {integrity: sha512-pyp2YR3mNxAMu0mGLtzs4g7O3uT4/9sQOLAKcViAkaS9fJWkud7nmaf6ZREFqQEi24IPkBcjfHjXhPTUWjo3uA==} + engines: {node: '>= 20'} socks@2.8.7: resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} @@ -3114,8 +3274,8 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} - strnum@2.2.2: - resolution: {integrity: sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==} + strnum@2.2.3: + resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -3154,6 +3314,14 @@ packages: resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} engines: {node: '>=6'} + temp-dir@3.0.0: + resolution: {integrity: sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==} + engines: {node: '>=14.16'} + + tempfile@5.0.0: + resolution: {integrity: sha512-bX655WZI/F7EoTDw9JvQURqAXiPHi8o8+yFxPF2lWYyz1aHnmMRuXWqL6YB6GmeO0o4DIYWHLgGNi/X64T+X4Q==} + engines: {node: '>=14.18'} + text-extensions@1.9.0: resolution: {integrity: sha512-wiBrwC1EhBelW12Zy26JeOUkQ5mRu+5o8rpsJk5+2t+Y5vE7e842qtZDQ2g1NpX/29HdyFeJ4nSIhI47ENSxlQ==} engines: {node: '>=0.10'} @@ -3171,23 +3339,23 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@1.0.4: - resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + tinyexec@1.1.1: + resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} engines: {node: '>=18'} - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} tinyrainbow@3.1.0: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} - tldts-core@7.0.27: - resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==} + tldts-core@7.0.28: + resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==} - tldts@7.0.27: - resolution: {integrity: sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==} + tldts@7.0.28: + resolution: {integrity: sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==} hasBin: true to-regex-range@5.0.1: @@ -3235,6 +3403,10 @@ packages: resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} engines: {node: '>=8'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} @@ -3255,10 +3427,17 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - undici@7.24.6: - resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==} + undici-types@7.24.7: + resolution: {integrity: sha512-XA+gOBkzYD3C74sZowtCLTpgtaCdqZhqCvR6y9LXvrKTt/IVU6bz49T4D+BPi475scshCCkb0IklJRw6T1ZlgQ==} + + undici@7.24.7: + resolution: {integrity: sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==} engines: {node: '>=20.18.1'} + unicorn-magic@0.1.0: + resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} + engines: {node: '>=18'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -3289,8 +3468,8 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite@7.3.1: - resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + vite@7.3.2: + resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -3329,18 +3508,20 @@ packages: yaml: optional: true - vitest@4.1.2: - resolution: {integrity: sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==} + vitest@4.1.4: + resolution: {integrity: sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.2 - '@vitest/browser-preview': 4.1.2 - '@vitest/browser-webdriverio': 4.1.2 - '@vitest/ui': 4.1.2 + '@vitest/browser-playwright': 4.1.4 + '@vitest/browser-preview': 4.1.4 + '@vitest/browser-webdriverio': 4.1.4 + '@vitest/coverage-istanbul': 4.1.4 + '@vitest/coverage-v8': 4.1.4 + '@vitest/ui': 4.1.4 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -3357,6 +3538,10 @@ packages: optional: true '@vitest/browser-webdriverio': optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true '@vitest/ui': optional: true happy-dom: @@ -3465,21 +3650,19 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@asamuzakjp/css-color@5.0.1': + '@asamuzakjp/css-color@5.1.9': dependencies: '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - lru-cache: 11.2.7 - '@asamuzakjp/dom-selector@7.0.4': + '@asamuzakjp/dom-selector@7.0.9': dependencies: '@asamuzakjp/nwsapi': 2.3.9 bidi-js: 1.0.3 css-tree: 3.2.1 is-potential-custom-element-name: 1.0.1 - lru-cache: 11.2.7 '@asamuzakjp/nwsapi@2.3.9': {} @@ -3493,53 +3676,53 @@ snapshots: '@babel/runtime@7.29.2': {} - '@biomejs/biome@2.4.9': + '@biomejs/biome@2.4.11': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.4.9 - '@biomejs/cli-darwin-x64': 2.4.9 - '@biomejs/cli-linux-arm64': 2.4.9 - '@biomejs/cli-linux-arm64-musl': 2.4.9 - '@biomejs/cli-linux-x64': 2.4.9 - '@biomejs/cli-linux-x64-musl': 2.4.9 - '@biomejs/cli-win32-arm64': 2.4.9 - '@biomejs/cli-win32-x64': 2.4.9 + '@biomejs/cli-darwin-arm64': 2.4.11 + '@biomejs/cli-darwin-x64': 2.4.11 + '@biomejs/cli-linux-arm64': 2.4.11 + '@biomejs/cli-linux-arm64-musl': 2.4.11 + '@biomejs/cli-linux-x64': 2.4.11 + '@biomejs/cli-linux-x64-musl': 2.4.11 + '@biomejs/cli-win32-arm64': 2.4.11 + '@biomejs/cli-win32-x64': 2.4.11 - '@biomejs/cli-darwin-arm64@2.4.9': + '@biomejs/cli-darwin-arm64@2.4.11': optional: true - '@biomejs/cli-darwin-x64@2.4.9': + '@biomejs/cli-darwin-x64@2.4.11': optional: true - '@biomejs/cli-linux-arm64-musl@2.4.9': + '@biomejs/cli-linux-arm64-musl@2.4.11': optional: true - '@biomejs/cli-linux-arm64@2.4.9': + '@biomejs/cli-linux-arm64@2.4.11': optional: true - '@biomejs/cli-linux-x64-musl@2.4.9': + '@biomejs/cli-linux-x64-musl@2.4.11': optional: true - '@biomejs/cli-linux-x64@2.4.9': + '@biomejs/cli-linux-x64@2.4.11': optional: true - '@biomejs/cli-win32-arm64@2.4.9': + '@biomejs/cli-win32-arm64@2.4.11': optional: true - '@biomejs/cli-win32-x64@2.4.9': + '@biomejs/cli-win32-x64@2.4.11': optional: true '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 - '@commitlint/cli@20.5.0(@types/node@25.5.0)(conventional-commits-parser@6.3.0)(typescript@6.0.2)': + '@commitlint/cli@20.5.0(@types/node@25.5.2)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@6.0.2)': dependencies: '@commitlint/format': 20.5.0 '@commitlint/lint': 20.5.0 - '@commitlint/load': 20.5.0(@types/node@25.5.0)(typescript@6.0.2) - '@commitlint/read': 20.5.0(conventional-commits-parser@6.3.0) + '@commitlint/load': 20.5.0(@types/node@25.5.2)(typescript@6.0.2) + '@commitlint/read': 20.5.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) '@commitlint/types': 20.5.0 - tinyexec: 1.0.4 + tinyexec: 1.1.1 yargs: 17.7.2 transitivePeerDependencies: - '@types/node' @@ -3550,7 +3733,7 @@ snapshots: '@commitlint/config-conventional@20.5.0': dependencies: '@commitlint/types': 20.5.0 - conventional-changelog-conventionalcommits: 9.3.0 + conventional-changelog-conventionalcommits: 9.3.1 '@commitlint/config-validator@20.5.0': dependencies: @@ -3585,14 +3768,14 @@ snapshots: '@commitlint/rules': 20.5.0 '@commitlint/types': 20.5.0 - '@commitlint/load@20.5.0(@types/node@25.5.0)(typescript@6.0.2)': + '@commitlint/load@20.5.0(@types/node@25.5.2)(typescript@6.0.2)': dependencies: '@commitlint/config-validator': 20.5.0 '@commitlint/execute-rule': 20.0.0 '@commitlint/resolve-extends': 20.5.0 '@commitlint/types': 20.5.0 cosmiconfig: 9.0.1(typescript@6.0.2) - cosmiconfig-typescript-loader: 6.2.0(@types/node@25.5.0)(cosmiconfig@9.0.1(typescript@6.0.2))(typescript@6.0.2) + cosmiconfig-typescript-loader: 6.3.0(@types/node@25.5.2)(cosmiconfig@9.0.1(typescript@6.0.2))(typescript@6.0.2) is-plain-obj: 4.1.0 lodash.mergewith: 4.6.2 picocolors: 1.1.1 @@ -3605,16 +3788,16 @@ snapshots: '@commitlint/parse@20.5.0': dependencies: '@commitlint/types': 20.5.0 - conventional-changelog-angular: 8.3.0 - conventional-commits-parser: 6.3.0 + conventional-changelog-angular: 8.3.1 + conventional-commits-parser: 6.4.0 - '@commitlint/read@20.5.0(conventional-commits-parser@6.3.0)': + '@commitlint/read@20.5.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)': dependencies: '@commitlint/top-level': 20.4.3 '@commitlint/types': 20.5.0 - git-raw-commits: 5.0.1(conventional-commits-parser@6.3.0) + git-raw-commits: 5.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) minimist: 1.2.8 - tinyexec: 1.0.4 + tinyexec: 1.1.1 transitivePeerDependencies: - conventional-commits-filter - conventional-commits-parser @@ -3643,16 +3826,17 @@ snapshots: '@commitlint/types@20.5.0': dependencies: - conventional-commits-parser: 6.3.0 + conventional-commits-parser: 6.4.0 picocolors: 1.1.1 - '@conventional-changelog/git-client@2.6.0(conventional-commits-parser@6.3.0)': + '@conventional-changelog/git-client@2.7.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)': dependencies: '@simple-libs/child-process-utils': 1.0.2 '@simple-libs/stream-utils': 1.2.0 semver: 7.7.4 optionalDependencies: - conventional-commits-parser: 6.3.0 + conventional-commits-filter: 5.0.0 + conventional-commits-parser: 6.4.0 '@csstools/color-helpers@6.0.2': {} @@ -3711,7 +3895,7 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.9.1': + '@emnapi/runtime@1.9.2': dependencies: tslib: 2.8.1 optional: true @@ -3725,7 +3909,7 @@ snapshots: '@esbuild-kit/core-utils@3.3.2': dependencies: - esbuild: 0.27.4 + esbuild: 0.28.0 source-map-support: 0.5.21 '@esbuild-kit/esm-loader@2.6.5': @@ -3733,82 +3917,82 @@ snapshots: '@esbuild-kit/core-utils': 3.3.2 get-tsconfig: 4.13.7 - '@esbuild/aix-ppc64@0.27.4': + '@esbuild/aix-ppc64@0.28.0': optional: true - '@esbuild/android-arm64@0.27.4': + '@esbuild/android-arm64@0.28.0': optional: true - '@esbuild/android-arm@0.27.4': + '@esbuild/android-arm@0.28.0': optional: true - '@esbuild/android-x64@0.27.4': + '@esbuild/android-x64@0.28.0': optional: true - '@esbuild/darwin-arm64@0.27.4': + '@esbuild/darwin-arm64@0.28.0': optional: true - '@esbuild/darwin-x64@0.27.4': + '@esbuild/darwin-x64@0.28.0': optional: true - '@esbuild/freebsd-arm64@0.27.4': + '@esbuild/freebsd-arm64@0.28.0': optional: true - '@esbuild/freebsd-x64@0.27.4': + '@esbuild/freebsd-x64@0.28.0': optional: true - '@esbuild/linux-arm64@0.27.4': + '@esbuild/linux-arm64@0.28.0': optional: true - '@esbuild/linux-arm@0.27.4': + '@esbuild/linux-arm@0.28.0': optional: true - '@esbuild/linux-ia32@0.27.4': + '@esbuild/linux-ia32@0.28.0': optional: true - '@esbuild/linux-loong64@0.27.4': + '@esbuild/linux-loong64@0.28.0': optional: true - '@esbuild/linux-mips64el@0.27.4': + '@esbuild/linux-mips64el@0.28.0': optional: true - '@esbuild/linux-ppc64@0.27.4': + '@esbuild/linux-ppc64@0.28.0': optional: true - '@esbuild/linux-riscv64@0.27.4': + '@esbuild/linux-riscv64@0.28.0': optional: true - '@esbuild/linux-s390x@0.27.4': + '@esbuild/linux-s390x@0.28.0': optional: true - '@esbuild/linux-x64@0.27.4': + '@esbuild/linux-x64@0.28.0': optional: true - '@esbuild/netbsd-arm64@0.27.4': + '@esbuild/netbsd-arm64@0.28.0': optional: true - '@esbuild/netbsd-x64@0.27.4': + '@esbuild/netbsd-x64@0.28.0': optional: true - '@esbuild/openbsd-arm64@0.27.4': + '@esbuild/openbsd-arm64@0.28.0': optional: true - '@esbuild/openbsd-x64@0.27.4': + '@esbuild/openbsd-x64@0.28.0': optional: true - '@esbuild/openharmony-arm64@0.27.4': + '@esbuild/openharmony-arm64@0.28.0': optional: true - '@esbuild/sunos-x64@0.27.4': + '@esbuild/sunos-x64@0.28.0': optional: true - '@esbuild/win32-arm64@0.27.4': + '@esbuild/win32-arm64@0.28.0': optional: true - '@esbuild/win32-ia32@0.27.4': + '@esbuild/win32-ia32@0.28.0': optional: true - '@esbuild/win32-x64@0.27.4': + '@esbuild/win32-x64@0.28.0': optional: true '@exodus/bytes@1.15.0(@noble/hashes@2.0.1)': @@ -3819,6 +4003,8 @@ snapshots: '@hutson/parse-repository-url@3.0.2': {} + '@hutson/parse-repository-url@5.0.0': {} + '@img/colour@1.1.0': optional: true @@ -3904,7 +4090,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.9.1 + '@emnapi/runtime': 1.9.2 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -3935,37 +4121,37 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@napi-rs/wasm-runtime@1.1.1': + '@napi-rs/wasm-runtime@1.1.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.2)': dependencies: '@emnapi/core': 1.9.1 - '@emnapi/runtime': 1.9.1 + '@emnapi/runtime': 1.9.2 '@tybys/wasm-util': 0.10.1 optional: true - '@next/env@16.2.1': {} + '@next/env@16.2.2': {} - '@next/swc-darwin-arm64@16.2.1': + '@next/swc-darwin-arm64@16.2.2': optional: true - '@next/swc-darwin-x64@16.2.1': + '@next/swc-darwin-x64@16.2.2': optional: true - '@next/swc-linux-arm64-gnu@16.2.1': + '@next/swc-linux-arm64-gnu@16.2.2': optional: true - '@next/swc-linux-arm64-musl@16.2.1': + '@next/swc-linux-arm64-musl@16.2.2': optional: true - '@next/swc-linux-x64-gnu@16.2.1': + '@next/swc-linux-x64-gnu@16.2.2': optional: true - '@next/swc-linux-x64-musl@16.2.1': + '@next/swc-linux-x64-musl@16.2.2': optional: true - '@next/swc-win32-arm64-msvc@16.2.1': + '@next/swc-win32-arm64-msvc@16.2.2': optional: true - '@next/swc-win32-x64-msvc@16.2.1': + '@next/swc-win32-x64-msvc@16.2.2': optional: true '@noble/hashes@2.0.1': {} @@ -3982,69 +4168,72 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@oxc-parser/binding-android-arm-eabi@0.120.0': + '@oxc-parser/binding-android-arm-eabi@0.121.0': optional: true - '@oxc-parser/binding-android-arm64@0.120.0': + '@oxc-parser/binding-android-arm64@0.121.0': optional: true - '@oxc-parser/binding-darwin-arm64@0.120.0': + '@oxc-parser/binding-darwin-arm64@0.121.0': optional: true - '@oxc-parser/binding-darwin-x64@0.120.0': + '@oxc-parser/binding-darwin-x64@0.121.0': optional: true - '@oxc-parser/binding-freebsd-x64@0.120.0': + '@oxc-parser/binding-freebsd-x64@0.121.0': optional: true - '@oxc-parser/binding-linux-arm-gnueabihf@0.120.0': + '@oxc-parser/binding-linux-arm-gnueabihf@0.121.0': optional: true - '@oxc-parser/binding-linux-arm-musleabihf@0.120.0': + '@oxc-parser/binding-linux-arm-musleabihf@0.121.0': optional: true - '@oxc-parser/binding-linux-arm64-gnu@0.120.0': + '@oxc-parser/binding-linux-arm64-gnu@0.121.0': optional: true - '@oxc-parser/binding-linux-arm64-musl@0.120.0': + '@oxc-parser/binding-linux-arm64-musl@0.121.0': optional: true - '@oxc-parser/binding-linux-ppc64-gnu@0.120.0': + '@oxc-parser/binding-linux-ppc64-gnu@0.121.0': optional: true - '@oxc-parser/binding-linux-riscv64-gnu@0.120.0': + '@oxc-parser/binding-linux-riscv64-gnu@0.121.0': optional: true - '@oxc-parser/binding-linux-riscv64-musl@0.120.0': + '@oxc-parser/binding-linux-riscv64-musl@0.121.0': optional: true - '@oxc-parser/binding-linux-s390x-gnu@0.120.0': + '@oxc-parser/binding-linux-s390x-gnu@0.121.0': optional: true - '@oxc-parser/binding-linux-x64-gnu@0.120.0': + '@oxc-parser/binding-linux-x64-gnu@0.121.0': optional: true - '@oxc-parser/binding-linux-x64-musl@0.120.0': + '@oxc-parser/binding-linux-x64-musl@0.121.0': optional: true - '@oxc-parser/binding-openharmony-arm64@0.120.0': + '@oxc-parser/binding-openharmony-arm64@0.121.0': optional: true - '@oxc-parser/binding-wasm32-wasi@0.120.0': + '@oxc-parser/binding-wasm32-wasi@0.121.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.2)': dependencies: - '@napi-rs/wasm-runtime': 1.1.1 + '@napi-rs/wasm-runtime': 1.1.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.2) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' optional: true - '@oxc-parser/binding-win32-arm64-msvc@0.120.0': + '@oxc-parser/binding-win32-arm64-msvc@0.121.0': optional: true - '@oxc-parser/binding-win32-ia32-msvc@0.120.0': + '@oxc-parser/binding-win32-ia32-msvc@0.121.0': optional: true - '@oxc-parser/binding-win32-x64-msvc@0.120.0': + '@oxc-parser/binding-win32-x64-msvc@0.121.0': optional: true - '@oxc-project/types@0.120.0': {} + '@oxc-project/types@0.121.0': {} '@oxc-resolver/binding-android-arm-eabi@11.19.1': optional: true @@ -4094,9 +4283,12 @@ snapshots: '@oxc-resolver/binding-openharmony-arm64@11.19.1': optional: true - '@oxc-resolver/binding-wasm32-wasi@11.19.1': + '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.2)': dependencies: - '@napi-rs/wasm-runtime': 1.1.1 + '@napi-rs/wasm-runtime': 1.1.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.2) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' optional: true '@oxc-resolver/binding-win32-arm64-msvc@11.19.1': @@ -4112,79 +4304,79 @@ snapshots: '@pinojs/redact@0.4.0': {} - '@rollup/rollup-android-arm-eabi@4.60.0': + '@rollup/rollup-android-arm-eabi@4.60.1': optional: true - '@rollup/rollup-android-arm64@4.60.0': + '@rollup/rollup-android-arm64@4.60.1': optional: true - '@rollup/rollup-darwin-arm64@4.60.0': + '@rollup/rollup-darwin-arm64@4.60.1': optional: true - '@rollup/rollup-darwin-x64@4.60.0': + '@rollup/rollup-darwin-x64@4.60.1': optional: true - '@rollup/rollup-freebsd-arm64@4.60.0': + '@rollup/rollup-freebsd-arm64@4.60.1': optional: true - '@rollup/rollup-freebsd-x64@4.60.0': + '@rollup/rollup-freebsd-x64@4.60.1': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.60.0': + '@rollup/rollup-linux-arm-musleabihf@4.60.1': optional: true - '@rollup/rollup-linux-arm64-gnu@4.60.0': + '@rollup/rollup-linux-arm64-gnu@4.60.1': optional: true - '@rollup/rollup-linux-arm64-musl@4.60.0': + '@rollup/rollup-linux-arm64-musl@4.60.1': optional: true - '@rollup/rollup-linux-loong64-gnu@4.60.0': + '@rollup/rollup-linux-loong64-gnu@4.60.1': optional: true - '@rollup/rollup-linux-loong64-musl@4.60.0': + '@rollup/rollup-linux-loong64-musl@4.60.1': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.60.0': + '@rollup/rollup-linux-ppc64-gnu@4.60.1': optional: true - '@rollup/rollup-linux-ppc64-musl@4.60.0': + '@rollup/rollup-linux-ppc64-musl@4.60.1': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.60.0': + '@rollup/rollup-linux-riscv64-gnu@4.60.1': optional: true - '@rollup/rollup-linux-riscv64-musl@4.60.0': + '@rollup/rollup-linux-riscv64-musl@4.60.1': optional: true - '@rollup/rollup-linux-s390x-gnu@4.60.0': + '@rollup/rollup-linux-s390x-gnu@4.60.1': optional: true - '@rollup/rollup-linux-x64-gnu@4.60.0': + '@rollup/rollup-linux-x64-gnu@4.60.1': optional: true - '@rollup/rollup-linux-x64-musl@4.60.0': + '@rollup/rollup-linux-x64-musl@4.60.1': optional: true - '@rollup/rollup-openbsd-x64@4.60.0': + '@rollup/rollup-openbsd-x64@4.60.1': optional: true - '@rollup/rollup-openharmony-arm64@4.60.0': + '@rollup/rollup-openharmony-arm64@4.60.1': optional: true - '@rollup/rollup-win32-arm64-msvc@4.60.0': + '@rollup/rollup-win32-arm64-msvc@4.60.1': optional: true - '@rollup/rollup-win32-ia32-msvc@4.60.0': + '@rollup/rollup-win32-ia32-msvc@4.60.1': optional: true - '@rollup/rollup-win32-x64-gnu@4.60.0': + '@rollup/rollup-win32-x64-gnu@4.60.1': optional: true - '@rollup/rollup-win32-x64-msvc@4.60.0': + '@rollup/rollup-win32-x64-msvc@4.60.1': optional: true '@simple-libs/child-process-utils@1.0.2': @@ -4265,7 +4457,7 @@ snapshots: '@alloc/quick-lru': 5.2.0 '@tailwindcss/node': 4.2.2 '@tailwindcss/oxide': 4.2.2 - postcss: 8.5.8 + postcss: 8.5.9 tailwindcss: 4.2.2 '@tailwindcss/typography@0.5.19(tailwindcss@4.2.2)': @@ -4273,11 +4465,11 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.2.2 - '@tanstack/query-core@5.95.2': {} + '@tanstack/query-core@5.97.0': {} - '@tanstack/react-query@5.95.2(react@19.2.4)': + '@tanstack/react-query@5.97.0(react@19.2.4)': dependencies: - '@tanstack/query-core': 5.95.2 + '@tanstack/query-core': 5.97.0 react: 19.2.4 '@testing-library/dom@10.4.1': @@ -4342,6 +4534,13 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/jsdom@28.0.1': + dependencies: + '@types/node': 25.5.2 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + undici-types: 7.24.7 + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -4352,7 +4551,7 @@ snapshots: '@types/node-cron@3.0.11': {} - '@types/node@25.5.0': + '@types/node@25.5.2': dependencies: undici-types: 7.18.2 @@ -4366,50 +4565,52 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/tough-cookie@4.0.5': {} + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} '@ungap/structured-clone@1.3.0': {} - '@vitest/expect@4.1.2': + '@vitest/expect@4.1.4': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.2 - '@vitest/utils': 4.1.2 + '@vitest/spy': 4.1.4 + '@vitest/utils': 4.1.4 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.2(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))': + '@vitest/mocker@4.1.4(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: - '@vitest/spy': 4.1.2 + '@vitest/spy': 4.1.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) - '@vitest/pretty-format@4.1.2': + '@vitest/pretty-format@4.1.4': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.2': + '@vitest/runner@4.1.4': dependencies: - '@vitest/utils': 4.1.2 + '@vitest/utils': 4.1.4 pathe: 2.0.3 - '@vitest/snapshot@4.1.2': + '@vitest/snapshot@4.1.4': dependencies: - '@vitest/pretty-format': 4.1.2 - '@vitest/utils': 4.1.2 + '@vitest/pretty-format': 4.1.4 + '@vitest/utils': 4.1.4 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.2': {} + '@vitest/spy@4.1.4': {} - '@vitest/utils@4.1.2': + '@vitest/utils@4.1.4': dependencies: - '@vitest/pretty-format': 4.1.2 + '@vitest/pretty-format': 4.1.4 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -4420,7 +4621,7 @@ snapshots: add-stream@1.0.0: {} - agent-base@8.0.0: {} + agent-base@9.0.0: {} ajv@8.18.0: dependencies: @@ -4468,13 +4669,15 @@ snapshots: balanced-match@1.0.2: {} - baseline-browser-mapping@2.10.11: {} + baseline-browser-mapping@2.10.17: {} bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 - brace-expansion@1.1.12: + boolbase@1.0.0: {} + + brace-expansion@1.1.13: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 @@ -4495,7 +4698,7 @@ snapshots: camelcase@5.3.1: {} - caniuse-lite@1.0.30001781: {} + caniuse-lite@1.0.30001787: {} ccount@2.0.1: {} @@ -4563,7 +4766,7 @@ snapshots: detect-indent: 6.1.0 detect-newline: 3.1.0 dotgitignore: 2.1.0 - fast-xml-parser: 5.5.9 + fast-xml-parser: 5.5.11 figures: 3.2.0 find-up: 5.0.0 git-semver-tags: 5.0.1 @@ -4589,21 +4792,38 @@ snapshots: dependencies: compare-func: 2.0.0 - conventional-changelog-angular@8.3.0: + conventional-changelog-angular@8.3.1: dependencies: compare-func: 2.0.0 conventional-changelog-atom@3.0.0: {} + conventional-changelog-atom@5.1.0: {} + + conventional-changelog-cli@5.0.0(conventional-commits-filter@5.0.0): + dependencies: + add-stream: 1.0.0 + conventional-changelog: 6.0.0(conventional-commits-filter@5.0.0) + meow: 13.2.0 + tempfile: 5.0.0 + transitivePeerDependencies: + - conventional-commits-filter + conventional-changelog-codemirror@3.0.0: {} + conventional-changelog-codemirror@5.1.0: {} + conventional-changelog-config-spec@2.1.0: {} conventional-changelog-conventionalcommits@6.1.0: dependencies: compare-func: 2.0.0 - conventional-changelog-conventionalcommits@9.3.0: + conventional-changelog-conventionalcommits@8.0.0: + dependencies: + compare-func: 2.0.0 + + conventional-changelog-conventionalcommits@9.3.1: dependencies: compare-func: 2.0.0 @@ -4621,20 +4841,49 @@ snapshots: read-pkg: 3.0.0 read-pkg-up: 3.0.0 + conventional-changelog-core@8.0.0(conventional-commits-filter@5.0.0): + dependencies: + '@hutson/parse-repository-url': 5.0.0 + add-stream: 1.0.0 + conventional-changelog-writer: 8.4.0 + conventional-commits-parser: 6.4.0 + git-raw-commits: 5.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) + git-semver-tags: 8.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) + hosted-git-info: 7.0.2 + normalize-package-data: 6.0.2 + read-package-up: 11.0.0 + read-pkg: 9.0.1 + transitivePeerDependencies: + - conventional-commits-filter + conventional-changelog-ember@3.0.0: {} + conventional-changelog-ember@5.1.0: {} + conventional-changelog-eslint@4.0.0: {} + conventional-changelog-eslint@6.1.0: {} + conventional-changelog-express@3.0.0: {} + conventional-changelog-express@5.1.0: {} + conventional-changelog-jquery@4.0.0: {} + conventional-changelog-jquery@6.1.0: {} + conventional-changelog-jshint@3.0.0: dependencies: compare-func: 2.0.0 + conventional-changelog-jshint@5.2.0: + dependencies: + compare-func: 2.0.0 + conventional-changelog-preset-loader@3.0.0: {} + conventional-changelog-preset-loader@5.0.0: {} + conventional-changelog-writer@6.0.1: dependencies: conventional-commits-filter: 3.0.0 @@ -4645,6 +4894,14 @@ snapshots: semver: 7.7.4 split: 1.0.1 + conventional-changelog-writer@8.4.0: + dependencies: + '@simple-libs/stream-utils': 1.2.0 + conventional-commits-filter: 5.0.0 + handlebars: 4.7.9 + meow: 13.2.0 + semver: 7.7.4 + conventional-changelog@4.0.0: dependencies: conventional-changelog-angular: 6.0.0 @@ -4659,11 +4916,29 @@ snapshots: conventional-changelog-jshint: 3.0.0 conventional-changelog-preset-loader: 3.0.0 + conventional-changelog@6.0.0(conventional-commits-filter@5.0.0): + dependencies: + conventional-changelog-angular: 8.3.1 + conventional-changelog-atom: 5.1.0 + conventional-changelog-codemirror: 5.1.0 + conventional-changelog-conventionalcommits: 8.0.0 + conventional-changelog-core: 8.0.0(conventional-commits-filter@5.0.0) + conventional-changelog-ember: 5.1.0 + conventional-changelog-eslint: 6.1.0 + conventional-changelog-express: 5.1.0 + conventional-changelog-jquery: 6.1.0 + conventional-changelog-jshint: 5.2.0 + conventional-changelog-preset-loader: 5.0.0 + transitivePeerDependencies: + - conventional-commits-filter + conventional-commits-filter@3.0.0: dependencies: lodash.ismatch: 4.4.0 modify-values: 1.0.1 + conventional-commits-filter@5.0.0: {} + conventional-commits-parser@4.0.0: dependencies: JSONStream: 1.3.5 @@ -4671,7 +4946,7 @@ snapshots: meow: 8.1.2 split2: 3.2.2 - conventional-commits-parser@6.3.0: + conventional-commits-parser@6.4.0: dependencies: '@simple-libs/stream-utils': 1.2.0 meow: 13.2.0 @@ -4690,9 +4965,9 @@ snapshots: core-util-is@1.0.3: {} - cosmiconfig-typescript-loader@6.2.0(@types/node@25.5.0)(cosmiconfig@9.0.1(typescript@6.0.2))(typescript@6.0.2): + cosmiconfig-typescript-loader@6.3.0(@types/node@25.5.2)(cosmiconfig@9.0.1(typescript@6.0.2))(typescript@6.0.2): dependencies: - '@types/node': 25.5.0 + '@types/node': 25.5.2 cosmiconfig: 9.0.1(typescript@6.0.2) jiti: 2.6.1 typescript: 6.0.2 @@ -4717,11 +4992,21 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + 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@3.2.1: dependencies: mdn-data: 2.27.1 source-map-js: 1.2.1 + css-what@6.2.2: {} + css.escape@1.5.1: {} cssesc@3.0.0: {} @@ -4774,11 +5059,29 @@ snapshots: dom-accessibility-api@0.6.3: {} + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dot-prop@5.3.0: dependencies: is-obj: 2.0.0 - dotenv@17.3.1: {} + dotenv@17.4.1: {} dotgitignore@2.1.0: dependencies: @@ -4789,12 +5092,12 @@ snapshots: dependencies: '@drizzle-team/brocli': 0.10.2 '@esbuild-kit/esm-loader': 2.6.5 - esbuild: 0.27.4 + esbuild: 0.28.0 tsx: 4.21.0 - drizzle-orm@0.45.1(postgres@3.4.8): + drizzle-orm@0.45.2(postgres@3.4.9): optionalDependencies: - postgres: 3.4.8 + postgres: 3.4.9 echarts-for-react@3.0.6(echarts@6.0.0)(react@19.2.4): dependencies: @@ -4830,6 +5133,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.2 + entities@4.5.0: {} + entities@6.0.1: {} env-paths@2.2.1: {} @@ -4840,34 +5145,34 @@ snapshots: es-module-lexer@2.0.0: {} - esbuild@0.27.4: + esbuild@0.28.0: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.4 - '@esbuild/android-arm': 0.27.4 - '@esbuild/android-arm64': 0.27.4 - '@esbuild/android-x64': 0.27.4 - '@esbuild/darwin-arm64': 0.27.4 - '@esbuild/darwin-x64': 0.27.4 - '@esbuild/freebsd-arm64': 0.27.4 - '@esbuild/freebsd-x64': 0.27.4 - '@esbuild/linux-arm': 0.27.4 - '@esbuild/linux-arm64': 0.27.4 - '@esbuild/linux-ia32': 0.27.4 - '@esbuild/linux-loong64': 0.27.4 - '@esbuild/linux-mips64el': 0.27.4 - '@esbuild/linux-ppc64': 0.27.4 - '@esbuild/linux-riscv64': 0.27.4 - '@esbuild/linux-s390x': 0.27.4 - '@esbuild/linux-x64': 0.27.4 - '@esbuild/netbsd-arm64': 0.27.4 - '@esbuild/netbsd-x64': 0.27.4 - '@esbuild/openbsd-arm64': 0.27.4 - '@esbuild/openbsd-x64': 0.27.4 - '@esbuild/openharmony-arm64': 0.27.4 - '@esbuild/sunos-x64': 0.27.4 - '@esbuild/win32-arm64': 0.27.4 - '@esbuild/win32-ia32': 0.27.4 - '@esbuild/win32-x64': 0.27.4 + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 escalade@3.2.0: {} @@ -4903,13 +5208,13 @@ snapshots: fast-xml-builder@1.1.4: dependencies: - path-expression-matcher: 1.2.0 + path-expression-matcher: 1.4.0 - fast-xml-parser@5.5.9: + fast-xml-parser@5.5.11: dependencies: fast-xml-builder: 1.1.4 - path-expression-matcher: 1.2.0 - strnum: 2.2.2 + path-expression-matcher: 1.4.0 + strnum: 2.2.3 fastq@1.20.1: dependencies: @@ -4931,6 +5236,8 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-up-simple@1.0.1: {} + find-up@2.1.0: dependencies: locate-path: 2.0.0 @@ -4979,9 +5286,9 @@ snapshots: meow: 8.1.2 split2: 3.2.2 - git-raw-commits@5.0.1(conventional-commits-parser@6.3.0): + git-raw-commits@5.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0): dependencies: - '@conventional-changelog/git-client': 2.6.0(conventional-commits-parser@6.3.0) + '@conventional-changelog/git-client': 2.7.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) meow: 13.2.0 transitivePeerDependencies: - conventional-commits-filter @@ -4997,6 +5304,14 @@ snapshots: meow: 8.1.2 semver: 7.7.4 + git-semver-tags@8.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0): + dependencies: + '@conventional-changelog/git-client': 2.7.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0) + meow: 13.2.0 + transitivePeerDependencies: + - conventional-commits-filter + - conventional-commits-parser + gitconfiglocal@1.0.0: dependencies: ini: 1.3.8 @@ -5052,6 +5367,8 @@ snapshots: dependencies: '@types/hast': 3.0.4 + he@1.2.0: {} + help-me@5.0.0: {} hosted-git-info@2.8.9: {} @@ -5060,6 +5377,10 @@ snapshots: dependencies: lru-cache: 6.0.0 + hosted-git-info@7.0.2: + dependencies: + lru-cache: 10.4.3 + html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1): dependencies: '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) @@ -5068,9 +5389,9 @@ snapshots: html-url-attributes@3.0.1: {} - https-proxy-agent@8.0.0: + https-proxy-agent@9.0.0: dependencies: - agent-base: 8.0.0 + agent-base: 9.0.0 debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -5086,6 +5407,8 @@ snapshots: indent-string@4.0.0: {} + index-to-position@1.2.0: {} + inherits@2.0.4: {} ini@1.3.8: {} @@ -5151,10 +5474,10 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@29.0.1(@noble/hashes@2.0.1): + jsdom@29.0.2(@noble/hashes@2.0.1): dependencies: - '@asamuzakjp/css-color': 5.0.1 - '@asamuzakjp/dom-selector': 7.0.4 + '@asamuzakjp/css-color': 5.1.9 + '@asamuzakjp/dom-selector': 7.0.9 '@bramus/specificity': 2.4.2 '@csstools/css-syntax-patches-for-csstree': 1.1.2(css-tree@3.2.1) '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) @@ -5163,12 +5486,12 @@ snapshots: decimal.js: 10.6.0 html-encoding-sniffer: 6.0.0(@noble/hashes@2.0.1) is-potential-custom-element-name: 1.0.1 - lru-cache: 11.2.7 + lru-cache: 11.3.3 parse5: 8.0.0 saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.1 - undici: 7.24.6 + undici: 7.24.7 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 @@ -5189,7 +5512,7 @@ snapshots: kind-of@6.0.3: {} - knip@6.0.6: + knip@6.3.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.2): dependencies: '@nodelib/fs.walk': 1.2.8 fast-glob: 3.3.3 @@ -5197,8 +5520,8 @@ snapshots: get-tsconfig: 4.13.7 jiti: 2.6.1 minimist: 1.2.8 - oxc-parser: 0.120.0 - oxc-resolver: 11.19.1 + oxc-parser: 0.121.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.2) + oxc-resolver: 11.19.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.2) picocolors: 1.1.1 picomatch: 4.0.4 smol-toml: 1.6.1 @@ -5206,6 +5529,9 @@ snapshots: unbash: 2.2.0 yaml: 2.8.3 zod: 4.3.6 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' lightningcss-android-arm64@1.32.0: optional: true @@ -5299,7 +5625,9 @@ snapshots: longest-streak@3.1.0: {} - lru-cache@11.2.7: {} + lru-cache@10.4.3: {} + + lru-cache@11.3.3: {} lru-cache@6.0.0: dependencies: @@ -5690,7 +6018,7 @@ snapshots: minimatch@3.1.5: dependencies: - brace-expansion: 1.1.12 + brace-expansion: 1.1.13 minimist-options@4.1.0: dependencies: @@ -5708,25 +6036,25 @@ snapshots: neo-async@2.6.2: {} - next@16.2.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - '@next/env': 16.2.1 + '@next/env': 16.2.2 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.11 - caniuse-lite: 1.0.30001781 + baseline-browser-mapping: 2.10.17 + caniuse-lite: 1.0.30001787 postcss: 8.4.31 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': 16.2.1 - '@next/swc-darwin-x64': 16.2.1 - '@next/swc-linux-arm64-gnu': 16.2.1 - '@next/swc-linux-arm64-musl': 16.2.1 - '@next/swc-linux-x64-gnu': 16.2.1 - '@next/swc-linux-x64-musl': 16.2.1 - '@next/swc-win32-arm64-msvc': 16.2.1 - '@next/swc-win32-x64-msvc': 16.2.1 + '@next/swc-darwin-arm64': 16.2.2 + '@next/swc-darwin-x64': 16.2.2 + '@next/swc-linux-arm64-gnu': 16.2.2 + '@next/swc-linux-arm64-musl': 16.2.2 + '@next/swc-linux-x64-gnu': 16.2.2 + '@next/swc-linux-x64-musl': 16.2.2 + '@next/swc-win32-arm64-msvc': 16.2.2 + '@next/swc-win32-x64-msvc': 16.2.2 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -5738,6 +6066,11 @@ snapshots: node-gyp-build@4.8.4: {} + node-html-parser@7.1.0: + dependencies: + css-select: 5.2.2 + he: 1.2.0 + normalize-package-data@2.5.0: dependencies: hosted-git-info: 2.8.9 @@ -5752,6 +6085,16 @@ snapshots: semver: 7.7.4 validate-npm-package-license: 3.0.4 + normalize-package-data@6.0.2: + dependencies: + hosted-git-info: 7.0.2 + semver: 7.7.4 + validate-npm-package-license: 3.0.4 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + obug@2.1.1: {} on-exit-leak-free@2.1.2: {} @@ -5764,32 +6107,35 @@ snapshots: dependencies: '@noble/hashes': 2.0.1 - oxc-parser@0.120.0: + oxc-parser@0.121.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.2): dependencies: - '@oxc-project/types': 0.120.0 + '@oxc-project/types': 0.121.0 optionalDependencies: - '@oxc-parser/binding-android-arm-eabi': 0.120.0 - '@oxc-parser/binding-android-arm64': 0.120.0 - '@oxc-parser/binding-darwin-arm64': 0.120.0 - '@oxc-parser/binding-darwin-x64': 0.120.0 - '@oxc-parser/binding-freebsd-x64': 0.120.0 - '@oxc-parser/binding-linux-arm-gnueabihf': 0.120.0 - '@oxc-parser/binding-linux-arm-musleabihf': 0.120.0 - '@oxc-parser/binding-linux-arm64-gnu': 0.120.0 - '@oxc-parser/binding-linux-arm64-musl': 0.120.0 - '@oxc-parser/binding-linux-ppc64-gnu': 0.120.0 - '@oxc-parser/binding-linux-riscv64-gnu': 0.120.0 - '@oxc-parser/binding-linux-riscv64-musl': 0.120.0 - '@oxc-parser/binding-linux-s390x-gnu': 0.120.0 - '@oxc-parser/binding-linux-x64-gnu': 0.120.0 - '@oxc-parser/binding-linux-x64-musl': 0.120.0 - '@oxc-parser/binding-openharmony-arm64': 0.120.0 - '@oxc-parser/binding-wasm32-wasi': 0.120.0 - '@oxc-parser/binding-win32-arm64-msvc': 0.120.0 - '@oxc-parser/binding-win32-ia32-msvc': 0.120.0 - '@oxc-parser/binding-win32-x64-msvc': 0.120.0 + '@oxc-parser/binding-android-arm-eabi': 0.121.0 + '@oxc-parser/binding-android-arm64': 0.121.0 + '@oxc-parser/binding-darwin-arm64': 0.121.0 + '@oxc-parser/binding-darwin-x64': 0.121.0 + '@oxc-parser/binding-freebsd-x64': 0.121.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.121.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.121.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.121.0 + '@oxc-parser/binding-linux-arm64-musl': 0.121.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.121.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.121.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.121.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.121.0 + '@oxc-parser/binding-linux-x64-gnu': 0.121.0 + '@oxc-parser/binding-linux-x64-musl': 0.121.0 + '@oxc-parser/binding-openharmony-arm64': 0.121.0 + '@oxc-parser/binding-wasm32-wasi': 0.121.0(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.2) + '@oxc-parser/binding-win32-arm64-msvc': 0.121.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.121.0 + '@oxc-parser/binding-win32-x64-msvc': 0.121.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' - oxc-resolver@11.19.1: + oxc-resolver@11.19.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.2): optionalDependencies: '@oxc-resolver/binding-android-arm-eabi': 11.19.1 '@oxc-resolver/binding-android-arm64': 11.19.1 @@ -5807,10 +6153,13 @@ snapshots: '@oxc-resolver/binding-linux-x64-gnu': 11.19.1 '@oxc-resolver/binding-linux-x64-musl': 11.19.1 '@oxc-resolver/binding-openharmony-arm64': 11.19.1 - '@oxc-resolver/binding-wasm32-wasi': 11.19.1 + '@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.2) '@oxc-resolver/binding-win32-arm64-msvc': 11.19.1 '@oxc-resolver/binding-win32-ia32-msvc': 11.19.1 '@oxc-resolver/binding-win32-x64-msvc': 11.19.1 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' p-limit@1.3.0: dependencies: @@ -5870,6 +6219,16 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.0 + index-to-position: 1.2.0 + type-fest: 4.41.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parse5@8.0.0: dependencies: entities: 6.0.1 @@ -5878,7 +6237,7 @@ snapshots: path-exists@4.0.0: {} - path-expression-matcher@1.2.0: {} + path-expression-matcher@1.4.0: {} path-key@3.1.1: {} @@ -5947,13 +6306,13 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.8: + postcss@8.5.9: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 - postgres@3.4.8: {} + postgres@3.4.9: {} prettier@3.8.1: {} @@ -6018,6 +6377,12 @@ snapshots: react@19.2.4: {} + read-package-up@11.0.0: + dependencies: + find-up-simple: 1.0.1 + read-pkg: 9.0.1 + type-fest: 4.41.0 + read-pkg-up@3.0.0: dependencies: find-up: 2.1.0 @@ -6042,6 +6407,14 @@ snapshots: parse-json: 5.2.0 type-fest: 0.6.0 + read-pkg@9.0.1: + dependencies: + '@types/normalize-package-data': 2.4.4 + normalize-package-data: 6.0.2 + parse-json: 8.3.0 + type-fest: 4.41.0 + unicorn-magic: 0.1.0 + readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 @@ -6117,35 +6490,35 @@ snapshots: reusify@1.1.0: {} - rollup@4.60.0: + rollup@4.60.1: dependencies: '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.60.0 - '@rollup/rollup-android-arm64': 4.60.0 - '@rollup/rollup-darwin-arm64': 4.60.0 - '@rollup/rollup-darwin-x64': 4.60.0 - '@rollup/rollup-freebsd-arm64': 4.60.0 - '@rollup/rollup-freebsd-x64': 4.60.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.60.0 - '@rollup/rollup-linux-arm-musleabihf': 4.60.0 - '@rollup/rollup-linux-arm64-gnu': 4.60.0 - '@rollup/rollup-linux-arm64-musl': 4.60.0 - '@rollup/rollup-linux-loong64-gnu': 4.60.0 - '@rollup/rollup-linux-loong64-musl': 4.60.0 - '@rollup/rollup-linux-ppc64-gnu': 4.60.0 - '@rollup/rollup-linux-ppc64-musl': 4.60.0 - '@rollup/rollup-linux-riscv64-gnu': 4.60.0 - '@rollup/rollup-linux-riscv64-musl': 4.60.0 - '@rollup/rollup-linux-s390x-gnu': 4.60.0 - '@rollup/rollup-linux-x64-gnu': 4.60.0 - '@rollup/rollup-linux-x64-musl': 4.60.0 - '@rollup/rollup-openbsd-x64': 4.60.0 - '@rollup/rollup-openharmony-arm64': 4.60.0 - '@rollup/rollup-win32-arm64-msvc': 4.60.0 - '@rollup/rollup-win32-ia32-msvc': 4.60.0 - '@rollup/rollup-win32-x64-gnu': 4.60.0 - '@rollup/rollup-win32-x64-msvc': 4.60.0 + '@rollup/rollup-android-arm-eabi': 4.60.1 + '@rollup/rollup-android-arm64': 4.60.1 + '@rollup/rollup-darwin-arm64': 4.60.1 + '@rollup/rollup-darwin-x64': 4.60.1 + '@rollup/rollup-freebsd-arm64': 4.60.1 + '@rollup/rollup-freebsd-x64': 4.60.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 + '@rollup/rollup-linux-arm-musleabihf': 4.60.1 + '@rollup/rollup-linux-arm64-gnu': 4.60.1 + '@rollup/rollup-linux-arm64-musl': 4.60.1 + '@rollup/rollup-linux-loong64-gnu': 4.60.1 + '@rollup/rollup-linux-loong64-musl': 4.60.1 + '@rollup/rollup-linux-ppc64-gnu': 4.60.1 + '@rollup/rollup-linux-ppc64-musl': 4.60.1 + '@rollup/rollup-linux-riscv64-gnu': 4.60.1 + '@rollup/rollup-linux-riscv64-musl': 4.60.1 + '@rollup/rollup-linux-s390x-gnu': 4.60.1 + '@rollup/rollup-linux-x64-gnu': 4.60.1 + '@rollup/rollup-linux-x64-musl': 4.60.1 + '@rollup/rollup-openbsd-x64': 4.60.1 + '@rollup/rollup-openharmony-arm64': 4.60.1 + '@rollup/rollup-win32-arm64-msvc': 4.60.1 + '@rollup/rollup-win32-ia32-msvc': 4.60.1 + '@rollup/rollup-win32-x64-gnu': 4.60.1 + '@rollup/rollup-win32-x64-msvc': 4.60.1 fsevents: 2.3.3 run-parallel@1.2.0: @@ -6216,9 +6589,9 @@ snapshots: smol-toml@1.6.1: {} - socks-proxy-agent@9.0.0: + socks-proxy-agent@10.0.0: dependencies: - agent-base: 8.0.0 + agent-base: 9.0.0 debug: 4.4.3 socks: 2.8.7 transitivePeerDependencies: @@ -6303,7 +6676,7 @@ snapshots: strip-json-comments@5.0.3: {} - strnum@2.2.2: {} + strnum@2.2.3: {} style-to-js@1.1.21: dependencies: @@ -6330,6 +6703,12 @@ snapshots: tapable@2.3.2: {} + temp-dir@3.0.0: {} + + tempfile@5.0.0: + dependencies: + temp-dir: 3.0.0 + text-extensions@1.9.0: {} thread-stream@4.0.0: @@ -6345,20 +6724,20 @@ snapshots: tinybench@2.9.0: {} - tinyexec@1.0.4: {} + tinyexec@1.1.1: {} - tinyglobby@0.2.15: + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 tinyrainbow@3.1.0: {} - tldts-core@7.0.27: {} + tldts-core@7.0.28: {} - tldts@7.0.27: + tldts@7.0.28: dependencies: - tldts-core: 7.0.27 + tldts-core: 7.0.28 to-regex-range@5.0.1: dependencies: @@ -6366,7 +6745,7 @@ snapshots: tough-cookie@6.0.1: dependencies: - tldts: 7.0.27 + tldts: 7.0.28 tr46@6.0.0: dependencies: @@ -6384,7 +6763,7 @@ snapshots: tsx@4.21.0: dependencies: - esbuild: 0.27.4 + esbuild: 0.28.0 get-tsconfig: 4.13.7 optionalDependencies: fsevents: 2.3.3 @@ -6395,6 +6774,8 @@ snapshots: type-fest@0.8.1: {} + type-fest@4.41.0: {} + typedarray@0.0.6: {} typescript@6.0.2: {} @@ -6406,7 +6787,11 @@ snapshots: undici-types@7.18.2: {} - undici@7.24.6: {} + undici-types@7.24.7: {} + + undici@7.24.7: {} + + unicorn-magic@0.1.0: {} unified@11.0.5: dependencies: @@ -6458,31 +6843,31 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3): + vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: - esbuild: 0.27.4 + esbuild: 0.28.0 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.8 - rollup: 4.60.0 - tinyglobby: 0.2.15 + postcss: 8.5.9 + rollup: 4.60.1 + tinyglobby: 0.2.16 optionalDependencies: - '@types/node': 25.5.0 + '@types/node': 25.5.2 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.32.0 tsx: 4.21.0 yaml: 2.8.3 - vitest@4.1.2(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@2.0.1))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.4(@types/node@25.5.2)(jsdom@29.0.2(@noble/hashes@2.0.1))(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)): dependencies: - '@vitest/expect': 4.1.2 - '@vitest/mocker': 4.1.2(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) - '@vitest/pretty-format': 4.1.2 - '@vitest/runner': 4.1.2 - '@vitest/snapshot': 4.1.2 - '@vitest/spy': 4.1.2 - '@vitest/utils': 4.1.2 + '@vitest/expect': 4.1.4 + '@vitest/mocker': 4.1.4(vite@7.3.2(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.4 + '@vitest/runner': 4.1.4 + '@vitest/snapshot': 4.1.4 + '@vitest/spy': 4.1.4 + '@vitest/utils': 4.1.4 es-module-lexer: 2.0.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -6491,14 +6876,14 @@ snapshots: picomatch: 4.0.4 std-env: 4.0.0 tinybench: 2.9.0 - tinyexec: 1.0.4 - tinyglobby: 0.2.15 + tinyexec: 1.1.1 + tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.2(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.5.0 - jsdom: 29.0.1(@noble/hashes@2.0.1) + '@types/node': 25.5.2 + jsdom: 29.0.2(@noble/hashes@2.0.1) transitivePeerDependencies: - msw diff --git a/public/tracker-logos/animez_logo.svg b/public/tracker-logos/animez_logo.svg new file mode 100644 index 00000000..2b55701f --- /dev/null +++ b/public/tracker-logos/animez_logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/tracker-logos/avistaz_logo.png b/public/tracker-logos/avistaz_logo.png new file mode 100644 index 00000000..6e792f06 Binary files /dev/null and b/public/tracker-logos/avistaz_logo.png differ diff --git a/public/tracker-logos/beyondhd_logo.png b/public/tracker-logos/beyondhd_logo.png new file mode 100644 index 00000000..1c55cc3d Binary files /dev/null and b/public/tracker-logos/beyondhd_logo.png differ diff --git a/public/tracker-logos/cinemaz_logo.png b/public/tracker-logos/cinemaz_logo.png new file mode 100644 index 00000000..051ba779 Binary files /dev/null and b/public/tracker-logos/cinemaz_logo.png differ diff --git a/public/tracker-logos/darkpeers_logo.png b/public/tracker-logos/darkpeers_logo.png new file mode 100644 index 00000000..4ac010e2 Binary files /dev/null and b/public/tracker-logos/darkpeers_logo.png differ diff --git a/public/tracker-logos/digitalcore_logo.png b/public/tracker-logos/digitalcore_logo.png new file mode 100644 index 00000000..7cd158b4 Binary files /dev/null and b/public/tracker-logos/digitalcore_logo.png differ diff --git a/public/tracker-logos/exoticaz_logo.png b/public/tracker-logos/exoticaz_logo.png new file mode 100644 index 00000000..c2ff0c12 Binary files /dev/null and b/public/tracker-logos/exoticaz_logo.png differ diff --git a/public/tracker-logos/luminarr_logo.png b/public/tracker-logos/luminarr_logo.png new file mode 100644 index 00000000..13d5db10 Binary files /dev/null and b/public/tracker-logos/luminarr_logo.png differ diff --git a/public/tracker-logos/privatehd_logo.png b/public/tracker-logos/privatehd_logo.png new file mode 100644 index 00000000..5772db2b Binary files /dev/null and b/public/tracker-logos/privatehd_logo.png differ diff --git a/scripts/regen-changelog.cjs b/scripts/regen-changelog.cjs new file mode 100644 index 00000000..bb9e99fd --- /dev/null +++ b/scripts/regen-changelog.cjs @@ -0,0 +1,58 @@ +// scripts/regen-changelog.cjs +// Regenerates CHANGELOG.md using the conventionalcommits preset, +// then post-processes to match .versionrc.json preferences. + +const { execFileSync } = require("node:child_process") +const { readFileSync, writeFileSync } = require("node:fs") +const { join } = require("node:path") + +const root = join(__dirname, "..") +const changelog = join(root, "CHANGELOG.md") + +// 1. Generate with the conventionalcommits preset +execFileSync( + "pnpm", + ["conventional-changelog", "-p", "conventionalcommits", "-i", "CHANGELOG.md", "-s", "-r", "0"], + { + cwd: root, + stdio: "inherit", + } +) + +// 2. Post-process to match .versionrc.json preferences +let content = readFileSync(changelog, "utf-8") + +// Add header +if (!content.startsWith("# Changelog")) { + content = `# Changelog\n${content}` +} + +// Strip commit hash links: ([abc1234](https://github.com/.../commit/...)) +content = content.replace(/ \(\[[a-f0-9]{7,}\]\(https?:\/\/[^)]+\/commit\/[^)]+\)\)/g, "") + +// Strip all issue links: closes [#123](...), (#123), [#123](...) +content = content.replace(/,?\s*closes\s+\[#\d+\]\([^)]+\)/g, "") +content = content.replace(/ \[#\d+\]\([^)]+\)/g, "") +content = content.replace(/ \(#\d+\)/g, "") + +// Clean up empty/orphaned parens left after stripping +content = content.replace(/ \( and \)/g, "") +content = content.replace(/ \(\)/g, "") +content = content.replace(/ \( \)/g, "") + +// Remove sections not shown in-app (heading + all bullets until next heading or blank line) +content = content.replace(/### Performance(?:\s+Improvements)?\n\n(?:\*[^\n]*\n)*/g, "") +content = content.replace(/### Refactoring\n\n(?:\*[^\n]*\n)*/g, "") +content = content.replace(/### Reverts\n\n(?:\*[^\n]*\n)*/g, "") + +// Rewrite version header links: compare URL → release URL +content = content.replace( + /## \[([^\]]+)\]\(https:\/\/github\.com\/([^/]+)\/([^/]+)\/compare\/[^)]+\)/g, + "## [$1](https://github.com/$2/$3/releases/tag/v$1)" +) + +// Collapse triple+ blank lines to double +content = content.replace(/\n{3,}/g, "\n\n") + +writeFileSync(changelog, `${content.trimEnd()}\n`) +console.log(`Regenerated ${changelog} (${content.split("\n").length} lines)`) diff --git a/scripts/security-audit.ts b/scripts/security-audit.ts index 34c13549..3143827f 100644 --- a/scripts/security-audit.ts +++ b/scripts/security-audit.ts @@ -18,7 +18,8 @@ // checkBackupPasswordBounds, checkWebhookRedirectPolicy, // checkSessionSecretLengthGuard, checkNotificationSsrfValidation, // checkErrorMessageDisclosure, checkDockerCopySensitiveFiles, -// checkClientEnvLeak, runAudit +// checkClientEnvLeak, checkAdapterCookieInjection, checkAdapterCredentialLogging, +// runAudit // // Usage: npx tsx scripts/security-audit.ts [--changed-only file1 file2 ...] // If --changed-only is provided, only those files are scanned for @@ -1283,13 +1284,7 @@ function checkProxyAllowlistSync(): CheckResult { // ── Check 20: BigInt fields use string serialization (warning) ─────────── // Column names that hold BigInt values in the DB schema -const BIGINT_COLUMNS = [ - "uploadedBytes", - "downloadedBytes", - "bufferBytes", - "rawUploadedBytes", - "rawDownloadedBytes", -] +const BIGINT_COLUMNS = ["uploadedBytes", "downloadedBytes", "bufferBytes"] function checkBigIntSafety(files?: string[]): CheckResult { const findings: Finding[] = [] @@ -2032,10 +2027,10 @@ function checkBackupPasswordBounds(): CheckResult { if (!readsBackupPw || !callsDeriveOrEncrypt) continue - // Must have an upper-bound length check (e.g., .length > 128) + // Must have an upper-bound length check (e.g., .length > 128 or .length > BACKUP_PASSWORD_MAX) const hasUpperBound = - /backupPassword\.length\s*>\s*\d+/.test(content) || - /formPassword\.length\s*>\s*\d+/.test(content) + /backupPassword\.length\s*>\s*(\d+|[A-Z][A-Z0-9_]*)/.test(content) || + /formPassword\.length\s*>\s*(\d+|[A-Z][A-Z0-9_]*)/.test(content) if (!hasUpperBound) { findings.push({ @@ -2390,6 +2385,137 @@ function checkClientEnvLeak(): CheckResult { } } +// ── Check 37: Adapter Cookie header injection guard ──────────────────── +// +// Adapters that construct Cookie: headers with template-literal interpolation +// (i.e. Cookie: `name=${value}`) must validate the interpolated values against +// injection characters (semicolons, carriage returns, newlines) BEFORE use. +// Without this, a malicious or malformed credential value could inject extra +// cookies or HTTP headers. +// +// AvistaZ is not flagged because it passes a pre-assembled cookie string +// (Cookie: cookies) rather than interpolating individual values. + +function checkAdapterCookieInjection(): CheckResult { + const findings: Finding[] = [] + const adaptersDir = path.resolve(SRC_DIR, "lib/adapters") + const adapterFiles = walkFiles(adaptersDir, ".ts") + + // Matches: Cookie: `...${...}...` (template literal with interpolation) + const COOKIE_INTERPOLATION_RE = /Cookie:\s*`[^`]*\$\{/ + + // Matches evidence that the file validates cookie values before interpolation. + // Two approaches are valid: + // 1. Blocklist: reject specific injection chars like [;\r\n] + // 2. Allowlist: only permit safe characters like [a-fA-F0-9] + const INJECTION_GUARD_PATTERNS = [ + /\[;\\r\\n\]/, // blocklist regex literal [;\r\n] + /\[;\\r\\n]/, // alternate escaping + /unsafeChars/, // variable name convention from DC adapter + /\[\^a-fA-F0-9\]/, // hex-only allowlist (stricter than blocklist) + /\[\^a-f0-9\]/i, // hex-only allowlist variant + /\[\^\\w\]/, // word-char-only allowlist + /injection.*guard/i, // explicit labeling + /header.*injection/i, // explicit labeling + /validateMam/, // MAM-specific validation function + ] + + for (const file of adapterFiles) { + if (isTestFile(file)) continue + const content = fs.readFileSync(file, "utf8") + const lines = content.split("\n") + const rel = relativePath(file) + + // Find lines with Cookie template interpolation + const cookieLines: number[] = [] + for (let i = 0; i < lines.length; i++) { + if (COOKIE_INTERPOLATION_RE.test(lines[i])) { + cookieLines.push(i) + } + } + + if (cookieLines.length === 0) continue + + // Check if the file contains ANY injection guard pattern + const hasGuard = INJECTION_GUARD_PATTERNS.some((re) => re.test(content)) + + if (!hasGuard) { + for (const lineIdx of cookieLines) { + findings.push({ + file: rel, + line: lineIdx + 1, + detail: + "Cookie header uses template interpolation but no injection guard (validation against [;\\r\\n]) found in this file. User-provided values could inject extra cookies or headers.", + }) + } + } + } + + return { + id: "adapter-cookie-injection", + name: "Adapter Cookie headers guard against injection", + severity: "critical", + status: findings.length === 0 ? "pass" : "fail", + findings, + } +} + +// ── Check 38: Adapter credential logging ─────────────────────────────── +// +// Adapter files must never log credential material (api tokens, cookies, +// passwords, passkeys). console.log/warn/error calls in adapters are checked +// to ensure they don't reference credential variables. + +function checkAdapterCredentialLogging(): CheckResult { + const findings: Finding[] = [] + const adaptersDir = path.resolve(SRC_DIR, "lib/adapters") + const adapterFiles = walkFiles(adaptersDir, ".ts") + + // Variable names that hold credentials in adapter code + const CREDENTIAL_VARS = [ + "apiToken", + "creds\\.uid", + "creds\\.pass", + "creds\\.cookies", + "cookies", + "trimmedToken", + "trimmedPass", + "trimmedUid", + ] + const CRED_IN_LOG_RE = new RegExp( + `console\\.(log|warn|error)\\s*\\([^)]*\\b(${CREDENTIAL_VARS.join("|")})\\b` + ) + + for (const file of adapterFiles) { + if (isTestFile(file)) continue + const content = fs.readFileSync(file, "utf8") + const lines = content.split("\n") + const rel = relativePath(file) + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + const trimmed = line.trim() + if (trimmed.startsWith("//") || trimmed.startsWith("*")) continue + + if (CRED_IN_LOG_RE.test(line)) { + findings.push({ + file: rel, + line: i + 1, + detail: "Adapter logs a credential variable. This could expose secrets in server logs.", + }) + } + } + } + + return { + id: "adapter-credential-logging", + name: "Adapter files do not log credential values", + severity: "critical", + status: findings.length === 0 ? "pass" : "fail", + findings, + } +} + // ── Run all checks ────────────────────────────────────────────────────── function runAudit(changedFiles?: string[]): AuditOutput { @@ -2426,6 +2552,8 @@ function runAudit(changedFiles?: string[]): AuditOutput { checkNotificationSsrfValidation(), checkDockerCopySensitiveFiles(), checkClientEnvLeak(), + checkAdapterCookieInjection(), + checkAdapterCredentialLogging(), // Warning — flag but don't fail checkConsoleLogInRoutes(absChangedFiles), checkTodoInSecurityFiles(), diff --git a/scripts/validate-trackers.ts b/scripts/validate-trackers.ts index ac4e7c28..3ec2f532 100644 --- a/scripts/validate-trackers.ts +++ b/scripts/validate-trackers.ts @@ -11,43 +11,22 @@ import fs from "node:fs" import path from "node:path" import type { TrackerRegistryEntry } from "@/data/tracker-registry" +import { + isEmpty, + LOGO_NAME_RE, + normalizeTrackerUrl, + PLACEHOLDER_RE, + SLUG_RE, + VALID_CONTENT_CATEGORIES, +} from "@/data/tracker-validation-rules" import { ALL_TRACKERS } from "@/data/trackers" import { DEFAULT_API_PATHS, VALID_PLATFORM_TYPES } from "@/lib/adapters/constants" +import { isValidHex } from "@/lib/validators" -const VALID_PLATFORMS = [...VALID_PLATFORM_TYPES, "custom"] as const -const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/ -const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/ -const LOGO_NAME_RE = /^\/tracker-logos\/[a-z0-9_]+_logo\.(svg|png)$/ -const PLACEHOLDER_RE = /^TODO$/i +const VALID_PLATFORMS = [...VALID_PLATFORM_TYPES, "custom"] const LOGO_DIR = path.resolve(__dirname, "../public/tracker-logos") const TRACKER_DIR = path.resolve(__dirname, "../src/data/trackers") -const VALID_CONTENT_CATEGORIES = new Set([ - "Movies", - "TV", - "Music", - "Games", - "Apps", - "Sports", - "Books", - "Audiobooks", - "Comics", - "Manga", - "Anime", - "XXX", - "Documentaries", - "Education", - "Tutorials", - "Fanres", -]) - -function isEmpty(val: unknown): boolean { - if (val === undefined || val === null) return true - if (typeof val === "string") return val.trim().length === 0 - if (Array.isArray(val)) return val.length === 0 - return false -} - interface TrackerResult { slug: string name: string @@ -60,8 +39,7 @@ function validate(slugFilter?: string[]): TrackerResult[] { // Check for global issues first const allSlugs = ALL_TRACKERS.map((t: TrackerRegistryEntry) => t.slug) const dupeSlugs = allSlugs.filter((s: string, i: number) => allSlugs.indexOf(s) !== i) - const normalize = (u: string) => u.replace(/\/+$/, "").toLowerCase() - const allUrls = ALL_TRACKERS.map((t: TrackerRegistryEntry) => normalize(t.url)) + const allUrls = ALL_TRACKERS.map((t: TrackerRegistryEntry) => normalizeTrackerUrl(t.url)) const dupeUrls = allUrls.filter((u: string, i: number) => allUrls.indexOf(u) !== i) const nonDraft = ALL_TRACKERS.filter((t: TrackerRegistryEntry) => !t.draft) @@ -75,7 +53,7 @@ function validate(slugFilter?: string[]): TrackerResult[] { // ── Global duplication checks ───────────────────────────────────── if (dupeSlugs.includes(tracker.slug)) errors.push("Duplicate slug") - if (dupeUrls.includes(normalize(tracker.url))) errors.push("Duplicate URL") + if (dupeUrls.includes(normalizeTrackerUrl(tracker.url))) errors.push("Duplicate URL") // ── Required fields (errors) ────────────────────────────────────── if (!SLUG_RE.test(tracker.slug)) errors.push("Invalid slug format") @@ -168,7 +146,7 @@ function validate(slugFilter?: string[]): TrackerResult[] { if (dupeCats.length > 0) errors.push(`Duplicate categories: ${[...new Set(dupeCats)].join(", ")}`) - if (tracker.color && !HEX_COLOR_RE.test(tracker.color)) { + if (tracker.color && !isValidHex(tracker.color)) { errors.push(`Invalid hex color "${tracker.color}"`) } if (tracker.logo) { diff --git a/src/app/(auth)/DashboardClient.tsx b/src/app/(auth)/DashboardClient.tsx index 06dafdd6..3d18a353 100644 --- a/src/app/(auth)/DashboardClient.tsx +++ b/src/app/(auth)/DashboardClient.tsx @@ -1,15 +1,13 @@ // src/app/(auth)/DashboardClient.tsx -// -// Functions: buildTrackerSeries, DashboardClient - "use client" import { H1, H2 } from "@typography" -import { useMemo, useState } from "react" +import dynamic from "next/dynamic" +import { useMemo, useState, useTransition } from "react" +import { DashboardSkeleton } from "@/app/(auth)/DashboardSkeleton" import { CHART_THEME } from "@/components/charts/lib/theme" import { AlertsBanner } from "@/components/dashboard/AlertsBanner" import { AnalyticsSection } from "@/components/dashboard/AnalyticsSection" -import { DashboardSettingsSheet } from "@/components/dashboard/DashboardSettingsSheet" import { DayRangeSidebar } from "@/components/dashboard/DayRangeSidebar" import { EcosystemStatsSection } from "@/components/dashboard/EcosystemStatsSection" import { FleetDashboard } from "@/components/dashboard/FleetDashboard" @@ -20,14 +18,23 @@ import { TodayAtAGlanceSkeleton } from "@/components/dashboard/TodayAtAGlanceSke import { TrackerLeaderboard } from "@/components/dashboard/TrackerLeaderboard" import { TrackerOverviewGrid } from "@/components/dashboard/TrackerOverviewGrid" import { useDashboardSettings } from "@/components/dashboard/useDashboardSettings" -import { Button } from "@/components/ui/Button" -import { GearIcon } from "@/components/ui/Icons" -import { TabBar } from "@/components/ui/TabBar" +import { Button, Divider, GearIcon, TabBar } from "@/components/ui" import { useDashboardData } from "@/hooks/useDashboardData" import { computeAggregateStats } from "@/lib/dashboard" import type { Snapshot, TrackerSummary } from "@/types/api" import type { TrackerSnapshotSeries } from "@/types/charts" +const DashboardSettingsSheet = dynamic( + () => + import("@/components/dashboard/DashboardSettingsSheet").then((m) => m.DashboardSettingsSheet), + { ssr: false } +) + +const DASHBOARD_TABS = [ + { key: "tracker-stats" as const, label: "Tracker Stats" }, + { key: "torrent-fleet" as const, label: "Torrent Fleet" }, +] + function buildTrackerSeries( trackers: TrackerSummary[], snapshotMap: Map @@ -41,15 +48,21 @@ function buildTrackerSeries( interface DashboardClientProps { initialTrackers: TrackerSummary[] + snapshotRetentionDays: number | null } -export function DashboardClient({ initialTrackers }: DashboardClientProps) { - const data = useDashboardData({ initialTrackers }) +export function DashboardClient({ initialTrackers, snapshotRetentionDays }: DashboardClientProps) { + const data = useDashboardData({ initialTrackers, snapshotRetentionDays }) const dashSettings = useDashboardSettings() const [settingsOpen, setSettingsOpen] = useState(false) + // Two-state tab pattern: dashboardTab updates immediately (drives TabBar pill animation), + // deferredTab updates via startTransition (drives content switch + query gating). + // This prevents the 350ms React reconciliation from blocking the pill's CSS transition. const [dashboardTab, setDashboardTab] = useState<"tracker-stats" | "torrent-fleet">( "tracker-stats" ) + const [deferredTab, setDeferredTab] = useState<"tracker-stats" | "torrent-fleet">("tracker-stats") + const [, startTransition] = useTransition() const aggregateStats = useMemo(() => computeAggregateStats(data.trackers), [data.trackers]) const trackerSeries = useMemo( @@ -58,18 +71,12 @@ export function DashboardClient({ initialTrackers }: DashboardClientProps) { ) if (data.loading) { - return ( -
-

- Loading dashboard... -

-
- ) + return } if (data.trackers.length === 0) { return ( -
+

No trackers added yet

) @@ -138,41 +145,31 @@ export function DashboardClient({ initialTrackers }: DashboardClientProps) {
{/* Divider */} -
+ {/* Aggregate Stats */} {/* Divider */} -
+ {/* Tab Switcher */} { + setDashboardTab(tab) + startTransition(() => setDeferredTab(tab)) + }} /> {/* Analytics / Fleet */}
-
- +
+
-
+
diff --git a/src/app/(auth)/DashboardSkeleton.tsx b/src/app/(auth)/DashboardSkeleton.tsx new file mode 100644 index 00000000..f2526438 --- /dev/null +++ b/src/app/(auth)/DashboardSkeleton.tsx @@ -0,0 +1,62 @@ +// src/app/(auth)/DashboardSkeleton.tsx + +import { Card, Shimmer } from "@/components/ui" + +function DashboardSkeleton() { + return ( +
+ {/* Page header */} +
+ +
+ + +
+
+ + {/* Today At A Glance */} +
+ + +
+
+ {Array.from({ length: 5 }, (_, i) => ( +
+ + +
+ ))} +
+
+
+
+ + {/* Tracker Overview */} +
+ +
+ {Array.from({ length: 3 }, (_, i) => ( + +
+
+ + +
+
+ + + +
+
+
+ ))} +
+
+
+ ) +} + +export { DashboardSkeleton } diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx index 3169d237..2443c9be 100644 --- a/src/app/(auth)/layout.tsx +++ b/src/app/(auth)/layout.tsx @@ -12,18 +12,20 @@ import { QueryProvider } from "./QueryProvider" export const dynamic = "force-dynamic" export default async function AuthLayout({ children }: { children: ReactNode }) { - const [settings] = await db.select({ id: appSettings.id }).from(appSettings).limit(1) - if (!settings) { - redirect("/setup") - } + const [[settings], session] = await Promise.all([ + db.select({ id: appSettings.id }).from(appSettings).limit(1), + getSession(), + ]) - const session = await getSession() - if (!session) { - redirect("/login") - } + if (!settings) redirect("/setup") + if (!session) redirect("/login") - // Auto-restart scheduler if it died (i.e, server restart). - ensureSchedulerRunning(session.encryptionKey) + // Auto-restart scheduler if it died (i.e. server restart). + try { + ensureSchedulerRunning(session.encryptionKey) + } catch (err) { + console.error("[auth-layout] Scheduler startup failed:", err) + } return ( diff --git a/src/app/(auth)/loading.tsx b/src/app/(auth)/loading.tsx new file mode 100644 index 00000000..a35cfc84 --- /dev/null +++ b/src/app/(auth)/loading.tsx @@ -0,0 +1,7 @@ +// src/app/(auth)/loading.tsx + +import { DashboardSkeleton } from "@/app/(auth)/DashboardSkeleton" + +export default function DashboardLoading() { + return +} diff --git a/src/app/(auth)/page.tsx b/src/app/(auth)/page.tsx index e5ed6a6a..6d64e851 100644 --- a/src/app/(auth)/page.tsx +++ b/src/app/(auth)/page.tsx @@ -1,10 +1,18 @@ // src/app/(auth)/page.tsx -import { getTrackerListForDashboard } from "@/lib/server-data" -import type { TrackerSummary } from "@/types/api" +import { fetchSettings, getTrackerListForDashboard } from "@/lib/server-data" import { DashboardClient } from "./DashboardClient" export default async function DashboardPage() { - const trackers = (await getTrackerListForDashboard()) as TrackerSummary[] - return + const [trackers, settingsResult] = await Promise.all([ + getTrackerListForDashboard(), + fetchSettings().catch(() => []), + ]) + const [settings] = settingsResult + return ( + + ) } diff --git a/src/app/(auth)/settings/page.tsx b/src/app/(auth)/settings/page.tsx index 5a16e35a..e54c4810 100644 --- a/src/app/(auth)/settings/page.tsx +++ b/src/app/(auth)/settings/page.tsx @@ -1,15 +1,14 @@ // src/app/(auth)/settings/page.tsx -// -// Functions: SettingsPage +import { Notice } from "@/components/ui/Notice" import { getDatabaseSize, getProxyTrackers, getSettingsForClient } from "@/lib/server-data" import { SettingsClient } from "./SettingsClient" export default async function SettingsPage() { const [settings, proxyTrackers, databaseSize] = await Promise.all([ getSettingsForClient(), - getProxyTrackers(), - getDatabaseSize(), + getProxyTrackers().catch(() => []), + getDatabaseSize().catch(() => "Unknown"), ]) // If settings don't exist, this page shouldn't be reachable @@ -17,7 +16,7 @@ export default async function SettingsPage() { if (!settings) { return (
-

Settings not configured

+
) } diff --git a/src/app/(auth)/trackers/[id]/TrackerDetailClient.tsx b/src/app/(auth)/trackers/[id]/TrackerDetailClient.tsx index a111b512..90599235 100644 --- a/src/app/(auth)/trackers/[id]/TrackerDetailClient.tsx +++ b/src/app/(auth)/trackers/[id]/TrackerDetailClient.tsx @@ -1,17 +1,13 @@ // src/app/(auth)/trackers/[id]/TrackerDetailClient.tsx -// -// Functions: TrackerDetailClient - "use client" import clsx from "clsx" -import { useRouter, useSearchParams } from "next/navigation" +import { useRouter } from "next/navigation" import { type CSSProperties, useCallback, useEffect, useMemo, useState } from "react" import { CHART_THEME } from "@/components/charts/lib/theme" -import type { DayRange } from "@/components/dashboard/DayRangeSidebar" import { RankProgress } from "@/components/dashboard/RankProgress" import { TorrentsTab } from "@/components/dashboard/TorrentsTab" -import { TrackerSettingsDialog } from "@/components/TrackerSettingsDialog" +import { TrackerSettingsSheet } from "@/components/TrackerSettingsSheet" import { AnalyticsTab } from "@/components/tracker-detail/AnalyticsTab" import type { DebugData } from "@/components/tracker-detail/DebugResponseDialog" import { DebugResponseDialog } from "@/components/tracker-detail/DebugResponseDialog" @@ -21,26 +17,35 @@ import { TrackerInfoTab } from "@/components/tracker-detail/TrackerInfoTab" import { TrackerStatusBanner } from "@/components/tracker-detail/TrackerStatusBanner" import { findRegistryEntry } from "@/data/tracker-registry" import { useTrackerTorrents } from "@/hooks/useTrackerTorrents" -import { computeDelta, hexToRgba } from "@/lib/formatters" -import type { SlotContext } from "@/lib/slot-types" +import { hexToRgba } from "@/lib/color-utils" +import { computeDelta } from "@/lib/data-transforms" import type { + DayRange, GazellePlatformMeta, QbitmanageTagConfig, Snapshot, TagGroup, TrackerSummary, } from "@/types/api" +import type { SlotContext } from "@/types/slots" type Tab = "analytics" | "info" | "torrents" const VALID_TABS: Tab[] = ["analytics", "info", "torrents"] +const TRACKER_DETAIL_TABS: { key: Tab; label: string }[] = [ + { key: "analytics", label: "Data & Analytics" }, + { key: "info", label: "Tracker Info" }, + { key: "torrents", label: "Torrents" }, +] + interface TrackerDetailClientProps { trackerId: number initialTracker: TrackerSummary initialAllTimeSnapshots: Snapshot[] initialTagGroups: TagGroup[] initialQbitmanageConfig: { enabled: boolean; tags: QbitmanageTagConfig } | null + initialTab?: string | null } export function TrackerDetailClient({ @@ -49,13 +54,13 @@ export function TrackerDetailClient({ initialAllTimeSnapshots, initialTagGroups, initialQbitmanageConfig, + initialTab: initialTabProp, }: TrackerDetailClientProps) { const router = useRouter() - const searchParams = useSearchParams() const id = String(trackerId) - const initialTab = VALID_TABS.includes(searchParams.get("tab") as Tab) - ? (searchParams.get("tab") as Tab) + const initialTab = VALID_TABS.includes(initialTabProp as Tab) + ? (initialTabProp as Tab) : "analytics" const [tracker, setTracker] = useState(initialTracker) @@ -80,6 +85,7 @@ export function TrackerDetailClient({ tagGroups, trackerSeedingCount: tracker.latestStats?.seedingCount, qbitmanageConfig, + isActive: activeTab === "torrents", }) const snapshots = useMemo(() => { @@ -127,7 +133,9 @@ export function TrackerDetailClient({ setTracker((prev) => ({ ...prev, userPausedAt: wasPaused ? null : new Date().toISOString(), - ...(wasPaused ? { pausedAt: null, consecutiveFailures: 0, lastError: null } : {}), + ...(wasPaused + ? { pausedAt: null, consecutiveFailures: 0, lastError: null, lastErrorAt: null } + : {}), })) try { @@ -137,8 +145,7 @@ export function TrackerDetailClient({ body: JSON.stringify({ pollingPaused: !wasPaused }), }) if (!res.ok) throw new Error("Failed to toggle pause") - const trackerRes = await fetch(`/api/trackers/${id}`) - if (trackerRes.ok) setTracker(await trackerRes.json()) + setTracker(await res.json()) } catch { setTracker((prev) => ({ ...prev, userPausedAt: originalUserPausedAt })) setPollError("Failed to toggle pause — please try again") @@ -206,6 +213,15 @@ export function TrackerDetailClient({ const delta = useMemo(() => computeDelta(snapshots), [snapshots]) const tc = tracker?.color || CHART_THEME.accent + const trackerStyle = useMemo( + () => + ({ + "--tracker-color": tc, + "--tracker-color-dim": hexToRgba(tc, 0.15), + "--tracker-color-glow": hexToRgba(tc, 0.25), + }) as CSSProperties, + [tc] + ) const baseUrl = tracker?.baseUrl const registryEntry = useMemo(() => (baseUrl ? findRegistryEntry(baseUrl) : undefined), [baseUrl]) @@ -214,7 +230,6 @@ export function TrackerDetailClient({ const ctx: SlotContext = { tracker, latestSnapshot, - snapshots, meta: tracker.platformMeta as SlotContext["meta"], registry: registryEntry, accentColor: tc, @@ -225,28 +240,15 @@ export function TrackerDetailClient({ badgeSlots: resolved.get("badge") ?? [], progressSlots: resolved.get("progress") ?? [], } - }, [tracker, latestSnapshot, snapshots, registryEntry, tc]) + }, [tracker, latestSnapshot, registryEntry, tc]) const gazelleMeta: GazellePlatformMeta | null = tracker.platformType === "gazelle" ? (tracker.platformMeta as GazellePlatformMeta | null) : null - const tabs: { key: Tab; label: string }[] = [ - { key: "analytics", label: "Data & Analytics" }, - { key: "info", label: "Tracker Info" }, - { key: "torrents", label: "Torrents" }, - ] + const tabs = TRACKER_DETAIL_TABS return ( -
+
{/* Header */} )} - - setShowDebugDialog(false)} - /> + {showDebugDialog && ( + setShowDebugDialog(false)} + /> + )}
) } diff --git a/src/app/(auth)/trackers/[id]/loading.tsx b/src/app/(auth)/trackers/[id]/loading.tsx index 9a719064..5cbd2fa5 100644 --- a/src/app/(auth)/trackers/[id]/loading.tsx +++ b/src/app/(auth)/trackers/[id]/loading.tsx @@ -1,9 +1,47 @@ // src/app/(auth)/trackers/[id]/loading.tsx +import { Card, Shimmer } from "@/components/ui" + export default function TrackerDetailLoading() { return ( -
-

Loading...

+
+ {/* Header: name + stats */} +
+
+ + +
+
+ {Array.from({ length: 4 }, (_, i) => ( +
+ + +
+ ))} +
+
+ + {/* Tab bar placeholder */} +
+ {Array.from({ length: 3 }, (_, i) => ( + + ))} +
+ + {/* Chart placeholders */} +
+ {Array.from({ length: 4 }, (_, i) => ( + +
+ + +
+
+ ))} +
) } diff --git a/src/app/(auth)/trackers/[id]/page.tsx b/src/app/(auth)/trackers/[id]/page.tsx index 7871c7d1..c038239f 100644 --- a/src/app/(auth)/trackers/[id]/page.tsx +++ b/src/app/(auth)/trackers/[id]/page.tsx @@ -3,31 +3,38 @@ import { notFound } from "next/navigation" import { db } from "@/lib/db" import { appSettings } from "@/lib/db/schema" -import { parseQbitmanageTags } from "@/lib/qbitmanage-defaults" +import { parseQbitmanageTags } from "@/lib/download-clients/qbt/qbitmanage-defaults" import { getSnapshotsForTracker, getTagGroupsWithMembers, getTrackerForClient, } from "@/lib/server-data" -import type { QbitmanageTagConfig, TrackerSummary } from "@/types/api" +import type { QbitmanageTagConfig } from "@/types/api" import { TrackerDetailClient } from "./TrackerDetailClient" -export default async function TrackerDetailPage(props: { params: Promise<{ id: string }> }) { +export default async function TrackerDetailPage(props: { + params: Promise<{ id: string }> + searchParams: Promise<{ [key: string]: string | string[] | undefined }> +}) { const { id } = await props.params + const searchParams = await props.searchParams + const rawTab = searchParams?.tab + const initialTab = typeof rawTab === "string" ? rawTab : null const trackerId = parseInt(id, 10) if (Number.isNaN(trackerId) || trackerId < 1) notFound() const [tracker, allTimeSnapshots, tagGroupsData, settingsRow] = await Promise.all([ getTrackerForClient(trackerId), getSnapshotsForTracker(trackerId, 0), - getTagGroupsWithMembers(), + getTagGroupsWithMembers().catch(() => []), db .select({ qbitmanageEnabled: appSettings.qbitmanageEnabled, qbitmanageTags: appSettings.qbitmanageTags, }) .from(appSettings) - .limit(1), + .limit(1) + .catch(() => []), ]) if (!tracker) notFound() @@ -44,10 +51,11 @@ export default async function TrackerDetailPage(props: { params: Promise<{ id: s return ( ) } diff --git a/src/app/api/alerts/dismissed/dismissed-route.test.ts b/src/app/api/alerts/dismissed/dismissed-route.test.ts index c14b66c2..8fde7002 100644 --- a/src/app/api/alerts/dismissed/dismissed-route.test.ts +++ b/src/app/api/alerts/dismissed/dismissed-route.test.ts @@ -2,15 +2,20 @@ import { NextResponse } from "next/server" import { beforeEach, describe, expect, it, vi } from "vitest" -import { NON_DISMISSIBLE_ALERT_TYPES } from "@/lib/alert-pruning" +// biome-ignore lint/correctness/noUnusedImports: used in vi.mock factory below +import { NON_DISMISSIBLE_ALERT_TYPES, pruneDismissedAlerts } from "@/lib/alert-pruning" import { authenticate, parseJsonBody } from "@/lib/api-helpers" import { db } from "@/lib/db" import { DELETE, GET, POST } from "./route" -vi.mock("@/lib/api-helpers", () => ({ - authenticate: vi.fn(), - parseJsonBody: vi.fn(), -})) +vi.mock("@/lib/api-helpers", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + authenticate: vi.fn(), + parseJsonBody: vi.fn(), + } +}) vi.mock("@/lib/db", () => ({ db: { @@ -30,6 +35,7 @@ vi.mock("@/lib/alert-pruning", async (importOriginal) => { EXPIRING_ALERT_TYPES: actual.EXPIRING_ALERT_TYPES, NON_DISMISSIBLE_ALERT_TYPES: actual.NON_DISMISSIBLE_ALERT_TYPES, ALERT_EXPIRY_MS: actual.ALERT_EXPIRY_MS, + pruneDismissedAlerts: vi.fn().mockResolvedValue(undefined), } }) @@ -61,49 +67,30 @@ describe("GET /api/alerts/dismissed", () => { expect(res.status).toBe(401) }) - it("returns dismissed alert keys after pruning expired rows", async () => { - const mockDeleteWhere = vi.fn().mockResolvedValue(undefined) - ;(db.delete as ReturnType).mockReturnValue({ where: mockDeleteWhere }) - - // First select: find expired rows (returns some expired keys) - const mockSelectWhere1 = vi.fn().mockResolvedValue([{ alertKey: "expired-1" }]) - const mockSelectFrom1 = vi.fn().mockReturnValue({ where: mockSelectWhere1 }) - - // Second select: get remaining rows - const mockSelectFrom2 = vi + it("prunes expired rows and returns remaining keys", async () => { + const mockSelectFrom = vi .fn() .mockResolvedValue([{ alertKey: "active-1" }, { alertKey: "active-2" }]) - - ;(db.select as ReturnType) - .mockReturnValueOnce({ from: mockSelectFrom1 }) - .mockReturnValueOnce({ from: mockSelectFrom2 }) + ;(db.select as ReturnType).mockReturnValue({ from: mockSelectFrom }) const res = await GET() const data = await res.json() expect(res.status).toBe(200) expect(data.keys).toEqual(["active-1", "active-2"]) - expect(db.delete).toHaveBeenCalledTimes(1) + expect(pruneDismissedAlerts).toHaveBeenCalledOnce() }) - it("skips pruning when no expired rows exist", async () => { - // First select: no expired rows - const mockSelectWhere1 = vi.fn().mockResolvedValue([]) - const mockSelectFrom1 = vi.fn().mockReturnValue({ where: mockSelectWhere1 }) - - // Second select: remaining rows - const mockSelectFrom2 = vi.fn().mockResolvedValue([{ alertKey: "key-1" }]) - - ;(db.select as ReturnType) - .mockReturnValueOnce({ from: mockSelectFrom1 }) - .mockReturnValueOnce({ from: mockSelectFrom2 }) + it("returns empty keys array when none exist", async () => { + const mockSelectFrom = vi.fn().mockResolvedValue([]) + ;(db.select as ReturnType).mockReturnValue({ from: mockSelectFrom }) const res = await GET() const data = await res.json() expect(res.status).toBe(200) - expect(data.keys).toEqual(["key-1"]) - expect(db.delete).not.toHaveBeenCalled() + expect(data.keys).toEqual([]) + expect(pruneDismissedAlerts).toHaveBeenCalledOnce() }) }) @@ -266,14 +253,6 @@ describe("POST /api/alerts/dismissed — non-dismissible rejection", () => { expect(data.error).toBe("This alert type cannot be dismissed") }) - it("confirms client-error is in the NON_DISMISSIBLE set", () => { - expect(NON_DISMISSIBLE_ALERT_TYPES.has("client-error")).toBe(true) - }) - - it("confirms poll-paused is in the NON_DISMISSIBLE set", () => { - expect(NON_DISMISSIBLE_ALERT_TYPES.has("poll-paused")).toBe(true) - }) - it("rejects dismissing a poll-paused alert type", async () => { ;(parseJsonBody as ReturnType).mockResolvedValue({ key: "poll-paused-1", @@ -321,20 +300,18 @@ describe("POST /api/alerts/dismissed — non-dismissible rejection", () => { expect(res.status).toBe(200) }) - it("allows dismissing an arbitrary non-blocked type", async () => { + it("rejects unknown alert types with 400", async () => { ;(parseJsonBody as ReturnType).mockResolvedValue({ key: "some-key", type: "custom-type", }) - const mockOnConflictDoNothing = vi.fn().mockResolvedValue(undefined) - const mockValues = vi.fn().mockReturnValue({ onConflictDoNothing: mockOnConflictDoNothing }) - ;(db.insert as ReturnType).mockReturnValue({ values: mockValues }) - const req = makeRequest("http://localhost/api/alerts/dismissed", undefined, "POST") const res = await POST(req) - expect(res.status).toBe(200) + expect(res.status).toBe(400) + const data = await res.json() + expect(data.error).toBe("Unknown alert type") }) }) diff --git a/src/app/api/alerts/dismissed/route.ts b/src/app/api/alerts/dismissed/route.ts index a83f4306..0826fde4 100644 --- a/src/app/api/alerts/dismissed/route.ts +++ b/src/app/api/alerts/dismissed/route.ts @@ -2,38 +2,20 @@ // // Functions: GET, POST, DELETE -import { and, eq, inArray, lt } from "drizzle-orm" +import { eq } from "drizzle-orm" import { NextResponse } from "next/server" -import { - ALERT_EXPIRY_MS, - EXPIRING_ALERT_TYPES, - NON_DISMISSIBLE_ALERT_TYPES, -} from "@/lib/alert-pruning" -import { authenticate, parseJsonBody } from "@/lib/api-helpers" +import { NON_DISMISSIBLE_ALERT_TYPES, pruneDismissedAlerts } from "@/lib/alert-pruning" +import { authenticate, parseJsonBody, validateMaxLength } from "@/lib/api-helpers" +import { VALID_ALERT_TYPES } from "@/lib/dashboard" import { db } from "@/lib/db" import { dismissedAlerts } from "@/lib/db/schema" +import { ALERT_KEY_MAX, ALERT_TYPE_MAX } from "@/lib/limits" export async function GET() { const auth = await authenticate() if (auth instanceof NextResponse) return auth - const cutoff = new Date(Date.now() - ALERT_EXPIRY_MS) - - // Lazily prune expired rows for expiring types - const expiredRows = await db - .select({ alertKey: dismissedAlerts.alertKey }) - .from(dismissedAlerts) - .where( - and( - inArray(dismissedAlerts.alertType, EXPIRING_ALERT_TYPES), - lt(dismissedAlerts.dismissedAt, cutoff) - ) - ) - - if (expiredRows.length > 0) { - const expiredKeys = expiredRows.map((r) => r.alertKey) - await db.delete(dismissedAlerts).where(inArray(dismissedAlerts.alertKey, expiredKeys)) - } + await pruneDismissedAlerts() const remaining = await db.select({ alertKey: dismissedAlerts.alertKey }).from(dismissedAlerts) @@ -56,9 +38,8 @@ export async function POST(request: Request) { if (normalizedKey.length === 0) { return NextResponse.json({ error: "key must be a non-empty string" }, { status: 400 }) } - if (normalizedKey.length > 255) { - return NextResponse.json({ error: "key must be 255 characters or fewer" }, { status: 400 }) - } + const keyErr = validateMaxLength(normalizedKey, ALERT_KEY_MAX, "key") + if (keyErr) return keyErr if (typeof type !== "string") { return NextResponse.json({ error: "type must be a non-empty string" }, { status: 400 }) @@ -67,11 +48,13 @@ export async function POST(request: Request) { if (normalizedType.length === 0) { return NextResponse.json({ error: "type must be a non-empty string" }, { status: 400 }) } - if (normalizedType.length > 30) { - return NextResponse.json({ error: "type must be 30 characters or fewer" }, { status: 400 }) - } + const typeErr = validateMaxLength(normalizedType, ALERT_TYPE_MAX, "type") + if (typeErr) return typeErr - if (NON_DISMISSIBLE_ALERT_TYPES.has(normalizedType)) { + if (!(VALID_ALERT_TYPES as Set).has(normalizedType)) { + return NextResponse.json({ error: "Unknown alert type" }, { status: 400 }) + } + if ((NON_DISMISSIBLE_ALERT_TYPES as Set).has(normalizedType)) { return NextResponse.json({ error: "This alert type cannot be dismissed" }, { status: 400 }) } diff --git a/src/app/api/auth/change-password/route.ts b/src/app/api/auth/change-password/route.ts index d255e372..ff43380d 100644 --- a/src/app/api/auth/change-password/route.ts +++ b/src/app/api/auth/change-password/route.ts @@ -4,7 +4,8 @@ // // Changes the master password and re-encrypts all encrypted fields // (tracker API tokens, download client credentials, proxy password, -// backup password, TOTP secrets) inside a single transaction. +// backup password, TOTP secrets, image host API keys, notification +// target configs) inside a single transaction. // Requires an active session and the current password for verification. import { eq } from "drizzle-orm" @@ -13,7 +14,8 @@ import { authenticate, decodeKey, parseJsonBody } from "@/lib/api-helpers" import { clearSession, hashPassword, verifyPassword } from "@/lib/auth" import { decrypt, deriveKey, encrypt, reencrypt } from "@/lib/crypto" import { db } from "@/lib/db" -import { appSettings, downloadClients, trackers } from "@/lib/db/schema" +import { appSettings, downloadClients, notificationTargets, trackers } from "@/lib/db/schema" +import { PASSWORD_MAX, PASSWORD_MIN } from "@/lib/limits" import { recordFailedAttempt, resetFailedAttempts } from "@/lib/lockout" import { log } from "@/lib/logger" import { stopScheduler } from "@/lib/scheduler" @@ -31,17 +33,21 @@ export async function POST(request: Request) { newPassword?: string } - if (!currentPassword || typeof currentPassword !== "string" || currentPassword.length > 128) { + if ( + !currentPassword || + typeof currentPassword !== "string" || + currentPassword.length > PASSWORD_MAX + ) { return NextResponse.json({ error: "Current password is required" }, { status: 400 }) } if ( !newPassword || typeof newPassword !== "string" || - newPassword.length < 8 || - newPassword.length > 128 + newPassword.length < PASSWORD_MIN || + newPassword.length > PASSWORD_MAX ) { return NextResponse.json( - { error: "New password must be between 8 and 128 characters" }, + { error: `New password must be between ${PASSWORD_MIN} and ${PASSWORD_MAX} characters` }, { status: 400 } ) } @@ -70,43 +76,75 @@ export async function POST(request: Request) { // already-corrupted items before committing any writes. const trackerPlaintexts = new Map() const clientPlaintexts = new Map() + const notificationPlaintexts = new Map() const failedTrackers: string[] = [] const failedClients: string[] = [] + const failedNotifications: string[] = [] + + const [allTrackers, allClients, allNotifications] = await Promise.all([ + db + .select({ + id: trackers.id, + name: trackers.name, + encryptedApiToken: trackers.encryptedApiToken, + }) + .from(trackers), + db + .select({ + id: downloadClients.id, + name: downloadClients.name, + encryptedUsername: downloadClients.encryptedUsername, + encryptedPassword: downloadClients.encryptedPassword, + }) + .from(downloadClients), + db + .select({ + id: notificationTargets.id, + name: notificationTargets.name, + encryptedConfig: notificationTargets.encryptedConfig, + }) + .from(notificationTargets), + ]) - const allTrackers = await db - .select({ - id: trackers.id, - name: trackers.name, - encryptedApiToken: trackers.encryptedApiToken, - }) - .from(trackers) for (const tracker of allTrackers) { try { trackerPlaintexts.set(tracker.id, decrypt(tracker.encryptedApiToken, oldKey)) - } catch { + } catch (err) { + log.warn( + { trackerId: tracker.id, error: String(err) }, + "Failed to decrypt tracker API token during password change" + ) failedTrackers.push(tracker.name) } } - const allClients = await db - .select({ - id: downloadClients.id, - name: downloadClients.name, - encryptedUsername: downloadClients.encryptedUsername, - encryptedPassword: downloadClients.encryptedPassword, - }) - .from(downloadClients) for (const client of allClients) { try { clientPlaintexts.set(client.id, { username: decrypt(client.encryptedUsername, oldKey), password: decrypt(client.encryptedPassword, oldKey), }) - } catch { + } catch (err) { + log.warn( + { clientId: client.id, error: String(err) }, + "Failed to decrypt client credentials during password change" + ) failedClients.push(client.name) } } + for (const nt of allNotifications) { + try { + notificationPlaintexts.set(nt.id, decrypt(nt.encryptedConfig, oldKey)) + } catch (err) { + log.warn( + { targetId: nt.id, error: String(err) }, + "Failed to decrypt notification config during password change" + ) + failedNotifications.push(nt.name) + } + } + const settingsUpdates: Record = {} const warnings: string[] = [] let totpDisabled = false @@ -114,8 +152,11 @@ export async function POST(request: Request) { if (settings.totpSecret) { try { settingsUpdates.totpSecret = reencrypt(settings.totpSecret, oldKey, newKey) - } catch { - // security-audit-ignore: re-encryption failed — clearing TOTP is the safe fallback + } catch (err) { + log.warn( + { error: String(err) }, + "TOTP secret re-encryption failed during password change, disabling 2FA" + ) settingsUpdates.totpSecret = null settingsUpdates.totpBackupCodes = null totpDisabled = true @@ -124,8 +165,11 @@ export async function POST(request: Request) { if (settings.totpBackupCodes && !settingsUpdates.totpBackupCodes && !totpDisabled) { try { settingsUpdates.totpBackupCodes = reencrypt(settings.totpBackupCodes, oldKey, newKey) - } catch { - // security-audit-ignore: clearing backup codes is safe when re-encryption fails + } catch (err) { + log.warn( + { error: String(err) }, + "TOTP backup codes re-encryption failed during password change" + ) settingsUpdates.totpBackupCodes = null } } @@ -160,6 +204,51 @@ export async function POST(request: Request) { } } + if (settings.encryptedPtpimgApiKey) { + try { + settingsUpdates.encryptedPtpimgApiKey = reencrypt( + settings.encryptedPtpimgApiKey, + oldKey, + newKey + ) + } catch { + settingsUpdates.encryptedPtpimgApiKey = null + warnings.push( + "PTPImg API key could not be re-encrypted and was cleared. Re-enter it in settings." + ) + } + } + + if (settings.encryptedOeimgApiKey) { + try { + settingsUpdates.encryptedOeimgApiKey = reencrypt( + settings.encryptedOeimgApiKey, + oldKey, + newKey + ) + } catch { + settingsUpdates.encryptedOeimgApiKey = null + warnings.push( + "OEImg API key could not be re-encrypted and was cleared. Re-enter it in settings." + ) + } + } + + if (settings.encryptedImgbbApiKey) { + try { + settingsUpdates.encryptedImgbbApiKey = reencrypt( + settings.encryptedImgbbApiKey, + oldKey, + newKey + ) + } catch { + settingsUpdates.encryptedImgbbApiKey = null + warnings.push( + "ImgBB API key could not be re-encrypted and was cleared. Re-enter it in settings." + ) + } + } + // All decrypts done. Write phase is all-or-nothing inside a transaction. // Only items that successfully decrypted are re-encrypted and committed. try { @@ -181,6 +270,13 @@ export async function POST(request: Request) { .where(eq(downloadClients.id, id)) } + for (const [id, plainConfig] of notificationPlaintexts) { + await tx + .update(notificationTargets) + .set({ encryptedConfig: encrypt(plainConfig, newKey) }) + .where(eq(notificationTargets.id, id)) + } + await tx .update(appSettings) .set({ passwordHash: newHash, ...settingsUpdates }) @@ -219,6 +315,11 @@ export async function POST(request: Request) { `Could not re-encrypt ${failedClients.length} client credential(s). Re-enter them manually.` ) } + if (failedNotifications.length > 0) { + warnings.push( + `${failedNotifications.length} notification target(s) could not be re-encrypted and were skipped: ${failedNotifications.join(", ")}` + ) + } if (totpDisabled) { log.warn( { route: "POST /api/auth/change-password" }, diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index b1b7dde3..905fca4d 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -9,6 +9,8 @@ import { extractClientIp } from "@/lib/client-ip" import { deriveKey } from "@/lib/crypto" import { db } from "@/lib/db" import { appSettings } from "@/lib/db/schema" +import { errMsg } from "@/lib/error-utils" +import { PASSWORD_MAX } from "@/lib/limits" import { checkLockout, recordFailedAttempt, resetFailedAttempts } from "@/lib/lockout" import { log } from "@/lib/logger" import { startScheduler } from "@/lib/scheduler" @@ -29,7 +31,7 @@ export async function POST(request: Request) { const clientIp = extractClientIp(request.headers) const password = body.password as string | undefined - if (!password || typeof password !== "string" || password.length > 128) { + if (!password || typeof password !== "string" || password.length > PASSWORD_MAX) { return NextResponse.json({ error: "Invalid password" }, { status: 400 }) } @@ -41,32 +43,70 @@ export async function POST(request: Request) { typeof username === "string" && username.toLowerCase() === settings.username.toLowerCase() } - // Always run Argon2 to normalize timing — prevents username oracle - const passwordOk = await verifyPassword(settings.passwordHash, password) + // Run Argon2 + scrypt in parallel — both depend only on password + settings, not each other. + // Argon2 always runs to normalize timing (prevents username oracle). + // scrypt runs even on failure — improves timing normalization and is acceptable + // for a single-user app with rate limiting. + let passwordOk: boolean + let key: Buffer + try { + ;[passwordOk, key] = await Promise.all([ + verifyPassword(settings.passwordHash, password), + deriveKey(password, settings.encryptionSalt), + ]) + } catch (err) { + log.error( + { route: "POST /api/auth/login", error: errMsg(err) }, + "Crypto operation failed during login" + ) + return NextResponse.json( + { error: "Login system error. Contact administrator." }, + { status: 500 } + ) + } if (!usernameOk || !passwordOk) { await recordFailedAttempt(settings.id, settings) log.warn({ event: "login_failed", ip: clientIp }, "Failed login attempt") return NextResponse.json({ error: "Invalid credentials" }, { status: 401 }) } - - // Derive encryption key - const key = await deriveKey(password, settings.encryptionSalt) const keyHex = key.toString("hex") // If TOTP is enrolled, return a pending token instead of a full session. // Don't reset the counter yet — TOTP verification is still pending. if (settings.totpSecret) { log.info({ event: "login_totp_pending", ip: clientIp }, "Password verified, awaiting TOTP") - const pendingToken = await createPendingToken(keyHex) - return NextResponse.json({ requiresTotp: true, pendingToken }) + try { + const pendingToken = await createPendingToken(keyHex) + return NextResponse.json({ requiresTotp: true, pendingToken }) + } catch (err) { + log.error( + { route: "POST /api/auth/login", error: errMsg(err) }, + "Failed to create pending token after successful auth" + ) + return NextResponse.json( + { error: "Login succeeded but session creation failed. Check server configuration." }, + { status: 500 } + ) + } } // No TOTP — login fully successful, reset failed attempts await resetFailedAttempts(settings.id) - await createSession(keyHex, settings.sessionTimeoutMinutes) - await persistSchedulerKey(key, settings.id) - startScheduler(key) + try { + await createSession(keyHex, settings.sessionTimeoutMinutes) + await persistSchedulerKey(key, settings.id) + startScheduler(key) + } catch (err) { + log.error( + { route: "POST /api/auth/login", error: errMsg(err) }, + "Session creation failed after successful auth" + ) + return NextResponse.json( + { error: "Login succeeded but session creation failed. Check server configuration." }, + { status: 500 } + ) + } log.info({ event: "login_success", ip: clientIp }, "Login successful") return NextResponse.json({ success: true }) diff --git a/src/app/api/auth/setup/route.ts b/src/app/api/auth/setup/route.ts index d594411d..2ce8558f 100644 --- a/src/app/api/auth/setup/route.ts +++ b/src/app/api/auth/setup/route.ts @@ -1,20 +1,38 @@ // src/app/api/auth/setup/route.ts import { NextResponse } from "next/server" -import { parseJsonBody } from "@/lib/api-helpers" +import { parseJsonBody, validateIntRange } from "@/lib/api-helpers" import { hashPassword } from "@/lib/auth" import { generateSalt } from "@/lib/crypto" import { db } from "@/lib/db" import { appSettings } from "@/lib/db/schema" +import { errMsg } from "@/lib/error-utils" +import { + PASSWORD_MAX, + PASSWORD_MIN, + SNAPSHOT_RETENTION_MAX, + SNAPSHOT_RETENTION_MIN, + USERNAME_MAX, + USERNAME_MIN, +} from "@/lib/limits" import { log } from "@/lib/logger" export async function POST(request: Request) { const body = await parseJsonBody(request) if (body instanceof NextResponse) return body - const { password, username } = body as { password?: string; username?: string } - if (!password || typeof password !== "string" || password.length < 8 || password.length > 128) { + const { password, username, snapshotRetentionDays } = body as { + password?: string + username?: string + snapshotRetentionDays?: number + } + if ( + !password || + typeof password !== "string" || + password.length < PASSWORD_MIN || + password.length > PASSWORD_MAX + ) { return NextResponse.json( - { error: "Password must be between 8 and 128 characters" }, + { error: `Password must be between ${PASSWORD_MIN} and ${PASSWORD_MAX} characters` }, { status: 400 } ) } @@ -23,9 +41,9 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Username is required" }, { status: 400 }) } const validatedUsername = username.trim() - if (validatedUsername.length < 3 || validatedUsername.length > 100) { + if (validatedUsername.length < USERNAME_MIN || validatedUsername.length > USERNAME_MAX) { return NextResponse.json( - { error: "Username must be between 3 and 100 characters" }, + { error: `Username must be between ${USERNAME_MIN} and ${USERNAME_MAX} characters` }, { status: 400 } ) } @@ -38,6 +56,28 @@ export async function POST(request: Request) { ) } + // Validate optional retention setting + let validatedRetention: number | undefined + if (snapshotRetentionDays !== undefined) { + if (typeof snapshotRetentionDays !== "number") { + return NextResponse.json( + { + error: `snapshotRetentionDays must be an integer between ${SNAPSHOT_RETENTION_MIN} and ${SNAPSHOT_RETENTION_MAX}`, + }, + { status: 400 } + ) + } + const retentionErr = validateIntRange( + snapshotRetentionDays, + SNAPSHOT_RETENTION_MIN, + SNAPSHOT_RETENTION_MAX, + "snapshotRetentionDays", + `snapshotRetentionDays must be an integer between ${SNAPSHOT_RETENTION_MIN} and ${SNAPSHOT_RETENTION_MAX}` + ) + if (retentionErr) return retentionErr + validatedRetention = snapshotRetentionDays + } + // Fast pre-flight: skip expensive hashing if already configured const preCheck = await db.select({ id: appSettings.id }).from(appSettings).limit(1) if (preCheck.length > 0) { @@ -49,22 +89,32 @@ export async function POST(request: Request) { const encryptionSalt = generateSalt() // Atomic check-and-insert with serializable isolation: prevents TOCTOU race - const inserted = await db.transaction( - async (tx) => { - const existing = await tx.select({ id: appSettings.id }).from(appSettings).limit(1) - if (existing.length > 0) return false - await tx.insert(appSettings).values({ - passwordHash, - encryptionSalt, - username: validatedUsername, - }) - return true - }, - { isolationLevel: "serializable" } - ) + let inserted: boolean + try { + inserted = await db.transaction( + async (tx) => { + const existing = await tx.select({ id: appSettings.id }).from(appSettings).limit(1) + if (existing.length > 0) return false + await tx.insert(appSettings).values({ + passwordHash, + encryptionSalt, + username: validatedUsername, + ...(validatedRetention !== undefined && { snapshotRetentionDays: validatedRetention }), + }) + return true + }, + { isolationLevel: "serializable" } + ) + } catch (err) { + log.error({ route: "POST /api/auth/setup", error: errMsg(err) }, "Setup transaction failed") + return NextResponse.json( + { error: "Setup failed due to a database error. Please try again." }, + { status: 500 } + ) + } if (!inserted) { - log.warn({ route: "POST /api/auth/setup" }, "setup rejected — race condition") + log.warn({ route: "POST /api/auth/setup" }, "setup rejected: race condition") return NextResponse.json({ error: "Already configured" }, { status: 400 }) } diff --git a/src/app/api/auth/status/route.ts b/src/app/api/auth/status/route.ts index 6c871b9e..8fb0fa41 100644 --- a/src/app/api/auth/status/route.ts +++ b/src/app/api/auth/status/route.ts @@ -5,8 +5,13 @@ import { db } from "@/lib/db" import { appSettings } from "@/lib/db/schema" export async function GET() { - const [settings] = await db.select().from(appSettings).limit(1) - const session = await getSession() + const [[settings], session] = await Promise.all([ + db + .select({ totpSecret: appSettings.totpSecret, username: appSettings.username }) + .from(appSettings) + .limit(1), + getSession(), + ]) return NextResponse.json({ configured: !!settings, diff --git a/src/app/api/auth/totp/confirm/route.ts b/src/app/api/auth/totp/confirm/route.ts index 0cfdf53a..c8e397c0 100644 --- a/src/app/api/auth/totp/confirm/route.ts +++ b/src/app/api/auth/totp/confirm/route.ts @@ -11,8 +11,9 @@ import { verifySetupToken } from "@/lib/auth" import { encrypt } from "@/lib/crypto" import { db } from "@/lib/db" import { appSettings } from "@/lib/db/schema" +import { TOTP_TOKEN_MAX } from "@/lib/limits" import { log } from "@/lib/logger" -import { verifyTotpCode } from "@/lib/totp" +import { TOTP_CODE_RE, verifyTotpCode } from "@/lib/totp" export async function POST(request: Request) { const auth = await authenticate() @@ -27,10 +28,10 @@ export async function POST(request: Request) { enableBackupCodes?: boolean } - if (!setupToken || typeof setupToken !== "string" || setupToken.length > 2048) { + if (!setupToken || typeof setupToken !== "string" || setupToken.length > TOTP_TOKEN_MAX) { return NextResponse.json({ error: "Missing setup token" }, { status: 400 }) } - if (!code || typeof code !== "string" || code.length !== 6 || !/^\d{6}$/.test(code)) { + if (!code || typeof code !== "string" || !TOTP_CODE_RE.test(code)) { return NextResponse.json({ error: "Invalid TOTP code — must be 6 digits" }, { status: 400 }) } diff --git a/src/app/api/auth/totp/disable/route.ts b/src/app/api/auth/totp/disable/route.ts index 888a4ddf..b4121412 100644 --- a/src/app/api/auth/totp/disable/route.ts +++ b/src/app/api/auth/totp/disable/route.ts @@ -11,10 +11,16 @@ import { verifyPassword } from "@/lib/auth" import { decrypt, encrypt } from "@/lib/crypto" import { db } from "@/lib/db" import { appSettings } from "@/lib/db/schema" +import { PASSWORD_MAX } from "@/lib/limits" import { recordFailedAttempt, resetFailedAttempts } from "@/lib/lockout" import { log } from "@/lib/logger" import type { BackupCodeEntry } from "@/lib/totp" -import { BACKUP_CODE_PATTERN, verifyAndConsumeBackupCode, verifyTotpCode } from "@/lib/totp" +import { + BACKUP_CODE_PATTERN, + TOTP_CODE_RE, + verifyAndConsumeBackupCode, + verifyTotpCode, +} from "@/lib/totp" export async function POST(request: Request) { const auth = await authenticate() @@ -30,7 +36,7 @@ export async function POST(request: Request) { } // Password re-verification required to disable 2FA - if (!password || typeof password !== "string" || password.length > 128) { + if (!password || typeof password !== "string" || password.length > PASSWORD_MAX) { return NextResponse.json( { error: "Master password is required to disable 2FA" }, { status: 400 } @@ -86,7 +92,7 @@ export async function POST(request: Request) { .where(eq(appSettings.id, settings.id)) } } else { - if (code.length !== 6 || !/^\d{6}$/.test(code)) { + if (!TOTP_CODE_RE.test(code)) { await recordFailedAttempt(settings.id, settings) return NextResponse.json({ error: "Invalid TOTP code — must be 6 digits" }, { status: 400 }) } diff --git a/src/app/api/auth/totp/verify/route.ts b/src/app/api/auth/totp/verify/route.ts index 2a482af7..b75936fd 100644 --- a/src/app/api/auth/totp/verify/route.ts +++ b/src/app/api/auth/totp/verify/route.ts @@ -4,7 +4,7 @@ // // Verifies a TOTP code (or backup code) during login. Exchanges a pending // token + valid code for a full session. This route is public (no session -// cookie required — the user is mid-login). +// cookie required since the user is mid-login). import { eq } from "drizzle-orm" import { NextResponse } from "next/server" @@ -14,12 +14,19 @@ import { extractClientIp } from "@/lib/client-ip" import { decrypt, encrypt } from "@/lib/crypto" import { db } from "@/lib/db" import { appSettings } from "@/lib/db/schema" +import { errMsg } from "@/lib/error-utils" +import { TOTP_TOKEN_MAX } from "@/lib/limits" import { checkLockout, recordFailedAttempt, resetFailedAttempts } from "@/lib/lockout" import { log } from "@/lib/logger" import { startScheduler } from "@/lib/scheduler" import { persistSchedulerKey } from "@/lib/scheduler-key-store" import type { BackupCodeEntry } from "@/lib/totp" -import { BACKUP_CODE_PATTERN, verifyAndConsumeBackupCode, verifyTotpCode } from "@/lib/totp" +import { + BACKUP_CODE_PATTERN, + TOTP_CODE_RE, + verifyAndConsumeBackupCode, + verifyTotpCode, +} from "@/lib/totp" export async function POST(request: Request) { const body = await parseJsonBody(request) @@ -33,7 +40,7 @@ export async function POST(request: Request) { isBackupCode?: boolean } - if (!pendingToken || typeof pendingToken !== "string" || pendingToken.length > 2048) { + if (!pendingToken || typeof pendingToken !== "string" || pendingToken.length > TOTP_TOKEN_MAX) { return NextResponse.json({ error: "Missing pending token" }, { status: 400 }) } if (!code || typeof code !== "string") { @@ -45,7 +52,7 @@ export async function POST(request: Request) { if (!BACKUP_CODE_PATTERN.test(code)) { return NextResponse.json({ error: "Invalid backup code format" }, { status: 400 }) } - } else if (code.length !== 6 || !/^\d{6}$/.test(code)) { + } else if (!TOTP_CODE_RE.test(code)) { return NextResponse.json({ error: "Invalid TOTP code — must be 6 digits" }, { status: 400 }) } @@ -99,7 +106,13 @@ export async function POST(request: Request) { .where(eq(appSettings.id, settings.id)) } else { // Verify TOTP code - const totpSecret = decrypt(settings.totpSecret, key) + let totpSecret: string + try { + totpSecret = decrypt(settings.totpSecret, key) + } catch { + log.error({ route: "POST /api/auth/totp/verify" }, "TOTP verify failed: decrypt error") + return NextResponse.json({ error: "Failed to decrypt TOTP secret" }, { status: 500 }) + } if (!verifyTotpCode(totpSecret, code)) { await recordFailedAttempt(settings.id, settings) log.warn({ event: "totp_failed", method: "totp", ip: clientIp }, "Failed TOTP code attempt") @@ -107,12 +120,26 @@ export async function POST(request: Request) { } } - // Code verified — login fully successful, reset failed attempts + // Code verified await resetFailedAttempts(settings.id) - await createSession(pending.encryptionKey, settings.sessionTimeoutMinutes) - await persistSchedulerKey(key, settings.id) - startScheduler(key) + try { + await createSession(pending.encryptionKey, settings.sessionTimeoutMinutes) + await persistSchedulerKey(key, settings.id) + startScheduler(key) + } catch (err) { + log.error( + { + route: "POST /api/auth/totp/verify", + error: errMsg(err), + }, + "Session creation failed after successful 2FA" + ) + return NextResponse.json( + { error: "Login succeeded but session creation failed. Check server configuration." }, + { status: 500 } + ) + } log.info( { event: "login_success", method: isBackupCode ? "backup_code" : "totp", ip: clientIp }, "Login successful (2FA verified)" diff --git a/src/app/api/changelog/route.ts b/src/app/api/changelog/route.ts index 36d35727..18c1c4fc 100644 --- a/src/app/api/changelog/route.ts +++ b/src/app/api/changelog/route.ts @@ -3,15 +3,20 @@ import { readFile } from "node:fs/promises" import { join } from "node:path" import { NextResponse } from "next/server" import { authenticate } from "@/lib/api-helpers" +import { errMsg } from "@/lib/error-utils" +import { log } from "@/lib/logger" export async function GET() { const auth = await authenticate() if (auth instanceof NextResponse) return auth try { - const content = await readFile(join(process.cwd(), "CHANGELOG.md"), "utf-8") + let content = await readFile(join(process.cwd(), "CHANGELOG.md"), "utf-8") + // Strip version header links for dialog display, keep plain text + content = content.replace(/## \[([^\]]+)\]\([^)]+\)/g, "## $1") return NextResponse.json({ content }) - } catch { + } catch (err) { + log.warn({ err: errMsg(err) }, "[changelog] Failed to read CHANGELOG.md") return NextResponse.json({ content: "No changelog available." }) } } diff --git a/src/app/api/clients/[id]/route.ts b/src/app/api/clients/[id]/route.ts index ea3d8c81..4b3c8ee5 100644 --- a/src/app/api/clients/[id]/route.ts +++ b/src/app/api/clients/[id]/route.ts @@ -1,6 +1,4 @@ // src/app/api/clients/[id]/route.ts -// -// Functions: PATCH, DELETE import { eq } from "drizzle-orm" import { NextResponse } from "next/server" @@ -9,17 +7,30 @@ import { decodeKey, parseJsonBody, parseRouteId, + type RouteContext, + validateIntRange, + validateMaxLength, validatePort, } from "@/lib/api-helpers" import { encrypt } from "@/lib/crypto" +import { sanitizeHost } from "@/lib/data-transforms" import { db } from "@/lib/db" import { downloadClients } from "@/lib/db/schema" +import { VALID_CLIENT_TYPES } from "@/lib/download-clients" +import { errMsg } from "@/lib/error-utils" +import { + CLIENT_POLL_INTERVAL_MAX, + CLIENT_POLL_INTERVAL_MIN, + CREDENTIAL_MAX, + CROSS_SEED_TAG_MAX, + CROSS_SEED_TAGS_MAX, + HOST_MAX, +} from "@/lib/limits" import { log } from "@/lib/logger" -import { PROXY_HOST_PATTERN } from "@/lib/proxy" -import { VALID_CLIENT_TYPES } from "@/lib/qbt/types" -import { removeClientFromAccumulator } from "@/lib/uptime" +import { PROXY_HOST_PATTERN } from "@/lib/tunnel" +import { removeDownloadClientFromAccumulator } from "@/lib/uptime" -export async function PATCH(request: Request, props: { params: Promise<{ id: string }> }) { +export async function PATCH(request: Request, props: RouteContext) { const auth = await authenticate() if (auth instanceof NextResponse) return auth @@ -49,17 +60,15 @@ export async function PATCH(request: Request, props: { params: Promise<{ id: str } if (typeof body.name === "string") { - if (body.name.length > 255) { - return NextResponse.json({ error: "Name must be 255 characters or fewer" }, { status: 400 }) - } + const nameErr = validateMaxLength(body.name, CREDENTIAL_MAX, "Name") + if (nameErr) return nameErr updates.name = body.name.trim() } if (typeof body.host === "string") { - if (body.host.length > 255) { - return NextResponse.json({ error: "Host must be 255 characters or fewer" }, { status: 400 }) - } - const sanitizedHost = body.host.trim().replace(/^https?:\/\//, "") + const hostErr = validateMaxLength(body.host, HOST_MAX, "Host") + if (hostErr) return hostErr + const sanitizedHost = sanitizeHost(body.host) if (!PROXY_HOST_PATTERN.test(sanitizedHost)) { return NextResponse.json({ error: "Invalid host format" }, { status: 400 }) } @@ -83,12 +92,14 @@ export async function PATCH(request: Request, props: { params: Promise<{ id: str if (typeof body.enabled === "boolean") updates.enabled = body.enabled if (typeof body.pollIntervalSeconds === "number") { - if (body.pollIntervalSeconds < 60 || body.pollIntervalSeconds > 86400) { - return NextResponse.json( - { error: "Poll interval must be between 60 and 86400 seconds" }, - { status: 400 } - ) - } + const pollErr = validateIntRange( + body.pollIntervalSeconds, + CLIENT_POLL_INTERVAL_MIN, + CLIENT_POLL_INTERVAL_MAX, + "pollIntervalSeconds", + `Poll interval must be between ${CLIENT_POLL_INTERVAL_MIN} and ${CLIENT_POLL_INTERVAL_MAX} seconds` + ) + if (pollErr) return pollErr updates.pollIntervalSeconds = body.pollIntervalSeconds } @@ -96,7 +107,7 @@ export async function PATCH(request: Request, props: { params: Promise<{ id: str if (!Array.isArray(body.crossSeedTags)) { return NextResponse.json({ error: "crossSeedTags must be an array" }, { status: 400 }) } - if (body.crossSeedTags.length > 50) { + if (body.crossSeedTags.length > CROSS_SEED_TAGS_MAX) { return NextResponse.json( { error: "Cannot specify more than 50 cross-seed tags" }, { status: 400 } @@ -104,7 +115,7 @@ export async function PATCH(request: Request, props: { params: Promise<{ id: str } if ( !body.crossSeedTags.every( - (t: unknown) => typeof t === "string" && t.length > 0 && t.length <= 100 + (t: unknown) => typeof t === "string" && t.length > 0 && t.length <= CROSS_SEED_TAG_MAX ) ) { return NextResponse.json( @@ -116,38 +127,41 @@ export async function PATCH(request: Request, props: { params: Promise<{ id: str } if (typeof body.username === "string") { - if (body.username.length > 255) { - return NextResponse.json( - { error: "Username must be 255 characters or fewer" }, - { status: 400 } - ) - } + const usernameErr = validateMaxLength(body.username, CREDENTIAL_MAX, "Username") + if (usernameErr) return usernameErr updates.encryptedUsername = encrypt(body.username, getKey()) } if (typeof body.password === "string") { - if (body.password.length > 255) { - return NextResponse.json( - { error: "Password must be 255 characters or fewer" }, - { status: 400 } - ) - } + const passwordErr = validateMaxLength(body.password, CREDENTIAL_MAX, "Password") + if (passwordErr) return passwordErr updates.encryptedPassword = encrypt(body.password, getKey()) } if (body.isDefault === true) { - await db.update(downloadClients).set({ isDefault: false }) updates.isDefault = true } else if (body.isDefault === false) { updates.isDefault = false } - await db.update(downloadClients).set(updates).where(eq(downloadClients.id, clientId)) - - return NextResponse.json({ success: true }) + try { + await db.transaction(async (tx) => { + if (body.isDefault === true) { + await tx.update(downloadClients).set({ isDefault: false }) + } + await tx.update(downloadClients).set(updates).where(eq(downloadClients.id, clientId)) + }) + return NextResponse.json({ success: true }) + } catch (err) { + log.error( + { route: "PATCH /api/clients/[id]", clientId, error: errMsg(err) }, + "Failed to update download client" + ) + return NextResponse.json({ error: "Failed to update download client" }, { status: 500 }) + } } -export async function DELETE(_request: Request, props: { params: Promise<{ id: string }> }) { +export async function DELETE(_request: Request, props: RouteContext) { const auth = await authenticate() if (auth instanceof NextResponse) return auth @@ -165,7 +179,7 @@ export async function DELETE(_request: Request, props: { params: Promise<{ id: s return NextResponse.json({ error: "Client not found" }, { status: 404 }) } - removeClientFromAccumulator(clientId) + removeDownloadClientFromAccumulator(clientId) await db.transaction(async (tx) => { await tx.delete(downloadClients).where(eq(downloadClients.id, clientId)) diff --git a/src/app/api/clients/[id]/snapshots/route.ts b/src/app/api/clients/[id]/snapshots/route.ts index 9e4c8062..098ea9be 100644 --- a/src/app/api/clients/[id]/snapshots/route.ts +++ b/src/app/api/clients/[id]/snapshots/route.ts @@ -4,11 +4,11 @@ import { desc, eq } from "drizzle-orm" import { NextResponse } from "next/server" -import { authenticate, parseRouteId } from "@/lib/api-helpers" +import { authenticate, parseRouteId, type RouteContext } from "@/lib/api-helpers" import { db } from "@/lib/db" import { clientSnapshots } from "@/lib/db/schema" -export async function GET(_request: Request, props: { params: Promise<{ id: string }> }) { +export async function GET(_request: Request, props: RouteContext) { const auth = await authenticate() if (auth instanceof NextResponse) return auth @@ -25,6 +25,7 @@ export async function GET(_request: Request, props: { params: Promise<{ id: stri // Serialize bigints to strings for JSON transport const serialized = snapshots.map((s) => ({ ...s, + polledAt: s.polledAt.toISOString(), uploadSpeedBytes: s.uploadSpeedBytes?.toString() ?? null, downloadSpeedBytes: s.downloadSpeedBytes?.toString() ?? null, tagStats: (() => { diff --git a/src/app/api/clients/[id]/speeds/route.ts b/src/app/api/clients/[id]/speeds/route.ts index 9681ca51..8d9cd5b6 100644 --- a/src/app/api/clients/[id]/speeds/route.ts +++ b/src/app/api/clients/[id]/speeds/route.ts @@ -1,10 +1,10 @@ // src/app/api/clients/[id]/speeds/route.ts import { NextResponse } from "next/server" -import { authenticate, parseRouteId } from "@/lib/api-helpers" -import { getSpeedSnapshots } from "@/lib/qbt" +import { authenticate, parseRouteId, type RouteContext } from "@/lib/api-helpers" +import { getSpeedSnapshots } from "@/lib/download-clients" -export async function GET(_request: Request, props: { params: Promise<{ id: string }> }) { +export async function GET(_request: Request, props: RouteContext) { const auth = await authenticate() if (auth instanceof NextResponse) return auth diff --git a/src/app/api/clients/[id]/test/route.ts b/src/app/api/clients/[id]/test/route.ts index 46de3ee4..5b3ddb8e 100644 --- a/src/app/api/clients/[id]/test/route.ts +++ b/src/app/api/clients/[id]/test/route.ts @@ -2,71 +2,28 @@ // // Functions: POST -import { eq } from "drizzle-orm" import { NextResponse } from "next/server" -import { authenticate, decodeKey, parseRouteId } from "@/lib/api-helpers" -import { decryptClientCredentials } from "@/lib/client-decrypt" -import { db } from "@/lib/db" -import { downloadClients } from "@/lib/db/schema" -import { isDecryptionError } from "@/lib/error-utils" +import { authenticate, decodeKey, parseRouteId, type RouteContext } from "@/lib/api-helpers" +import { testClientConnection } from "@/lib/download-clients" import { log } from "@/lib/logger" -import { buildBaseUrl, getTransferInfo, invalidateSession, login } from "@/lib/qbt" -export async function POST(_request: Request, props: { params: Promise<{ id: string }> }) { +export async function POST(_request: Request, props: RouteContext) { const auth = await authenticate() if (auth instanceof NextResponse) return auth const clientId = await parseRouteId(props.params, "client ID") if (clientId instanceof NextResponse) return clientId - const [client] = await db - .select() - .from(downloadClients) - .where(eq(downloadClients.id, clientId)) - .limit(1) - - if (!client) { - return NextResponse.json({ error: "Client not found" }, { status: 404 }) - } - const key = decodeKey(auth) + const out = await testClientConnection(clientId, key) - let username: string - let password: string - try { - ;({ username, password } = decryptClientCredentials(client, key)) - } catch (err) { - if (isDecryptionError(err)) { - log.warn( - { route: "POST /api/clients/[id]/test", clientId }, - "client test failed — stale session key" - ) - return NextResponse.json({ error: "Session expired — please log in again" }, { status: 401 }) - } - log.error( - { route: "POST /api/clients/[id]/test", clientId }, - "client test failed — credential decrypt error" - ) - return NextResponse.json({ error: "Failed to decrypt credentials" }, { status: 422 }) - } - - try { - // Force a fresh login for explicit connection tests — don't use cached SID - const baseUrl = buildBaseUrl(client.host, client.port, client.useSsl) - invalidateSession(baseUrl) - const sid = await login(client.host, client.port, client.useSsl, username, password) - await getTransferInfo(baseUrl, sid) - return NextResponse.json({ success: true }) - } catch (error) { - const raw = error instanceof Error ? error.message : "" - let detail = "" - if (/timed?\s*out/i.test(raw)) detail = " (timed out)" - else if (/ECONNREFUSED/i.test(raw)) detail = " (ECONNREFUSED)" - else if (/403/.test(raw)) detail = " (403)" + if ("error" in out) { log.warn( - { route: "POST /api/clients/[id]/test", clientId, error: `Connection test failed${detail}` }, + { route: "POST /api/clients/[id]/test", clientId, error: out.error }, "client connection test failed" ) - return NextResponse.json({ error: `Connection test failed${detail}` }, { status: 422 }) + return NextResponse.json({ error: out.error }, { status: out.status }) } + + return NextResponse.json(out) } diff --git a/src/app/api/clients/[id]/torrents/route.ts b/src/app/api/clients/[id]/torrents/route.ts index fcb1f7bd..1d1ed7bc 100644 --- a/src/app/api/clients/[id]/torrents/route.ts +++ b/src/app/api/clients/[id]/torrents/route.ts @@ -4,15 +4,14 @@ import { eq } from "drizzle-orm" import { NextResponse } from "next/server" -import { authenticate, decodeKey, parseRouteId } from "@/lib/api-helpers" -import { decryptClientCredentials } from "@/lib/client-decrypt" +import { authenticate, decodeKey, parseRouteId, type RouteContext } from "@/lib/api-helpers" import { db } from "@/lib/db" import { downloadClients } from "@/lib/db/schema" -import { isDecryptionError } from "@/lib/error-utils" +import { createAdapterForClient, stripSensitiveTorrentFields } from "@/lib/download-clients" +import { isDecryptionError, sanitizeNetworkError } from "@/lib/error-utils" import { log } from "@/lib/logger" -import { getTorrents, withSessionRetry } from "@/lib/qbt" -export async function GET(request: Request, props: { params: Promise<{ id: string }> }) { +export async function GET(request: Request, props: RouteContext) { const auth = await authenticate() if (auth instanceof NextResponse) return auth @@ -26,7 +25,16 @@ export async function GET(request: Request, props: { params: Promise<{ id: strin } const [client] = await db - .select() + .select({ + name: downloadClients.name, + host: downloadClients.host, + port: downloadClients.port, + useSsl: downloadClients.useSsl, + encryptedUsername: downloadClients.encryptedUsername, + encryptedPassword: downloadClients.encryptedPassword, + crossSeedTags: downloadClients.crossSeedTags, + type: downloadClients.type, + }) .from(downloadClients) .where(eq(downloadClients.id, clientId)) .limit(1) @@ -37,52 +45,25 @@ export async function GET(request: Request, props: { params: Promise<{ id: strin const key = decodeKey(auth) - let username: string - let password: string try { - ;({ username, password } = decryptClientCredentials(client, key)) - } catch (err) { - if (isDecryptionError(err)) { - log.warn( - { route: "GET /api/clients/[id]/torrents", clientId }, - "torrent fetch failed — stale session key" - ) + const adapter = createAdapterForClient(client, key) + const torrents = await adapter.getTorrents({ tag: tag.trim() }) + return NextResponse.json(torrents.map(stripSensitiveTorrentFields)) + } catch (error) { + if (isDecryptionError(error)) { + log.warn({ route: "GET /api/clients/[id]/torrents", clientId }, "failed — stale session key") return NextResponse.json({ error: "Session expired. Please log in again" }, { status: 401 }) } - log.error( - { route: "GET /api/clients/[id]/torrents", clientId }, - "torrent fetch failed — credential decrypt error" - ) - return NextResponse.json({ error: "Failed to decrypt credentials" }, { status: 422 }) - } - - try { - const torrents = await withSessionRetry( - client.host, - client.port, - client.useSsl, - username, - password, - (baseUrl, sid) => getTorrents(baseUrl, sid, tag.trim()) - ) - return NextResponse.json(torrents) - } catch (error) { const raw = error instanceof Error ? error.message : "" - let detail = "" - if (/timed?\s*out/i.test(raw)) detail = " (timed out)" - else if (/ECONNREFUSED/i.test(raw)) detail = " (ECONNREFUSED)" - else if (/403/.test(raw)) detail = " (403)" + const message = sanitizeNetworkError(raw, "Failed to fetch torrents") log.error( { route: "GET /api/clients/[id]/torrents", clientId, - error: `Failed to fetch torrents${detail}`, + error: message, }, "torrent fetch failed" ) - return NextResponse.json( - { error: `Failed to fetch torrents from client${detail}` }, - { status: 502 } - ) + return NextResponse.json({ error: message }, { status: 502 }) } } diff --git a/src/app/api/clients/[id]/uptime/route.ts b/src/app/api/clients/[id]/uptime/route.ts index f4025699..199c8469 100644 --- a/src/app/api/clients/[id]/uptime/route.ts +++ b/src/app/api/clients/[id]/uptime/route.ts @@ -1,14 +1,12 @@ // src/app/api/clients/[id]/uptime/route.ts -// -// Functions: GET import { and, eq, gte } from "drizzle-orm" import { NextResponse } from "next/server" -import { authenticate, parseRouteId } from "@/lib/api-helpers" +import { authenticate, parseRouteId, type RouteContext } from "@/lib/api-helpers" import { db } from "@/lib/db" import { clientUptimeBuckets, downloadClients } from "@/lib/db/schema" -export async function GET(_request: Request, props: { params: Promise<{ id: string }> }) { +export async function GET(_request: Request, props: RouteContext) { const auth = await authenticate() if (auth instanceof NextResponse) return auth @@ -44,5 +42,9 @@ export async function GET(_request: Request, props: { params: Promise<{ id: stri const total = totalOk + totalFail const uptimePercent = total > 0 ? Math.round((totalOk / total) * 1000) / 10 : null - return NextResponse.json({ buckets, uptimePercent }) + const serializedBuckets = buckets.map((b) => ({ + ...b, + bucketTs: b.bucketTs.toISOString(), + })) + return NextResponse.json({ buckets: serializedBuckets, uptimePercent }) } diff --git a/src/app/api/clients/client-routes.test.ts b/src/app/api/clients/client-routes.test.ts index a6af0ad7..4043607c 100644 --- a/src/app/api/clients/client-routes.test.ts +++ b/src/app/api/clients/client-routes.test.ts @@ -1,15 +1,10 @@ // src/app/api/clients/client-routes.test.ts -// -// Functions: -// mockDbSelectClient - Sets up db.select chain returning one client or empty -// makeRequest - Constructs a Request for GET handler calls import { NextResponse } from "next/server" import { beforeEach, describe, expect, it, vi } from "vitest" import { authenticate } from "@/lib/api-helpers" -import { decrypt } from "@/lib/crypto" import { db } from "@/lib/db" -import { getTorrents, withSessionRetry } from "@/lib/qbt" +import { createAdapterForClient } from "@/lib/download-clients" import { GET } from "./[id]/torrents/route" // --------------------------------------------------------------------------- @@ -33,25 +28,22 @@ vi.mock("@/lib/db", () => ({ }, })) -vi.mock("@/lib/crypto", () => ({ - decrypt: vi.fn(), -})) +const mockAdapter = { + type: "qbittorrent" as const, + baseUrl: "http://192.168.1.100:8080", + testConnection: vi.fn(), + getTorrents: vi.fn().mockResolvedValue([]), + getTransferInfo: vi.fn().mockResolvedValue({ uploadSpeed: 0, downloadSpeed: 0 }), + getDeltaSync: vi.fn(), + dispose: vi.fn(), +} -vi.mock("@/lib/qbt", () => ({ - getTorrents: vi.fn(), - // withSessionRetry: by default, call op with a fixed baseUrl+sid so the - // getTorrents mock still fires normally. Individual tests that need to - // simulate upstream errors replace this with vi.fn().mockRejectedValue(...). - withSessionRetry: vi.fn( - async ( - _host: string, - _port: number, - _ssl: boolean, - _username: string, - _password: string, - op: (baseUrl: string, sid: string) => Promise - ) => op("http://192.168.1.100:8080", "sid-token") - ), +vi.mock("@/lib/download-clients", () => ({ + createAdapterForClient: vi.fn(() => mockAdapter), + stripSensitiveTorrentFields: vi.fn((t: Record) => { + const { tracker: _t, content_path: _cp, save_path: _sp, ...rest } = t + return rest + }), })) // --------------------------------------------------------------------------- @@ -139,6 +131,8 @@ describe("GET /api/clients/[id]/torrents", () => { ;(authenticate as ReturnType).mockResolvedValue({ encryptionKey: VALID_KEY, }) + vi.mocked(createAdapterForClient).mockReturnValue(mockAdapter) + mockAdapter.getTorrents.mockResolvedValue([]) }) // ------------------------------------------------------------------------- @@ -240,8 +234,10 @@ describe("GET /api/clients/[id]/torrents", () => { it("returns 401 when credential decryption fails", async () => { mockDbSelectClient(MOCK_CLIENT) - ;(decrypt as ReturnType).mockImplementation(() => { - throw new Error("decryption error") + // Decryption now happens inside createAdapterForClient. Simulate it throwing + // an AES-GCM authentication failure, which isDecryptionError detects as 401. + vi.mocked(createAdapterForClient).mockImplementation(() => { + throw new Error("EVP_DecryptFinal_ex: bad decrypt") }) const request = makeRequest("http://localhost/api/clients/1/torrents?tag=aither") @@ -251,7 +247,6 @@ describe("GET /api/clients/[id]/torrents", () => { expect(response.status).toBe(401) expect(data.error).toMatch(/session expired/i) - // Response body must not contain the encryption key or the encrypted credential strings const body = JSON.stringify(data) expect(body).not.toContain(VALID_KEY) expect(body).not.toContain(MOCK_CLIENT.encryptedUsername) @@ -264,10 +259,7 @@ describe("GET /api/clients/[id]/torrents", () => { it("returns 502 when qBT login fails with authentication error", async () => { mockDbSelectClient(MOCK_CLIENT) - ;(decrypt as ReturnType) - .mockReturnValueOnce("admin") - .mockReturnValueOnce("secret") - ;(withSessionRetry as ReturnType).mockRejectedValue( + mockAdapter.getTorrents.mockRejectedValue( new Error("Authentication failed — check username and password") ) @@ -277,17 +269,12 @@ describe("GET /api/clients/[id]/torrents", () => { const data = await response.json() expect(response.status).toBe(502) - expect(data.error).toBe("Failed to fetch torrents from client") + expect(data.error).toBe("Failed to fetch torrents") }) it("returns 502 when qBT connection times out", async () => { mockDbSelectClient(MOCK_CLIENT) - ;(decrypt as ReturnType) - .mockReturnValueOnce("admin") - .mockReturnValueOnce("secret") - ;(withSessionRetry as ReturnType).mockRejectedValue( - new Error("Request to 192.168.1.100 timed out") - ) + mockAdapter.getTorrents.mockRejectedValue(new Error("Request to 192.168.1.100 timed out")) const request = makeRequest("http://localhost/api/clients/1/torrents?tag=aither") const params = Promise.resolve({ id: "1" }) @@ -300,12 +287,7 @@ describe("GET /api/clients/[id]/torrents", () => { it("returns 502 when getTorrents fails", async () => { mockDbSelectClient(MOCK_CLIENT) - ;(decrypt as ReturnType) - .mockReturnValueOnce("admin") - .mockReturnValueOnce("secret") - ;(getTorrents as ReturnType).mockRejectedValue( - new Error("qBittorrent API error: 403 Forbidden") - ) + mockAdapter.getTorrents.mockRejectedValue(new Error("qBittorrent API error: 403 Forbidden")) const request = makeRequest("http://localhost/api/clients/1/torrents?tag=aither") const params = Promise.resolve({ id: "1" }) @@ -324,10 +306,7 @@ describe("GET /api/clients/[id]/torrents", () => { // Note: if the upstream error already contains them, that is a known risk // outside this handler's control — this test validates the handler itself. mockDbSelectClient(MOCK_CLIENT) - ;(decrypt as ReturnType) - .mockReturnValueOnce("plaintext-user") - .mockReturnValueOnce("plaintext-pass") - ;(withSessionRetry as ReturnType).mockRejectedValue( + mockAdapter.getTorrents.mockRejectedValue( new Error("upstream error with no credential content") ) @@ -336,7 +315,7 @@ describe("GET /api/clients/[id]/torrents", () => { const response = await GET(request, { params }) const data = await response.json() - // The handler must not inject the plaintext credentials into its own error output + // The handler must not inject plaintext credentials into its own error output expect(data.error).not.toContain("plaintext-user") expect(data.error).not.toContain("plaintext-pass") }) @@ -347,10 +326,7 @@ describe("GET /api/clients/[id]/torrents", () => { it("returns torrent array on success", async () => { mockDbSelectClient(MOCK_CLIENT) - ;(decrypt as ReturnType) - .mockReturnValueOnce("admin") - .mockReturnValueOnce("secret") - ;(getTorrents as ReturnType).mockResolvedValue(MOCK_TORRENTS) + mockAdapter.getTorrents.mockResolvedValue(MOCK_TORRENTS) const request = makeRequest("http://localhost/api/clients/1/torrents?tag=aither") const params = Promise.resolve({ id: "1" }) @@ -370,36 +346,26 @@ describe("GET /api/clients/[id]/torrents", () => { it("passes the tag to getTorrents for server-side filtering", async () => { mockDbSelectClient(MOCK_CLIENT) - ;(decrypt as ReturnType) - .mockReturnValueOnce("admin") - .mockReturnValueOnce("secret") - ;(getTorrents as ReturnType).mockResolvedValue([]) const request = makeRequest("http://localhost/api/clients/1/torrents?tag=aither") const params = Promise.resolve({ id: "1" }) await GET(request, { params }) - // If this assertion fails, the route is not passing the tag to getTorrents, + // If this assertion fails, the route is not passing the tag to adapter.getTorrents, // meaning it would fall back to fetching all torrents — the optimization is broken. - const getTorrentsCalls = (getTorrents as ReturnType).mock.calls - expect(getTorrentsCalls).toHaveLength(1) - expect(getTorrentsCalls[0][2]).toBe("aither") + expect(mockAdapter.getTorrents).toHaveBeenCalledOnce() + expect(mockAdapter.getTorrents).toHaveBeenCalledWith({ tag: "aither" }) }) it("trims whitespace from tag before querying getTorrents", async () => { mockDbSelectClient(MOCK_CLIENT) - ;(decrypt as ReturnType) - .mockReturnValueOnce("admin") - .mockReturnValueOnce("secret") - ;(getTorrents as ReturnType).mockResolvedValue([]) // %20aither%20 decodes to " aither " — should be trimmed to "aither" const request = makeRequest("http://localhost/api/clients/1/torrents?tag=%20aither%20") const params = Promise.resolve({ id: "1" }) await GET(request, { params }) - const getTorrentsCalls = (getTorrents as ReturnType).mock.calls - expect(getTorrentsCalls).toHaveLength(1) - expect(getTorrentsCalls[0][2]).toBe("aither") + expect(mockAdapter.getTorrents).toHaveBeenCalledOnce() + expect(mockAdapter.getTorrents).toHaveBeenCalledWith({ tag: "aither" }) }) }) diff --git a/src/app/api/clients/route.ts b/src/app/api/clients/route.ts index cb3bab77..64906e28 100644 --- a/src/app/api/clients/route.ts +++ b/src/app/api/clients/route.ts @@ -3,42 +3,39 @@ // Functions: GET, POST import { NextResponse } from "next/server" -import { authenticate, decodeKey, parseJsonBody, validatePort } from "@/lib/api-helpers" +import { + authenticate, + decodeKey, + parseJsonBody, + validateIntRange, + validateMaxLength, + validatePort, +} from "@/lib/api-helpers" import { encrypt } from "@/lib/crypto" +import { sanitizeHost } from "@/lib/data-transforms" import { db } from "@/lib/db" import { downloadClients } from "@/lib/db/schema" +import { VALID_CLIENT_TYPES } from "@/lib/download-clients" +import { errMsg } from "@/lib/error-utils" +import { + CLIENT_POLL_INTERVAL_DEFAULT, + CLIENT_POLL_INTERVAL_MAX, + CLIENT_POLL_INTERVAL_MIN, + CREDENTIAL_MAX, + CROSS_SEED_TAG_MAX, + CROSS_SEED_TAGS_MAX, + HOST_MAX, +} from "@/lib/limits" import { log } from "@/lib/logger" -import { PROXY_HOST_PATTERN } from "@/lib/proxy" -import { parseCrossSeedTags } from "@/lib/qbt" -import { VALID_CLIENT_TYPES } from "@/lib/qbt/types" +import { fetchDownloadClients, serializeDownloadClientResponse } from "@/lib/server-data" +import { PROXY_HOST_PATTERN } from "@/lib/tunnel" export async function GET() { const auth = await authenticate() if (auth instanceof NextResponse) return auth - const clients = await db.select().from(downloadClients).orderBy(downloadClients.createdAt) - - // SECURITY: Never return encryptedUsername or encryptedPassword - const safe = clients.map((client) => ({ - id: client.id, - name: client.name, - type: client.type, - enabled: client.enabled, - host: client.host, - port: client.port, - useSsl: client.useSsl, - hasCredentials: !!(client.encryptedUsername && client.encryptedPassword), - pollIntervalSeconds: client.pollIntervalSeconds, - isDefault: client.isDefault, - crossSeedTags: parseCrossSeedTags(client.crossSeedTags), - lastPolledAt: client.lastPolledAt, - lastError: client.lastError, - errorSince: client.errorSince, - createdAt: client.createdAt, - updatedAt: client.updatedAt, - })) - - return NextResponse.json(safe) + const clients = await fetchDownloadClients() + return NextResponse.json(clients.map(serializeDownloadClientResponse)) } export async function POST(request: Request) { @@ -88,23 +85,19 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Invalid field types" }, { status: 400 }) } - if (name.length > 255) { - return NextResponse.json({ error: "Name must be 255 characters or fewer" }, { status: 400 }) - } + const nameErr = validateMaxLength(name, CREDENTIAL_MAX, "Name") + if (nameErr) return nameErr - if (host.length > 255) { - return NextResponse.json({ error: "Host must be 255 characters or fewer" }, { status: 400 }) - } + const hostErr = validateMaxLength(host, HOST_MAX, "Host") + if (hostErr) return hostErr - if (username.length > 255) { - return NextResponse.json({ error: "Username must be 255 characters or fewer" }, { status: 400 }) - } + const usernameErr = validateMaxLength(username, CREDENTIAL_MAX, "Username") + if (usernameErr) return usernameErr - if (password.length > 255) { - return NextResponse.json({ error: "Password must be 255 characters or fewer" }, { status: 400 }) - } + const passwordErr = validateMaxLength(password, CREDENTIAL_MAX, "Password") + if (passwordErr) return passwordErr - const sanitizedHost = host.trim().replace(/^https?:\/\//, "") + const sanitizedHost = sanitizeHost(host) if (!PROXY_HOST_PATTERN.test(sanitizedHost)) { return NextResponse.json({ error: "Invalid host format" }, { status: 400 }) } @@ -118,14 +111,15 @@ export async function POST(request: Request) { const portErr = validatePort(resolvedPort) if (portErr) return portErr - if ( - typeof pollIntervalSeconds === "number" && - (pollIntervalSeconds < 60 || pollIntervalSeconds > 86400) - ) { - return NextResponse.json( - { error: "Poll interval must be between 60 and 86400 seconds" }, - { status: 400 } + if (typeof pollIntervalSeconds === "number") { + const pollErr = validateIntRange( + pollIntervalSeconds, + CLIENT_POLL_INTERVAL_MIN, + CLIENT_POLL_INTERVAL_MAX, + "pollIntervalSeconds", + `Poll interval must be between ${CLIENT_POLL_INTERVAL_MIN} and ${CLIENT_POLL_INTERVAL_MAX} seconds` ) + if (pollErr) return pollErr } const key = decodeKey(auth) @@ -135,7 +129,7 @@ export async function POST(request: Request) { const resolvedIsDefault = typeof isDefault === "boolean" ? isDefault : false const resolvedTags = Array.isArray(crossSeedTags) ? crossSeedTags : [] - if (resolvedTags.length > 50) { + if (resolvedTags.length > CROSS_SEED_TAGS_MAX) { return NextResponse.json( { error: "Cannot specify more than 50 cross-seed tags" }, { status: 400 } @@ -144,7 +138,9 @@ export async function POST(request: Request) { if ( resolvedTags.length > 0 && - !resolvedTags.every((t: unknown) => typeof t === "string" && t.length > 0 && t.length <= 100) + !resolvedTags.every( + (t: unknown) => typeof t === "string" && t.length > 0 && t.length <= CROSS_SEED_TAG_MAX + ) ) { return NextResponse.json( { error: "Each cross-seed tag must be a non-empty string of 100 characters or fewer" }, @@ -152,27 +148,38 @@ export async function POST(request: Request) { ) } - if (resolvedIsDefault) { - await db.update(downloadClients).set({ isDefault: false }) - } - - const [client] = await db - .insert(downloadClients) - .values({ - name: name.trim(), - host: sanitizedHost, - type: resolvedType, - port: resolvedPort, - useSsl: typeof useSsl === "boolean" ? useSsl : false, - encryptedUsername, - encryptedPassword, - pollIntervalSeconds: typeof pollIntervalSeconds === "number" ? pollIntervalSeconds : 300, - isDefault: resolvedIsDefault, - crossSeedTags: resolvedTags, + try { + const [client] = await db.transaction(async (tx) => { + if (resolvedIsDefault) { + await tx.update(downloadClients).set({ isDefault: false }) + } + return tx + .insert(downloadClients) + .values({ + name: name.trim(), + host: sanitizedHost, + type: resolvedType, + port: resolvedPort, + useSsl: typeof useSsl === "boolean" ? useSsl : false, + encryptedUsername, + encryptedPassword, + pollIntervalSeconds: + typeof pollIntervalSeconds === "number" + ? pollIntervalSeconds + : CLIENT_POLL_INTERVAL_DEFAULT, + isDefault: resolvedIsDefault, + crossSeedTags: resolvedTags, + }) + .returning() }) - .returning() - // SECURITY: Only return safe fields - log.info({ route: "POST /api/clients", clientId: client.id }, "download client created") - return NextResponse.json({ id: client.id, name: client.name }, { status: 201 }) + log.info({ route: "POST /api/clients", clientId: client.id }, "download client created") + return NextResponse.json({ id: client.id, name: client.name }, { status: 201 }) + } catch (err) { + log.error( + { route: "POST /api/clients", error: errMsg(err) }, + "Failed to create download client" + ) + return NextResponse.json({ error: "Failed to create download client" }, { status: 500 }) + } } diff --git a/src/app/api/fleet/snapshots/route.ts b/src/app/api/fleet/snapshots/route.ts index 12f58223..0058e1c4 100644 --- a/src/app/api/fleet/snapshots/route.ts +++ b/src/app/api/fleet/snapshots/route.ts @@ -5,11 +5,15 @@ // Returns historical client snapshots with parsed tagStats for all clients. // Query param: ?days=N (default 7, max 365) -import { gte } from "drizzle-orm" +import { desc, gte, sql } from "drizzle-orm" import { NextResponse } from "next/server" import { authenticate } from "@/lib/api-helpers" import { db } from "@/lib/db" import { clientSnapshots, downloadClients } from "@/lib/db/schema" +import { errMsg } from "@/lib/error-utils" +import { FLEET_SNAPSHOT_QUERY_MAX } from "@/lib/limits" +import { log } from "@/lib/logger" +import { getSnapshotBucket } from "@/lib/server-data" export async function GET(request: Request) { const auth = await authenticate() @@ -17,39 +21,76 @@ export async function GET(request: Request) { const url = new URL(request.url) const daysParam = parseInt(url.searchParams.get("days") ?? "7", 10) - const days = Math.min(Math.max(1, Number.isNaN(daysParam) ? 7 : daysParam), 365) + const days = Math.min( + Math.max(1, Number.isNaN(daysParam) ? 7 : daysParam), + FLEET_SNAPSHOT_QUERY_MAX + ) const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000) - const clients = await db - .select({ id: downloadClients.id, name: downloadClients.name }) - .from(downloadClients) - - const clientNameMap = new Map(clients.map((c) => [c.id, c.name])) - - const snapshots = await db - .select() - .from(clientSnapshots) - .where(gte(clientSnapshots.polledAt, cutoff)) - - const serialized = snapshots.map((s) => ({ - clientId: s.clientId, - clientName: clientNameMap.get(s.clientId) ?? `Client ${s.clientId}`, - polledAt: s.polledAt.toISOString(), - totalSeedingCount: s.totalSeedingCount, - totalLeechingCount: s.totalLeechingCount, - uploadSpeedBytes: s.uploadSpeedBytes?.toString() ?? null, - downloadSpeedBytes: s.downloadSpeedBytes?.toString() ?? null, - tagStats: s.tagStats + try { + const bucket = getSnapshotBucket(days) + + const clientSnapshotColumns = { + clientId: clientSnapshots.clientId, + polledAt: clientSnapshots.polledAt, + totalSeedingCount: clientSnapshots.totalSeedingCount, + totalLeechingCount: clientSnapshots.totalLeechingCount, + uploadSpeedBytes: clientSnapshots.uploadSpeedBytes, + downloadSpeedBytes: clientSnapshots.downloadSpeedBytes, + tagStats: clientSnapshots.tagStats, + } + + const snapshotQuery = bucket ? (() => { - try { - return JSON.parse(s.tagStats) as unknown - } catch { - return null - } + const bucketExpr = sql`date_trunc(${sql.raw(`'${bucket}'`)}, ${clientSnapshots.polledAt})` + return db + .selectDistinctOn([clientSnapshots.clientId, bucketExpr], clientSnapshotColumns) + .from(clientSnapshots) + .where(gte(clientSnapshots.polledAt, cutoff)) + .orderBy(clientSnapshots.clientId, bucketExpr, desc(clientSnapshots.polledAt)) })() - : null, - })) + : db + .select(clientSnapshotColumns) + .from(clientSnapshots) + .where(gte(clientSnapshots.polledAt, cutoff)) + + const [clients, snapshots] = await Promise.all([ + db.select({ id: downloadClients.id, name: downloadClients.name }).from(downloadClients), + snapshotQuery, + ]) + + const clientNameMap = new Map(clients.map((c) => [c.id, c.name])) + + const serialized = snapshots.map((s) => ({ + clientId: s.clientId, + clientName: clientNameMap.get(s.clientId) ?? `Client ${s.clientId}`, + polledAt: s.polledAt.toISOString(), + totalSeedingCount: s.totalSeedingCount, + totalLeechingCount: s.totalLeechingCount, + uploadSpeedBytes: s.uploadSpeedBytes?.toString() ?? null, + downloadSpeedBytes: s.downloadSpeedBytes?.toString() ?? null, + tagStats: s.tagStats + ? (() => { + try { + return JSON.parse(s.tagStats) as unknown + } catch { + log.warn( + { clientId: s.clientId, polledAt: s.polledAt.toISOString() }, + "Corrupt tagStats JSON in client snapshot" + ) + return null + } + })() + : null, + })) - return NextResponse.json(serialized) + return NextResponse.json(serialized) + } catch (err) { + log.error( + { route: "GET /api/fleet/snapshots", error: errMsg(err) }, + "Failed to fetch fleet snapshots" + ) + return NextResponse.json({ error: "Failed to load fleet snapshots" }, { status: 500 }) + } } diff --git a/src/app/api/fleet/torrents/cached/route.ts b/src/app/api/fleet/torrents/cached/route.ts index 74e1e1ca..a759fd7e 100644 --- a/src/app/api/fleet/torrents/cached/route.ts +++ b/src/app/api/fleet/torrents/cached/route.ts @@ -1,83 +1,24 @@ // src/app/api/fleet/torrents/cached/route.ts // // Functions: GET -// -// Returns cached torrent data from the last deep poll across all clients. -// Reads from downloadClients.cachedTorrents (populated by client-scheduler). -// No live qBT HTTP requests — DB read only. -import { eq } from "drizzle-orm" import { NextResponse } from "next/server" import { authenticate } from "@/lib/api-helpers" -import { db } from "@/lib/db" -import { downloadClients } from "@/lib/db/schema" -import { parseCrossSeedTags, type QbtTorrent } from "@/lib/qbt" -import { aggregateCrossSeedTags, mergeTorrentLists } from "@/lib/qbt/merge" +import { fetchFleetAggregation } from "@/lib/download-clients" +import { log } from "@/lib/logger" export async function GET() { const auth = await authenticate() if (auth instanceof NextResponse) return auth - const clients = await db - .select({ - id: downloadClients.id, - name: downloadClients.name, - cachedTorrents: downloadClients.cachedTorrents, - cachedTorrentsAt: downloadClients.cachedTorrentsAt, - crossSeedTags: downloadClients.crossSeedTags, - }) - .from(downloadClients) - .where(eq(downloadClients.enabled, true)) - - if (clients.length === 0) { - return NextResponse.json({ - torrents: [], - crossSeedTags: [], - clientErrors: [], - clientCount: 0, - cachedAt: null, - }) - } - - const torrentLists: QbtTorrent[][] = [] - const crossSeedClients: { crossSeedTags: string[] }[] = [] - const hashClients = new Map() - let oldestCacheAt: Date | null = null - - for (const client of clients) { - if (!client.cachedTorrents || !Array.isArray(client.cachedTorrents)) continue - - const torrents = client.cachedTorrents as QbtTorrent[] - torrentLists.push(torrents) - - for (const t of torrents) { - const names = hashClients.get(t.hash) ?? [] - names.push(client.name) - hashClients.set(t.hash, names) - } - - crossSeedClients.push({ crossSeedTags: parseCrossSeedTags(client.crossSeedTags) }) - - if (client.cachedTorrentsAt) { - if (!oldestCacheAt || client.cachedTorrentsAt < oldestCacheAt) { - oldestCacheAt = client.cachedTorrentsAt - } - } + try { + const result = await fetchFleetAggregation() + return NextResponse.json(result) + } catch (error) { + log.error( + error instanceof Error ? error : { err: String(error) }, + "GET /api/fleet/torrents/cached failed" + ) + return NextResponse.json({ error: "Failed to load fleet data" }, { status: 500 }) } - - const merged = mergeTorrentLists(torrentLists) - const crossSeedTags = aggregateCrossSeedTags(crossSeedClients) - - const stamped = merged.map((t) => ({ - ...t, - client_name: (hashClients.get(t.hash) ?? []).join(", "), - })) - - return NextResponse.json({ - torrents: stamped, - crossSeedTags, - clientErrors: [], - clientCount: clients.length, - cachedAt: oldestCacheAt?.toISOString() ?? null, - }) } diff --git a/src/app/api/fleet/torrents/route.ts b/src/app/api/fleet/torrents/route.ts index 68d0eb8b..ba71d3d3 100644 --- a/src/app/api/fleet/torrents/route.ts +++ b/src/app/api/fleet/torrents/route.ts @@ -1,54 +1,31 @@ // src/app/api/fleet/torrents/route.ts // // Functions: GET -// -// Returns merged torrent list across all download clients and all tracker tags. -// Uses Promise.allSettled for resilience — one failed client doesn't block others. -import { eq } from "drizzle-orm" import { NextResponse } from "next/server" import { authenticate, decodeKey } from "@/lib/api-helpers" -import { db } from "@/lib/db" -import { downloadClients, trackers } from "@/lib/db/schema" +import { fetchFleetTorrents } from "@/lib/download-clients" import { log } from "@/lib/logger" -import { fetchAndMergeTorrents } from "@/lib/qbt/fetch-merged" export async function GET() { const auth = await authenticate() if (auth instanceof NextResponse) return auth const key = decodeKey(auth) - const [allTrackers, clients] = await Promise.all([ - db.select({ qbtTag: trackers.qbtTag }).from(trackers).where(eq(trackers.isActive, true)), - db - .select({ - name: downloadClients.name, - host: downloadClients.host, - port: downloadClients.port, - useSsl: downloadClients.useSsl, - encryptedUsername: downloadClients.encryptedUsername, - encryptedPassword: downloadClients.encryptedPassword, - crossSeedTags: downloadClients.crossSeedTags, - }) - .from(downloadClients) - .where(eq(downloadClients.enabled, true)), - ]) - - const tags = [ - ...new Set( - allTrackers - .map((t) => t.qbtTag) - .filter((t): t is string => t !== null && t.trim() !== "") - .map((t) => t.trim()) - ), - ] + try { + const result = await fetchFleetTorrents(key) - const result = await fetchAndMergeTorrents(clients, tags, key) + if (result.sessionExpired) { + log.warn({ route: "GET /api/fleet/torrents" }, "fleet fetch failed — stale session key") + return NextResponse.json({ error: "Session expired. Please log in again." }, { status: 401 }) + } - if (result.sessionExpired) { - log.warn({ route: "GET /api/fleet/torrents" }, "fleet fetch failed — stale session key") - return NextResponse.json({ error: "Session expired — please log in again" }, { status: 401 }) + return NextResponse.json(result) + } catch (error) { + log.error( + { route: "GET /api/fleet/torrents", error: String(error) }, + "fleet torrent fetch failed" + ) + return NextResponse.json({ error: "Failed to fetch fleet torrents" }, { status: 500 }) } - - return NextResponse.json(result) } diff --git a/src/app/api/notifications/[id]/route.ts b/src/app/api/notifications/[id]/route.ts index 4e53f153..24056ef9 100644 --- a/src/app/api/notifications/[id]/route.ts +++ b/src/app/api/notifications/[id]/route.ts @@ -4,14 +4,23 @@ import { eq } from "drizzle-orm" import { NextResponse } from "next/server" -import { authenticate, decodeKey, parseJsonBody, parseRouteId } from "@/lib/api-helpers" +import { + authenticate, + decodeKey, + parseJsonBody, + parseRouteId, + type RouteContext, + validateMaxLength, +} from "@/lib/api-helpers" import { encrypt } from "@/lib/crypto" import { db } from "@/lib/db" import { notificationTargets } from "@/lib/db/schema" +import { errMsg } from "@/lib/error-utils" +import { SHORT_NAME_MAX } from "@/lib/limits" import { log } from "@/lib/logger" import { validateNotificationConfig } from "@/lib/notifications/validate" -export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { +export async function PATCH(req: Request, { params }: RouteContext) { const auth = await authenticate() if (auth instanceof NextResponse) return auth @@ -45,9 +54,8 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st if (typeof fields.name !== "string" || !fields.name.trim()) { return NextResponse.json({ error: "name must be a non-empty string" }, { status: 400 }) } - if (fields.name.length > 100) { - return NextResponse.json({ error: "name must be ≤100 characters" }, { status: 400 }) - } + const nameErr = validateMaxLength(fields.name, SHORT_NAME_MAX, "name") + if (nameErr) return nameErr updates.name = fields.name.trim() } @@ -190,22 +198,37 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st updates.scope = fields.scope } - await db.update(notificationTargets).set(updates).where(eq(notificationTargets.id, id)) - - return NextResponse.json({ success: true }) + try { + await db.update(notificationTargets).set(updates).where(eq(notificationTargets.id, id)) + return NextResponse.json({ success: true }) + } catch (err) { + log.error( + { route: "PATCH /api/notifications/[id]", targetId: id, error: errMsg(err) }, + "Failed to update notification target" + ) + return NextResponse.json({ error: "Failed to update notification target" }, { status: 500 }) + } } -export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { +export async function DELETE(_req: Request, { params }: RouteContext) { const auth = await authenticate() if (auth instanceof NextResponse) return auth const id = await parseRouteId(params, "notification target ID") if (id instanceof NextResponse) return id - // notificationDeliveryState rows are cleaned up automatically via FK cascade - // (onDelete: "cascade" on targetId) - await db.delete(notificationTargets).where(eq(notificationTargets.id, id)) - - log.info({ route: "DELETE /api/notifications/[id]", targetId: id }, "notification target deleted") - return NextResponse.json({ success: true }) + try { + await db.delete(notificationTargets).where(eq(notificationTargets.id, id)) + log.info( + { route: "DELETE /api/notifications/[id]", targetId: id }, + "notification target deleted" + ) + return NextResponse.json({ success: true }) + } catch (err) { + log.error( + { route: "DELETE /api/notifications/[id]", targetId: id, error: errMsg(err) }, + "Failed to delete notification target" + ) + return NextResponse.json({ error: "Failed to delete notification target" }, { status: 500 }) + } } diff --git a/src/app/api/notifications/[id]/test/route.ts b/src/app/api/notifications/[id]/test/route.ts index 75437358..c65c8e0a 100644 --- a/src/app/api/notifications/[id]/test/route.ts +++ b/src/app/api/notifications/[id]/test/route.ts @@ -4,15 +4,15 @@ import { eq } from "drizzle-orm" import { NextResponse } from "next/server" -import { authenticate, decodeKey, parseRouteId } from "@/lib/api-helpers" +import { authenticate, decodeKey, parseRouteId, type RouteContext } from "@/lib/api-helpers" import { db } from "@/lib/db" import { notificationTargets } from "@/lib/db/schema" import { log } from "@/lib/logger" import { decryptNotificationConfig } from "@/lib/notifications/decrypt" import { deliverDiscordWebhook } from "@/lib/notifications/deliver" -import type { DiscordConfig } from "@/lib/notifications/types" +import { type DiscordConfig, isDiscordConfig } from "@/lib/notifications/types" -export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) { +export async function POST(_req: Request, { params }: RouteContext) { const auth = await authenticate() if (auth instanceof NextResponse) return auth @@ -33,7 +33,11 @@ export async function POST(_req: Request, { params }: { params: Promise<{ id: st let config: DiscordConfig try { - config = decryptNotificationConfig(target, key) as DiscordConfig + const raw = decryptNotificationConfig(target, key) + if (!isDiscordConfig(raw)) { + return NextResponse.json({ error: "Invalid notification config shape" }, { status: 422 }) + } + config = raw } catch { log.error( { route: "POST /api/notifications/[id]/test", targetId: id }, diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts index 2f46fc9a..ce51d0be 100644 --- a/src/app/api/notifications/route.ts +++ b/src/app/api/notifications/route.ts @@ -3,51 +3,23 @@ // Functions: GET, POST import { NextResponse } from "next/server" -import { authenticate, decodeKey, parseJsonBody } from "@/lib/api-helpers" +import { authenticate, decodeKey, parseJsonBody, validateMaxLength } from "@/lib/api-helpers" import { encrypt } from "@/lib/crypto" import { db } from "@/lib/db" import { notificationTargets } from "@/lib/db/schema" +import { errMsg } from "@/lib/error-utils" +import { SHORT_NAME_MAX } from "@/lib/limits" import { log } from "@/lib/logger" -import { VALID_NOTIFICATION_TYPES } from "@/lib/notifications/types" +import { SUPPORTED_NOTIFICATION_TYPES } from "@/lib/notifications/types" import { validateNotificationConfig } from "@/lib/notifications/validate" +import { fetchNotificationTargets, serializeNotificationTarget } from "@/lib/server-data" export async function GET() { const auth = await authenticate() if (auth instanceof NextResponse) return auth - const targets = await db.select().from(notificationTargets) - - const safe = targets.map((t) => ({ - id: t.id, - name: t.name, - type: t.type, - enabled: t.enabled, - hasConfig: !!t.encryptedConfig, - notifyRatioDrop: t.notifyRatioDrop, - notifyHitAndRun: t.notifyHitAndRun, - notifyTrackerDown: t.notifyTrackerDown, - notifyBufferMilestone: t.notifyBufferMilestone, - notifyWarned: t.notifyWarned, - notifyRatioDanger: t.notifyRatioDanger, - notifyZeroSeeding: t.notifyZeroSeeding, - notifyRankChange: t.notifyRankChange, - notifyAnniversary: t.notifyAnniversary, - notifyBonusCap: t.notifyBonusCap, - notifyVipExpiring: t.notifyVipExpiring, - notifyUnsatisfiedLimit: t.notifyUnsatisfiedLimit, - notifyActiveHnrs: t.notifyActiveHnrs, - thresholds: t.thresholds, - includeTrackerName: t.includeTrackerName, - scope: t.scope, - lastDeliveryStatus: t.lastDeliveryStatus, - lastDeliveryAt: t.lastDeliveryAt?.toISOString() ?? null, - lastDeliveryError: t.lastDeliveryError, - createdAt: t.createdAt.toISOString(), - updatedAt: t.updatedAt.toISOString(), - // SECURITY: encryptedConfig is NEVER included - })) - - return NextResponse.json(safe) + const targets = await fetchNotificationTargets() + return NextResponse.json(targets.map(serializeNotificationTarget)) } export async function POST(req: Request) { @@ -61,15 +33,15 @@ export async function POST(req: Request) { if (typeof name !== "string" || !name.trim()) return NextResponse.json({ error: "name is required" }, { status: 400 }) - if (name.length > 100) - return NextResponse.json({ error: "name must be ≤100 characters" }, { status: 400 }) + const nameErr = validateMaxLength(name, SHORT_NAME_MAX, "name") + if (nameErr) return nameErr if ( typeof type !== "string" || - !VALID_NOTIFICATION_TYPES.includes(type as (typeof VALID_NOTIFICATION_TYPES)[number]) + !SUPPORTED_NOTIFICATION_TYPES.includes(type as (typeof SUPPORTED_NOTIFICATION_TYPES)[number]) ) return NextResponse.json( - { error: `type must be one of: ${VALID_NOTIFICATION_TYPES.join(", ")}` }, + { error: `type must be one of: ${SUPPORTED_NOTIFICATION_TYPES.join(", ")}` }, { status: 400 } ) @@ -77,26 +49,34 @@ export async function POST(req: Request) { return NextResponse.json({ error: "config object is required" }, { status: 400 }) const validationError = validateNotificationConfig( - type as (typeof VALID_NOTIFICATION_TYPES)[number], + type as (typeof SUPPORTED_NOTIFICATION_TYPES)[number], config as Record ) if (validationError) return NextResponse.json({ error: validationError }, { status: 400 }) - const key = decodeKey(auth) - const encryptedConfig = encrypt(JSON.stringify(config), key) + try { + const key = decodeKey(auth) + const encryptedConfig = encrypt(JSON.stringify(config), key) - const [inserted] = await db - .insert(notificationTargets) - .values({ - name: name.trim(), - type, - encryptedConfig, - }) - .returning({ id: notificationTargets.id, name: notificationTargets.name }) + const [inserted] = await db + .insert(notificationTargets) + .values({ + name: name.trim(), + type, + encryptedConfig, + }) + .returning({ id: notificationTargets.id, name: notificationTargets.name }) - log.info( - { route: "POST /api/notifications", targetId: inserted.id }, - "notification target created" - ) - return NextResponse.json(inserted, { status: 201 }) + log.info( + { route: "POST /api/notifications", targetId: inserted.id }, + "notification target created" + ) + return NextResponse.json(inserted, { status: 201 }) + } catch (err) { + log.error( + { route: "POST /api/notifications", error: errMsg(err) }, + "Failed to create notification target" + ) + return NextResponse.json({ error: "Failed to create notification target" }, { status: 500 }) + } } diff --git a/src/app/api/settings/backup/[id]/route.ts b/src/app/api/settings/backup/[id]/route.ts index 12db6c61..ed4ff97a 100644 --- a/src/app/api/settings/backup/[id]/route.ts +++ b/src/app/api/settings/backup/[id]/route.ts @@ -6,14 +6,14 @@ import { readFile, stat, unlink } from "node:fs/promises" import path from "node:path" import { eq } from "drizzle-orm" import { NextResponse } from "next/server" -import { authenticate, parseRouteId } from "@/lib/api-helpers" +import { authenticate, parseRouteId, type RouteContext } from "@/lib/api-helpers" import { db } from "@/lib/db" import { appSettings, backupHistory } from "@/lib/db/schema" import { log } from "@/lib/logger" export async function GET( _request: Request, - props: { params: Promise<{ id: string }> } + props: RouteContext ): Promise { const auth = await authenticate() if (auth instanceof NextResponse) return auth @@ -75,10 +75,7 @@ export async function GET( }) } -export async function DELETE( - _request: Request, - props: { params: Promise<{ id: string }> } -): Promise { +export async function DELETE(_request: Request, props: RouteContext): Promise { const auth = await authenticate() if (auth instanceof NextResponse) return auth diff --git a/src/app/api/settings/backup/export/route.ts b/src/app/api/settings/backup/export/route.ts index 4b49505c..af4199ba 100644 --- a/src/app/api/settings/backup/export/route.ts +++ b/src/app/api/settings/backup/export/route.ts @@ -4,10 +4,10 @@ import { mkdir, writeFile } from "node:fs/promises" import nodePath from "node:path" import { NextResponse } from "next/server" import { authenticate, decodeKey } from "@/lib/api-helpers" -import { encryptBackupPayload, generateBackupPayload } from "@/lib/backup" -import { decrypt } from "@/lib/crypto" +import { encryptBackupPayload, generateBackupPayload, resolveBackupPassword } from "@/lib/backup" import { db } from "@/lib/db" import { appSettings, backupHistory } from "@/lib/db/schema" +import { BACKUP_PASSWORD_MAX } from "@/lib/limits" import { log } from "@/lib/logger" export async function POST(request: Request) { @@ -26,7 +26,7 @@ export async function POST(request: Request) { // Resolve backup password: explicit form value > stored encrypted password let backupPassword: string | null = null if (formPassword && typeof formPassword === "string" && formPassword.length > 0) { - if (formPassword.length > 128) { + if (formPassword.length > BACKUP_PASSWORD_MAX) { return NextResponse.json( { error: "Backup password must be 128 characters or fewer" }, { status: 400 } @@ -35,10 +35,19 @@ export async function POST(request: Request) { backupPassword = formPassword } else if (settings?.backupEncryptionEnabled && settings.encryptedBackupPassword) { try { - const key = decodeKey(auth) - backupPassword = decrypt(settings.encryptedBackupPassword, key) + backupPassword = resolveBackupPassword( + true, + settings.encryptedBackupPassword, + decodeKey(auth) + ) } catch { log.error("Failed to decrypt stored backup password for manual export") + return NextResponse.json( + { + error: "Failed to decrypt backup password. Re-enter your backup password in settings.", + }, + { status: 500 } + ) } } @@ -79,7 +88,7 @@ export async function POST(request: Request) { sizeBytes, encrypted, frequency: null, - status: "completed", + status: filePath ? "completed" : "disk_write_failed", storagePath: filePath, }) diff --git a/src/app/api/settings/backup/history/route.ts b/src/app/api/settings/backup/history/route.ts index 3e42b1bf..dc8ee5d7 100644 --- a/src/app/api/settings/backup/history/route.ts +++ b/src/app/api/settings/backup/history/route.ts @@ -1,6 +1,6 @@ // src/app/api/settings/backup/history/route.ts -import { desc } from "drizzle-orm" +import { desc, isNotNull } from "drizzle-orm" import { NextResponse } from "next/server" import { authenticate } from "@/lib/api-helpers" import { db } from "@/lib/db" @@ -10,7 +10,24 @@ export async function GET() { const auth = await authenticate() if (auth instanceof NextResponse) return auth - const records = await db.select().from(backupHistory).orderBy(desc(backupHistory.createdAt)) + const records = await db + .select({ + id: backupHistory.id, + createdAt: backupHistory.createdAt, + sizeBytes: backupHistory.sizeBytes, + encrypted: backupHistory.encrypted, + frequency: backupHistory.frequency, + status: backupHistory.status, + notes: backupHistory.notes, + hasStoredFile: isNotNull(backupHistory.storagePath), + }) + .from(backupHistory) + .orderBy(desc(backupHistory.createdAt)) + .limit(200) - return NextResponse.json(records) + const serialized = records.map((r) => ({ + ...r, + createdAt: r.createdAt.toISOString(), + })) + return NextResponse.json(serialized) } diff --git a/src/app/api/settings/backup/restore/route.ts b/src/app/api/settings/backup/restore/route.ts index 262cbc1e..a0675187 100644 --- a/src/app/api/settings/backup/restore/route.ts +++ b/src/app/api/settings/backup/restore/route.ts @@ -29,9 +29,19 @@ import { trackerSnapshots, trackers, } from "@/lib/db/schema" +import { errMsg } from "@/lib/error-utils" +import { + BACKUP_PASSWORD_MAX, + BACKUP_RESTORE_MAX_BYTES, + LOCKOUT_DURATION_DEFAULT, + LOCKOUT_THRESHOLD_DEFAULT, + PASSWORD_MAX, + PASSWORD_MIN, + POLL_INTERVAL_DEFAULT, +} from "@/lib/limits" import { checkLockout, recordFailedAttempt, resetFailedAttempts } from "@/lib/lockout" import { log } from "@/lib/logger" -import { stopScheduler } from "@/lib/scheduler" +import { ensureSchedulerRunning, stopScheduler } from "@/lib/scheduler" const BATCH_SIZE = 500 @@ -56,11 +66,20 @@ async function batchInsert>( // Attempt to decrypt a field with the backup key and re-encrypt with the current key. // Returns the re-encrypted ciphertext, or "" if the field is empty or re-encryption fails. -function reencryptField(ciphertext: string, backupKey: Buffer, currentKey: Buffer): string { +function reencryptField( + ciphertext: string, + backupKey: Buffer, + currentKey: Buffer, + context: string +): string { if (!ciphertext) return "" try { return reencrypt(ciphertext, backupKey, currentKey) - } catch { + } catch (err) { + log.warn( + { error: errMsg(err), field: context }, + "Failed to re-encrypt field during restore, value will be cleared" + ) return "" } } @@ -86,8 +105,7 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Backup file is required" }, { status: 400 }) } - const MAX_BACKUP_SIZE = 50 * 1024 * 1024 // 50 MB - if (file.size > MAX_BACKUP_SIZE) { + if (file.size > BACKUP_RESTORE_MAX_BYTES) { return NextResponse.json( { error: "Backup file exceeds maximum size of 50 MB" }, { status: 400 } @@ -98,8 +116,8 @@ export async function POST(request: Request) { if ( !masterPassword || typeof masterPassword !== "string" || - masterPassword.length === 0 || - masterPassword.length > 128 + masterPassword.length < PASSWORD_MIN || + masterPassword.length > PASSWORD_MAX ) { return NextResponse.json( { error: "Master password is required to restore backups" }, @@ -134,7 +152,7 @@ export async function POST(request: Request) { { status: 400 } ) } - if (backupPassword.length > 128) { + if (backupPassword.length > BACKUP_PASSWORD_MAX) { return NextResponse.json( { error: "Backup password must be 128 characters or fewer" }, { status: 400 } @@ -215,9 +233,11 @@ export async function POST(request: Request) { backupKey = await deriveKey(masterPassword, backupSalt) currentKey = sameSalt ? backupKey : await deriveKey(masterPassword, currentSalt) } catch (err) { - log.warn({ err }, "Backup restore: key derivation failed, encrypted fields will be cleared") - backupKey = Buffer.alloc(0) - currentKey = Buffer.alloc(0) + log.error({ error: errMsg(err) }, "Backup restore aborted: encryption key derivation failed") + return NextResponse.json( + { error: "Failed to derive encryption keys. Restore cannot proceed safely." }, + { status: 500 } + ) } const canReencrypt = backupKey.length === 32 @@ -231,6 +251,7 @@ export async function POST(request: Request) { let tokensCleared = 0 let clientCredentialsCleared = 0 let totpDisabledOnRestore = false + let orphanedRecordsSkipped = 0 try { await db.transaction(async (tx) => { @@ -257,7 +278,12 @@ export async function POST(request: Request) { // Same instance — keep ciphertext as-is apiToken = (fields.encryptedApiToken as string) || "" } else if (canReencrypt) { - apiToken = reencryptField(fields.encryptedApiToken as string, backupKey, currentKey) + apiToken = reencryptField( + fields.encryptedApiToken as string, + backupKey, + currentKey, + `tracker '${fields.name}' apiToken` + ) } else { apiToken = "" } @@ -306,8 +332,18 @@ export async function POST(request: Request) { encUsername = (fields.encryptedUsername as string) || "" encPassword = (fields.encryptedPassword as string) || "" } else if (canReencrypt) { - encUsername = reencryptField(fields.encryptedUsername as string, backupKey, currentKey) - encPassword = reencryptField(fields.encryptedPassword as string, backupKey, currentKey) + encUsername = reencryptField( + fields.encryptedUsername as string, + backupKey, + currentKey, + `downloadClient '${fields.name}' username` + ) + encPassword = reencryptField( + fields.encryptedPassword as string, + backupKey, + currentKey, + `downloadClient '${fields.name}' password` + ) } else { encUsername = "" encPassword = "" @@ -333,6 +369,10 @@ export async function POST(request: Request) { try { return JSON.parse(fields.crossSeedTags as string) as string[] } catch { + log.warn( + { client: fields.name }, + "Malformed crossSeedTags in backup, defaulting to empty" + ) return [] } })(), @@ -372,7 +412,10 @@ export async function POST(request: Request) { for (const s of payload.trackerSnapshots) { const fields = s as Record const newTrackerId = trackerIdMap.get(fields.trackerId as number) - if (!newTrackerId) continue + if (!newTrackerId) { + orphanedRecordsSkipped++ + continue + } snapshotRows.push({ trackerId: newTrackerId, polledAt: new Date(fields.polledAt as string), @@ -398,7 +441,10 @@ export async function POST(request: Request) { for (const r of payload.trackerRoles) { const fields = r as Record const newTrackerId = trackerIdMap.get(fields.trackerId as number) - if (!newTrackerId) continue + if (!newTrackerId) { + orphanedRecordsSkipped++ + continue + } await tx.insert(trackerRoles).values({ trackerId: newTrackerId, roleName: fields.roleName as string, @@ -411,7 +457,10 @@ export async function POST(request: Request) { for (const m of payload.tagGroupMembers) { const fields = m as Record const newGroupId = tagGroupIdMap.get(fields.groupId as number) - if (!newGroupId) continue + if (!newGroupId) { + orphanedRecordsSkipped++ + continue + } await tx.insert(tagGroupMembers).values({ groupId: newGroupId, tag: fields.tag as string, @@ -426,7 +475,10 @@ export async function POST(request: Request) { for (const cs of payload.clientSnapshots) { const fields = cs as Record const newClientId = clientIdMap.get(fields.clientId as number) - if (!newClientId) continue + if (!newClientId) { + orphanedRecordsSkipped++ + continue + } clientSnapshotRows.push({ clientId: newClientId, polledAt: new Date(fields.polledAt as string), @@ -449,7 +501,10 @@ export async function POST(request: Request) { for (const ub of payload.clientUptimeBuckets) { const fields = ub as Record const newClientId = clientIdMap.get(fields.clientId as number) - if (!newClientId) continue + if (!newClientId) { + orphanedRecordsSkipped++ + continue + } uptimeRows.push({ clientId: newClientId, bucketTs: new Date(fields.bucketTs as string), @@ -497,7 +552,8 @@ export async function POST(request: Request) { encryptedConfig = reencryptField( fields.encryptedConfig as string, backupKey, - currentKey + currentKey, + `notificationTarget '${fields.name}' config` ) } else { encryptedConfig = "" @@ -539,7 +595,8 @@ export async function POST(request: Request) { const result = reencryptField( payload.settings.encryptedProxyPassword as string, backupKey, - currentKey + currentKey, + "settings proxyPassword" ) proxyPassword = result || null } @@ -554,7 +611,8 @@ export async function POST(request: Request) { const result = reencryptField( payload.settings.encryptedBackupPassword as string, backupKey, - currentKey + currentKey, + "settings backupPassword" ) backupPasswordEncrypted = result || null } @@ -570,7 +628,8 @@ export async function POST(request: Request) { reencryptField( payload.settings.encryptedPtpimgApiKey as string, backupKey, - currentKey + currentKey, + "settings ptpimgApiKey" ) || null } } @@ -584,7 +643,8 @@ export async function POST(request: Request) { reencryptField( payload.settings.encryptedOeimgApiKey as string, backupKey, - currentKey + currentKey, + "settings oeimgApiKey" ) || null } } @@ -598,7 +658,8 @@ export async function POST(request: Request) { reencryptField( payload.settings.encryptedImgbbApiKey as string, backupKey, - currentKey + currentKey, + "settings imgbbApiKey" ) || null } } @@ -614,13 +675,18 @@ export async function POST(request: Request) { const reencryptedSecret = reencryptField( payload.settings.totpSecret as string, backupKey, - currentKey + currentKey, + "settings totpSecret" ) if (reencryptedSecret) { totpSecret = reencryptedSecret totpBackupCodes = payload.settings.totpBackupCodes - ? reencryptField(payload.settings.totpBackupCodes as string, backupKey, currentKey) || - null + ? reencryptField( + payload.settings.totpBackupCodes as string, + backupKey, + currentKey, + "settings totpBackupCodes" + ) || null : null } else { totpDisabledOnRestore = true @@ -630,6 +696,11 @@ export async function POST(request: Request) { } } + // Detect TOTP being silently wiped: live instance has TOTP but backup predates it + if (currentSettings.totpSecret && !totpSecret) { + totpDisabledOnRestore = true + } + // Update appSettings in place — NEVER delete + re-insert await tx .update(appSettings) @@ -640,12 +711,15 @@ export async function POST(request: Request) { totpBackupCodes, sessionTimeoutMinutes: (payload.settings.sessionTimeoutMinutes as number | null) ?? null, lockoutEnabled: (payload.settings.lockoutEnabled as boolean) ?? true, - lockoutThreshold: (payload.settings.lockoutThreshold as number) ?? 5, - lockoutDurationMinutes: (payload.settings.lockoutDurationMinutes as number) ?? 15, + lockoutThreshold: + (payload.settings.lockoutThreshold as number) ?? LOCKOUT_THRESHOLD_DEFAULT, + lockoutDurationMinutes: + (payload.settings.lockoutDurationMinutes as number) ?? LOCKOUT_DURATION_DEFAULT, failedLoginAttempts: 0, lockedUntil: null, snapshotRetentionDays: (payload.settings.snapshotRetentionDays as number | null) ?? null, - trackerPollIntervalMinutes: (payload.settings.trackerPollIntervalMinutes as number) ?? 60, + trackerPollIntervalMinutes: + (payload.settings.trackerPollIntervalMinutes as number) ?? POLL_INTERVAL_DEFAULT, proxyEnabled: payload.settings.proxyEnabled as boolean, proxyType: payload.settings.proxyType as string, proxyHost: (payload.settings.proxyHost as string | null) ?? null, @@ -675,13 +749,16 @@ export async function POST(request: Request) { log.error( { event: "restore_failed", - error: err instanceof Error ? err.message : String(err), + error: errMsg(err), fileNameHash: hashFileName(fileName), }, "Restore operation failed" ) - return NextResponse.json({ error: "Backup restore failed" }, { status: 409 }) + // Transaction rolled back, DB unchanged. Restart the scheduler we stopped. + ensureSchedulerRunning(auth.encryptionKey) + + return NextResponse.json({ error: "Backup restore failed" }, { status: 500 }) } finally { if (backupKey.length > 0) backupKey.fill(0) if (currentKey !== backupKey && currentKey.length > 0) currentKey.fill(0) @@ -702,6 +779,7 @@ export async function POST(request: Request) { tokensCleared, clientCredentialsCleared, totpDisabledOnRestore, + orphanedRecordsSkipped, restored: { trackers: payload.trackers.length, trackerSnapshots: payload.trackerSnapshots.length, @@ -741,6 +819,7 @@ export async function POST(request: Request) { tokensCleared, clientCredentialsCleared, totpDisabledOnRestore, + orphanedRecordsSkipped, requiresRelogin: false, }) } diff --git a/src/app/api/settings/dashboard/route.ts b/src/app/api/settings/dashboard/route.ts index 5a08f2ab..11320e66 100644 --- a/src/app/api/settings/dashboard/route.ts +++ b/src/app/api/settings/dashboard/route.ts @@ -1,12 +1,11 @@ // src/app/api/settings/dashboard/route.ts -// -// Functions: GET, PUT import { eq } from "drizzle-orm" import { NextResponse } from "next/server" import { authenticate, parseJsonBody } from "@/lib/api-helpers" import { db } from "@/lib/db" import { appSettings } from "@/lib/db/schema" +import { log } from "@/lib/logger" import { DASHBOARD_SETTINGS_DEFAULTS, type DashboardSettings } from "@/types/api" const DEFAULTS = DASHBOARD_SETTINGS_DEFAULTS @@ -15,7 +14,8 @@ function parseSettings(raw: string | null): DashboardSettings { if (!raw) return { ...DEFAULTS } try { return { ...DEFAULTS, ...(JSON.parse(raw) as Partial) } - } catch { + } catch (err) { + log.warn({ error: String(err) }, "Corrupt dashboardSettings JSON in DB, returning defaults") return { ...DEFAULTS } } } @@ -39,17 +39,23 @@ export async function PUT(request: Request) { const body = await parseJsonBody(request) if (body instanceof NextResponse) return body - const merged: DashboardSettings = { ...DEFAULTS } + const [row] = await db + .select({ id: appSettings.id, dashboardSettings: appSettings.dashboardSettings }) + .from(appSettings) + .limit(1) + if (!row) { + return NextResponse.json({ error: "Not configured" }, { status: 400 }) + } + + const merged: DashboardSettings = parseSettings(row.dashboardSettings) if (typeof body.showHealthIndicators === "boolean") { merged.showHealthIndicators = body.showHealthIndicators } if (typeof body.showLoginTimers === "boolean") { merged.showLoginTimers = body.showLoginTimers } - - const [row] = await db.select({ id: appSettings.id }).from(appSettings).limit(1) - if (!row) { - return NextResponse.json({ error: "Not configured" }, { status: 400 }) + if (typeof body.showTodayAtAGlance === "boolean") { + merged.showTodayAtAGlance = body.showTodayAtAGlance } await db diff --git a/src/app/api/settings/db-size/route.ts b/src/app/api/settings/db-size/route.ts new file mode 100644 index 00000000..80c4a265 --- /dev/null +++ b/src/app/api/settings/db-size/route.ts @@ -0,0 +1,23 @@ +// src/app/api/settings/db-size/route.ts + +import { NextResponse } from "next/server" +import { authenticate } from "@/lib/api-helpers" +import { errMsg } from "@/lib/error-utils" +import { log } from "@/lib/logger" +import { getDbSizeHistory } from "@/lib/server-data" + +export async function GET() { + const auth = await authenticate() + if (auth instanceof NextResponse) return auth + + try { + const data = await getDbSizeHistory() + return NextResponse.json(data) + } catch (err) { + log.error( + { route: "GET /api/settings/db-size", error: errMsg(err) }, + "Failed to fetch DB size history" + ) + return NextResponse.json({ error: "Failed to load database size history" }, { status: 500 }) + } +} diff --git a/src/app/api/settings/events/route.ts b/src/app/api/settings/events/route.ts index 07192656..8f88cae9 100644 --- a/src/app/api/settings/events/route.ts +++ b/src/app/api/settings/events/route.ts @@ -11,15 +11,17 @@ import { backupToEvent, EVENT_CATEGORIES, type EventCategory, + groupPollBatches, mergeAndSort, parseLogLine, type SystemEvent, snapshotToEvent, } from "@/lib/events" +import { EVENTS_LIMIT_CAP, EVENTS_LIMIT_DEFAULT } from "@/lib/limits" import { readLogTail } from "@/lib/log-reader" import { log } from "@/lib/logger" -const MAX_LOG_BYTES = 256 * 1024 // 256 KB tail read +const MAX_LOG_BYTES = 256 * 1024 // 256 KB export async function GET(request: Request): Promise { const auth = await authenticate() @@ -29,7 +31,9 @@ export async function GET(request: Request): Promise { // Parse and validate limit const rawLimit = parseInt(searchParams.get("limit") ?? "", 10) - const limit = Number.isNaN(rawLimit) ? 50 : Math.min(Math.max(rawLimit, 1), 200) + const limit = Number.isNaN(rawLimit) + ? EVENTS_LIMIT_DEFAULT + : Math.min(Math.max(rawLimit, 1), EVENTS_LIMIT_CAP) // Parse and validate offset const rawOffset = parseInt(searchParams.get("offset") ?? "", 10) @@ -73,7 +77,7 @@ export async function GET(request: Request): Promise { .limit(100), ]) - // Convert DB rows to SystemEvent — dates → ISO strings, bigints → strings + // Convert DB rows to SystemEvent (dates to ISO strings, bigints to strings) const dbEvents = [ ...snapshotRows.map((row) => snapshotToEvent({ @@ -98,7 +102,7 @@ export async function GET(request: Request): Promise { ), ] - // Positional tail-read of log file — never loads the full file + // tail-read of log file let logEvents: SystemEvent[] = [] let logSizeBytes = 0 @@ -115,13 +119,17 @@ export async function GET(request: Request): Promise { ) { log.error({ route: "GET /api/settings/events" }, "failed to read log file for events") } - // ENOENT is non-fatal — proceed with DB events only } - // Single pass — sort+filter once, then slice for pagination - const allMerged = mergeAndSort(dbEvents, logEvents, category, Number.MAX_SAFE_INTEGER, 0) - const total = allMerged.length - const events = allMerged.slice(offset, offset + limit) + // Count total + const total = + category === "all" + ? dbEvents.length + logEvents.length + : dbEvents.reduce((count, e) => (e.category === category ? count + 1 : count), 0) + + logEvents.reduce((count, e) => (e.category === category ? count + 1 : count), 0) + + // Sort, paginate, then collapse same-timestamp polls into batches + const events = groupPollBatches(mergeAndSort(dbEvents, logEvents, category, limit, offset)) return NextResponse.json({ events, diff --git a/src/app/api/settings/image-hosts/route.ts b/src/app/api/settings/image-hosts/route.ts index 279e2b1b..72a78e35 100644 --- a/src/app/api/settings/image-hosts/route.ts +++ b/src/app/api/settings/image-hosts/route.ts @@ -4,29 +4,21 @@ import { NextResponse } from "next/server" import { authenticate } from "@/lib/api-helpers" -import { db } from "@/lib/db" -import { appSettings } from "@/lib/db/schema" +import { fetchSettings } from "@/lib/server-data" export async function GET() { const auth = await authenticate() if (auth instanceof NextResponse) return auth - const [settings] = await db - .select({ - encryptedPtpimgApiKey: appSettings.encryptedPtpimgApiKey, - encryptedOeimgApiKey: appSettings.encryptedOeimgApiKey, - encryptedImgbbApiKey: appSettings.encryptedImgbbApiKey, - }) - .from(appSettings) - .limit(1) + const [settings] = await fetchSettings() if (!settings) { return NextResponse.json({ error: "Not configured" }, { status: 400 }) } return NextResponse.json({ - ptpimg: !!settings.encryptedPtpimgApiKey, - onlyimage: !!settings.encryptedOeimgApiKey, - imgbb: !!settings.encryptedImgbbApiKey, + ptpimg: !!settings.hasPtpimgKey, + onlyimage: !!settings.hasOeimgKey, + imgbb: !!settings.hasImgbbKey, }) } diff --git a/src/app/api/settings/lockdown/route.ts b/src/app/api/settings/lockdown/route.ts index e10e7dbf..210fbd06 100644 --- a/src/app/api/settings/lockdown/route.ts +++ b/src/app/api/settings/lockdown/route.ts @@ -11,7 +11,9 @@ import { authenticate, parseJsonBody } from "@/lib/api-helpers" import { clearSession, verifyPassword } from "@/lib/auth" import { generateSalt } from "@/lib/crypto" import { db } from "@/lib/db" -import { appSettings, trackers } from "@/lib/db/schema" +import { appSettings, downloadClients, trackers } from "@/lib/db/schema" +import { errMsg } from "@/lib/error-utils" +import { PASSWORD_MAX } from "@/lib/limits" import { log } from "@/lib/logger" import { stopScheduler } from "@/lib/scheduler" import { clearSchedulerKey } from "@/lib/scheduler-key-store" @@ -24,7 +26,7 @@ export async function POST(request: Request) { if (body instanceof NextResponse) return body const { password } = body as { password?: string } - if (!password || typeof password !== "string" || password.length > 128) { + if (!password || typeof password !== "string" || password.length > PASSWORD_MAX) { return NextResponse.json({ error: "Master password is required" }, { status: 400 }) } @@ -44,29 +46,54 @@ export async function POST(request: Request) { stopScheduler() await clearSchedulerKey(settings.id) - // 2. Nullify all tracker API tokens — they're now useless - await db.update(trackers).set({ - encryptedApiToken: "LOCKDOWN_REVOKED", - isActive: false, - lastError: "Emergency lockdown — API token revoked", - updatedAt: new Date(), - }) - - // 3. Rotate encryption salt — orphans any remaining ciphertext const newSalt = generateSalt() + const now = new Date() + + try { + await db.transaction(async (tx) => { + // 2. Revoke all tracker API tokens + await tx.update(trackers).set({ + encryptedApiToken: "LOCKDOWN_REVOKED", + isActive: false, + lastError: "Emergency lockdown: API token revoked", + updatedAt: now, + }) - // 4. Wipe all encrypted fields (encrypted with old key, now unrecoverable anyway) - await db - .update(appSettings) - .set({ - encryptionSalt: newSalt, - totpSecret: null, - totpBackupCodes: null, - encryptedProxyPassword: null, - encryptedBackupPassword: null, - username: null, + // 3. Revoke all download client credentials + await tx.update(downloadClients).set({ + encryptedUsername: "", + encryptedPassword: "", + enabled: false, + lastError: "Emergency lockdown: credentials revoked", + updatedAt: now, + }) + + // 4. Rotate salt + wipe all encrypted settings fields + await tx + .update(appSettings) + .set({ + encryptionSalt: newSalt, + totpSecret: null, + totpBackupCodes: null, + encryptedProxyPassword: null, + encryptedBackupPassword: null, + encryptedPtpimgApiKey: null, + encryptedOeimgApiKey: null, + encryptedImgbbApiKey: null, + username: null, + }) + .where(eq(appSettings.id, settings.id)) }) - .where(eq(appSettings.id, settings.id)) + } catch (err) { + log.error( + { route: "POST /api/settings/lockdown", error: errMsg(err) }, + "Lockdown DB operations failed" + ) + return NextResponse.json( + { error: "Emergency lockdown failed. Retry immediately or shut down the server." }, + { status: 500 } + ) + } // 5. Kill the session await clearSession() diff --git a/src/app/api/settings/logs/download/route.ts b/src/app/api/settings/logs/download/route.ts index 8e592357..705151c1 100644 --- a/src/app/api/settings/logs/download/route.ts +++ b/src/app/api/settings/logs/download/route.ts @@ -1,20 +1,21 @@ // src/app/api/settings/logs/download/route.ts -// -// Functions: GET import { createReadStream, existsSync } from "node:fs" +import { dirname } from "node:path" import { Readable } from "node:stream" import { NextResponse } from "next/server" import { authenticate } from "@/lib/api-helpers" -import { DEFAULT_LOG_FILE } from "@/lib/constants" +import { DEFAULT_LOG_FILE, DEV_LOG_FILE } from "@/lib/constants" +import { localDateStr } from "@/lib/formatters" import { log } from "@/lib/logger" export async function GET(): Promise { const auth = await authenticate() if (auth instanceof NextResponse) return auth - // Path comes from server env only — no user input, no traversal risk - const logFile = process.env.LOG_FILE || DEFAULT_LOG_FILE + const logFile = + process.env.LOG_FILE ?? + (existsSync(dirname(DEFAULT_LOG_FILE)) ? DEFAULT_LOG_FILE : DEV_LOG_FILE) if (!existsSync(logFile)) { return NextResponse.json({ error: "Log file not found" }, { status: 404 }) @@ -23,7 +24,7 @@ export async function GET(): Promise { try { const stream = createReadStream(logFile) const webStream = Readable.toWeb(stream) as ReadableStream - const today = new Date().toISOString().split("T")[0] + const today = localDateStr() return new Response(webStream, { headers: { diff --git a/src/app/api/settings/logs/route.ts b/src/app/api/settings/logs/route.ts index b22e5fa4..dfd011a4 100644 --- a/src/app/api/settings/logs/route.ts +++ b/src/app/api/settings/logs/route.ts @@ -2,10 +2,12 @@ // // Functions: GET, DELETE +import { existsSync } from "node:fs" import { writeFile } from "node:fs/promises" +import { dirname } from "node:path" import { NextResponse } from "next/server" import { authenticate } from "@/lib/api-helpers" -import { DEFAULT_LOG_FILE } from "@/lib/constants" +import { DEFAULT_LOG_FILE, DEV_LOG_FILE } from "@/lib/constants" import { readLogTail } from "@/lib/log-reader" import { log } from "@/lib/logger" @@ -31,7 +33,9 @@ export async function DELETE(_request: Request): Promise { const auth = await authenticate() if (auth instanceof NextResponse) return auth - const logFile = process.env.LOG_FILE || DEFAULT_LOG_FILE + const logFile = + process.env.LOG_FILE ?? + (existsSync(dirname(DEFAULT_LOG_FILE)) ? DEFAULT_LOG_FILE : DEV_LOG_FILE) try { await writeFile(logFile, "", "utf8") log.info({ route: "DELETE /api/settings/logs" }, "log file cleared by user") diff --git a/src/app/api/settings/nuke/route.ts b/src/app/api/settings/nuke/route.ts index 4446a08a..377b6f83 100644 --- a/src/app/api/settings/nuke/route.ts +++ b/src/app/api/settings/nuke/route.ts @@ -7,8 +7,11 @@ import { authenticate, parseJsonBody } from "@/lib/api-helpers" import { clearSession, verifyPassword } from "@/lib/auth" import { db } from "@/lib/db" import { appSettings } from "@/lib/db/schema" +import { errMsg } from "@/lib/error-utils" +import { PASSWORD_MAX } from "@/lib/limits" import { log } from "@/lib/logger" import { scrubAndDeleteAll } from "@/lib/nuke" +import { ensureSchedulerRunning } from "@/lib/scheduler" export async function POST(request: Request) { const auth = await authenticate() @@ -18,7 +21,7 @@ export async function POST(request: Request) { if (body instanceof NextResponse) return body const { password } = body as { password?: string } - if (!password || typeof password !== "string" || password.length > 128) { + if (!password || typeof password !== "string" || password.length > PASSWORD_MAX) { return NextResponse.json({ error: "Master password is required" }, { status: 400 }) } @@ -34,8 +37,18 @@ export async function POST(request: Request) { } log.info({ route: "POST /api/settings/nuke" }, "data scrub initiated") - await scrubAndDeleteAll() - await clearSession() + try { + await scrubAndDeleteAll() + } catch (err) { + log.error({ route: "POST /api/settings/nuke", error: errMsg(err) }, "Data scrub failed") + ensureSchedulerRunning(auth.encryptionKey) + return NextResponse.json( + { error: "Data scrub failed. Your data is unchanged." }, + { status: 500 } + ) + } + + await clearSession() return NextResponse.json({ success: true }) } diff --git a/src/app/api/settings/proxy-test/route.ts b/src/app/api/settings/proxy-test/route.ts index 78f2a02f..8e7b68df 100644 --- a/src/app/api/settings/proxy-test/route.ts +++ b/src/app/api/settings/proxy-test/route.ts @@ -3,10 +3,17 @@ // Functions: POST import { NextResponse } from "next/server" -import { authenticate, decodeKey, parseJsonBody, validatePort } from "@/lib/api-helpers" +import { + authenticate, + decodeKey, + parseJsonBody, + validateMaxLength, + validatePort, +} from "@/lib/api-helpers" import { decrypt } from "@/lib/crypto" import { db } from "@/lib/db" import { appSettings } from "@/lib/db/schema" +import { CREDENTIAL_MAX, HOST_MAX } from "@/lib/limits" import { log } from "@/lib/logger" import { createProxyAgent, @@ -14,7 +21,7 @@ import { type ProxyType, proxyFetch, VALID_PROXY_TYPES, -} from "@/lib/proxy" +} from "@/lib/tunnel" const TEST_URL = "https://httpbin.org/ip" // Loose IP pattern — IPv4, IPv6, or comma-separated (httpbin returns this) @@ -41,12 +48,8 @@ export async function POST(request: Request) { return NextResponse.json({ error: "proxyHost is required" }, { status: 400 }) } - if (proxyHost.length > 255) { - return NextResponse.json( - { error: "Proxy host must be 255 characters or fewer" }, - { status: 400 } - ) - } + const proxyHostErr = validateMaxLength(proxyHost, HOST_MAX, "Proxy host") + if (proxyHostErr) return proxyHostErr if (!PROXY_HOST_PATTERN.test(proxyHost)) { return NextResponse.json({ error: "Invalid proxy host format" }, { status: 400 }) @@ -59,6 +62,15 @@ export async function POST(request: Request) { ) } + if (typeof proxyUsername === "string") { + const proxyUsernameErr = validateMaxLength(proxyUsername, CREDENTIAL_MAX, "Proxy username") + if (proxyUsernameErr) return proxyUsernameErr + } + if (typeof proxyPassword === "string") { + const proxyPasswordErr = validateMaxLength(proxyPassword, CREDENTIAL_MAX, "Proxy password") + if (proxyPasswordErr) return proxyPasswordErr + } + const port = typeof proxyPort === "number" ? proxyPort : 1080 const portErr = validatePort(port) if (portErr) return portErr diff --git a/src/app/api/settings/quicklinks/route.ts b/src/app/api/settings/quicklinks/route.ts index cd7a6e9c..7ae81d38 100644 --- a/src/app/api/settings/quicklinks/route.ts +++ b/src/app/api/settings/quicklinks/route.ts @@ -4,9 +4,11 @@ import { eq } from "drizzle-orm" import { NextResponse } from "next/server" -import { authenticate, parseJsonBody } from "@/lib/api-helpers" +import { authenticate, parseJsonBody, validateMaxLength } from "@/lib/api-helpers" import { db } from "@/lib/db" import { appSettings } from "@/lib/db/schema" +import { QUICKLINK_SLUG_MAX, QUICKLINK_SLUGS_MAX } from "@/lib/limits" +import { log } from "@/lib/logger" export async function GET() { const auth = await authenticate() @@ -28,7 +30,8 @@ export async function GET() { if (Array.isArray(parsed) && parsed.every((s) => typeof s === "string")) { slugs = parsed } - } catch { + } catch (err) { + log.warn({ error: String(err) }, "Corrupt draftQuicklinks JSON in DB, returning empty") slugs = [] } } @@ -49,12 +52,13 @@ export async function PUT(request: Request) { return NextResponse.json({ error: "slugs must be an array of strings" }, { status: 400 }) } - if (slugs.length > 100) { + if (slugs.length > QUICKLINK_SLUGS_MAX) { return NextResponse.json({ error: "Too many quicklinks (max 100)" }, { status: 400 }) } - if (slugs.some((s) => s.length > 200)) { - return NextResponse.json({ error: "Slug too long (max 200 characters)" }, { status: 400 }) + for (const s of slugs) { + const slugErr = validateMaxLength(s, QUICKLINK_SLUG_MAX, "Slug") + if (slugErr) return slugErr } const [settings] = await db.select({ id: appSettings.id }).from(appSettings).limit(1) diff --git a/src/app/api/settings/reset-stats/reset-stats.test.ts b/src/app/api/settings/reset-stats/reset-stats.test.ts index 828aa2b8..0d2b3450 100644 --- a/src/app/api/settings/reset-stats/reset-stats.test.ts +++ b/src/app/api/settings/reset-stats/reset-stats.test.ts @@ -21,6 +21,7 @@ vi.mock("@/lib/db", () => ({ select: vi.fn(), delete: vi.fn(), update: vi.fn(), + transaction: vi.fn(), }, })) @@ -83,18 +84,32 @@ describe("POST /api/settings/reset-stats", () => { mockSelectSettings({ id: 1, passwordHash: "hash" }) ;(verifyPassword as ReturnType).mockResolvedValue(true) - const mockWhere = vi.fn().mockResolvedValue(undefined) - const mockSet = vi.fn().mockReturnValue({ where: mockWhere }) - ;(db.delete as ReturnType).mockResolvedValue(undefined) - ;(db.update as ReturnType).mockReturnValue({ set: mockSet }) + // Mock the transaction to invoke the callback with a tx mock + const txDeleteCalls: unknown[] = [] + const txSetCalls: unknown[] = [] + const txMock = { + delete: vi.fn().mockImplementation((table) => { + txDeleteCalls.push(table) + return Promise.resolve(undefined) + }), + update: vi.fn().mockReturnValue({ + set: vi.fn().mockImplementation((data) => { + txSetCalls.push(data) + return Promise.resolve(undefined) + }), + }), + } + ;(db.transaction as ReturnType).mockImplementation( + async (fn: (tx: unknown) => Promise) => fn(txMock) + ) const res = await POST(makeRequest()) const data = await res.json() expect(res.status).toBe(200) expect(data.success).toBe(true) - expect(db.delete).toHaveBeenCalledTimes(2) - expect(db.update).toHaveBeenCalledTimes(1) - expect(mockSet).toHaveBeenCalledWith({ lastPolledAt: null, lastError: null }) + expect(txMock.delete).toHaveBeenCalledTimes(2) + expect(txMock.update).toHaveBeenCalledTimes(1) + expect(txSetCalls[0]).toEqual({ lastPolledAt: null, lastError: null }) }) }) diff --git a/src/app/api/settings/reset-stats/route.ts b/src/app/api/settings/reset-stats/route.ts index 69157cec..b3402723 100644 --- a/src/app/api/settings/reset-stats/route.ts +++ b/src/app/api/settings/reset-stats/route.ts @@ -7,6 +7,8 @@ import { authenticate, parseJsonBody } from "@/lib/api-helpers" import { verifyPassword } from "@/lib/auth" import { db } from "@/lib/db" import { appSettings, clientSnapshots, trackerSnapshots, trackers } from "@/lib/db/schema" +import { errMsg } from "@/lib/error-utils" +import { PASSWORD_MAX } from "@/lib/limits" import { log } from "@/lib/logger" export async function POST(request: Request) { @@ -17,7 +19,7 @@ export async function POST(request: Request) { if (body instanceof NextResponse) return body const { password } = body as { password?: string } - if (!password || typeof password !== "string" || password.length > 128) { + if (!password || typeof password !== "string" || password.length > PASSWORD_MAX) { return NextResponse.json({ error: "Master password is required" }, { status: 400 }) } @@ -36,14 +38,17 @@ export async function POST(request: Request) { } log.info({ route: "POST /api/settings/reset-stats" }, "stats reset initiated") - // Delete all tracker snapshots - await db.delete(trackerSnapshots) - // Delete all client snapshots - await db.delete(clientSnapshots) - - // Clear lastPolledAt and lastError on all trackers so they re-poll fresh - await db.update(trackers).set({ lastPolledAt: null, lastError: null }) + try { + await db.transaction(async (tx) => { + await tx.delete(trackerSnapshots) + await tx.delete(clientSnapshots) + await tx.update(trackers).set({ lastPolledAt: null, lastError: null }) + }) + } catch (err) { + log.error({ route: "POST /api/settings/reset-stats", error: errMsg(err) }, "stats reset failed") + return NextResponse.json({ error: "Stats reset failed" }, { status: 500 }) + } return NextResponse.json({ success: true }) } diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 8add5be8..09eb89bc 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -6,16 +6,47 @@ import { access, mkdir } from "node:fs/promises" import path from "node:path" import { eq } from "drizzle-orm" import { NextResponse } from "next/server" -import { authenticate, decodeKey, parseJsonBody } from "@/lib/api-helpers" +import { + authenticate, + decodeKey, + parseJsonBody, + validateIntRange, + validateMaxLength, +} from "@/lib/api-helpers" import { VALID_BACKUP_FREQUENCIES } from "@/lib/backup" import { encrypt } from "@/lib/crypto" import { db } from "@/lib/db" import { appSettings } from "@/lib/db/schema" +import { QBITMANAGE_KEYS } from "@/lib/download-clients/qbt/qbitmanage-defaults" +import { errMsg } from "@/lib/error-utils" +import { + BACKUP_PASSWORD_MAX, + BACKUP_RETENTION_MAX, + BACKUP_RETENTION_MIN, + CREDENTIAL_MAX, + HOST_MAX, + LOCKOUT_DURATION_MAX, + LOCKOUT_DURATION_MIN, + LOCKOUT_THRESHOLD_MAX, + LOCKOUT_THRESHOLD_MIN, + LONG_STRING_MAX, + POLL_INTERVAL_MAX, + POLL_INTERVAL_MIN, + PORT_MAX, + PORT_MIN, + SESSION_TIMEOUT_MAX, + SESSION_TIMEOUT_MIN, + SHORT_NAME_MAX, + SNAPSHOT_RETENTION_MAX, + SNAPSHOT_RETENTION_MIN, + USERNAME_MAX, + USERNAME_MIN, +} from "@/lib/limits" import { log } from "@/lib/logger" import { scrubSnapshotUsernames } from "@/lib/privacy-db" -import { PROXY_HOST_PATTERN, VALID_PROXY_TYPES } from "@/lib/proxy" -import { QBITMANAGE_KEYS } from "@/lib/qbitmanage-defaults" import { fetchSettings, serializeSettingsResponse } from "@/lib/server-data" +import { PROXY_HOST_PATTERN, VALID_PROXY_TYPES } from "@/lib/tunnel" +import { isValidPort } from "@/lib/validators" export async function GET() { const auth = await authenticate() @@ -49,9 +80,9 @@ export async function PATCH(request: Request) { if (body.username === null || body.username === "") { updates.username = null } else if (typeof body.username === "string") { - if (body.username.length < 6 || body.username.length > 100) { + if (body.username.length < USERNAME_MIN || body.username.length > USERNAME_MAX) { return NextResponse.json( - { error: "Username must be between 6 and 100 characters" }, + { error: `Username must be between ${USERNAME_MIN} and ${USERNAME_MAX} characters` }, { status: 400 } ) } @@ -69,9 +100,14 @@ export async function PATCH(request: Request) { typeof body.sessionTimeoutMinutes === "number" && Number.isInteger(body.sessionTimeoutMinutes) ) { - if (body.sessionTimeoutMinutes < 1 || body.sessionTimeoutMinutes > 525960) { + if ( + body.sessionTimeoutMinutes < SESSION_TIMEOUT_MIN || + body.sessionTimeoutMinutes > SESSION_TIMEOUT_MAX + ) { return NextResponse.json( - { error: "Session timeout must be between 1 minute and 1 year" }, + { + error: `Session timeout must be between ${SESSION_TIMEOUT_MIN} and ${SESSION_TIMEOUT_MAX} minutes`, + }, { status: 400 } ) } @@ -94,31 +130,32 @@ export async function PATCH(request: Request) { } if (body.lockoutThreshold !== undefined) { - if (typeof body.lockoutThreshold !== "number" || !Number.isInteger(body.lockoutThreshold)) { + if (typeof body.lockoutThreshold !== "number") { return NextResponse.json({ error: "Invalid lockout threshold" }, { status: 400 }) } - if (body.lockoutThreshold < 1 || body.lockoutThreshold > 99) { - return NextResponse.json( - { error: "Lockout threshold must be between 1 and 99" }, - { status: 400 } - ) - } + const thresholdErr = validateIntRange( + body.lockoutThreshold, + LOCKOUT_THRESHOLD_MIN, + LOCKOUT_THRESHOLD_MAX, + "lockoutThreshold", + `Lockout threshold must be between ${LOCKOUT_THRESHOLD_MIN} and ${LOCKOUT_THRESHOLD_MAX}` + ) + if (thresholdErr) return thresholdErr updates.lockoutThreshold = body.lockoutThreshold } if (body.lockoutDurationMinutes !== undefined) { - if ( - typeof body.lockoutDurationMinutes !== "number" || - !Number.isInteger(body.lockoutDurationMinutes) - ) { + if (typeof body.lockoutDurationMinutes !== "number") { return NextResponse.json({ error: "Invalid lockout duration" }, { status: 400 }) } - if (body.lockoutDurationMinutes < 1 || body.lockoutDurationMinutes > 1440) { - return NextResponse.json( - { error: "Lockout duration must be between 1 minute and 24 hours" }, - { status: 400 } - ) - } + const durationErr = validateIntRange( + body.lockoutDurationMinutes, + LOCKOUT_DURATION_MIN, + LOCKOUT_DURATION_MAX, + "lockoutDurationMinutes", + `Lockout duration must be between ${LOCKOUT_DURATION_MIN} and ${LOCKOUT_DURATION_MAX} minutes` + ) + if (durationErr) return durationErr updates.lockoutDurationMinutes = body.lockoutDurationMinutes } @@ -130,9 +167,14 @@ export async function PATCH(request: Request) { typeof body.snapshotRetentionDays === "number" && Number.isInteger(body.snapshotRetentionDays) ) { - if (body.snapshotRetentionDays < 7 || body.snapshotRetentionDays > 3650) { + if ( + body.snapshotRetentionDays < SNAPSHOT_RETENTION_MIN || + body.snapshotRetentionDays > SNAPSHOT_RETENTION_MAX + ) { return NextResponse.json( - { error: "Snapshot retention must be between 7 days and 10 years" }, + { + error: `Snapshot retention must be between ${SNAPSHOT_RETENTION_MIN} and ${SNAPSHOT_RETENTION_MAX} days`, + }, { status: 400 } ) } @@ -144,18 +186,17 @@ export async function PATCH(request: Request) { // --- Tracker poll interval --- if (body.trackerPollIntervalMinutes !== undefined) { - if ( - typeof body.trackerPollIntervalMinutes !== "number" || - !Number.isInteger(body.trackerPollIntervalMinutes) - ) { + if (typeof body.trackerPollIntervalMinutes !== "number") { return NextResponse.json({ error: "Invalid poll interval" }, { status: 400 }) } - if (body.trackerPollIntervalMinutes < 15 || body.trackerPollIntervalMinutes > 1440) { - return NextResponse.json( - { error: "Poll interval must be between 15 minutes and 24 hours" }, - { status: 400 } - ) - } + const pollErr = validateIntRange( + body.trackerPollIntervalMinutes, + POLL_INTERVAL_MIN, + POLL_INTERVAL_MAX, + "trackerPollIntervalMinutes", + `Poll interval must be between ${POLL_INTERVAL_MIN} and ${POLL_INTERVAL_MAX} minutes` + ) + if (pollErr) return pollErr updates.trackerPollIntervalMinutes = body.trackerPollIntervalMinutes } @@ -181,12 +222,8 @@ export async function PATCH(request: Request) { if (body.proxyHost === null || body.proxyHost === "") { updates.proxyHost = null } else if (typeof body.proxyHost === "string") { - if (body.proxyHost.length > 255) { - return NextResponse.json( - { error: "Proxy host must be 255 characters or fewer" }, - { status: 400 } - ) - } + const proxyHostErr = validateMaxLength(body.proxyHost, HOST_MAX, "Proxy host") + if (proxyHostErr) return proxyHostErr if (!PROXY_HOST_PATTERN.test(body.proxyHost)) { return NextResponse.json({ error: "Invalid proxy host format" }, { status: 400 }) } @@ -200,9 +237,9 @@ export async function PATCH(request: Request) { if (body.proxyPort === null) { updates.proxyPort = null } else if (typeof body.proxyPort === "number" && Number.isInteger(body.proxyPort)) { - if (body.proxyPort < 1 || body.proxyPort > 65535) { + if (!isValidPort(body.proxyPort)) { return NextResponse.json( - { error: "Proxy port must be between 1 and 65535" }, + { error: `Proxy port must be between ${PORT_MIN} and ${PORT_MAX}` }, { status: 400 } ) } @@ -216,12 +253,12 @@ export async function PATCH(request: Request) { if (body.proxyUsername === null || body.proxyUsername === "") { updates.proxyUsername = null } else if (typeof body.proxyUsername === "string") { - if (body.proxyUsername.length > 255) { - return NextResponse.json( - { error: "Proxy username must be 255 characters or fewer" }, - { status: 400 } - ) - } + const proxyUsernameErr = validateMaxLength( + body.proxyUsername, + CREDENTIAL_MAX, + "Proxy username" + ) + if (proxyUsernameErr) return proxyUsernameErr updates.proxyUsername = body.proxyUsername } else { return NextResponse.json({ error: "Invalid proxy username" }, { status: 400 }) @@ -232,12 +269,12 @@ export async function PATCH(request: Request) { if (body.proxyPassword === null || body.proxyPassword === "") { updates.encryptedProxyPassword = null } else if (typeof body.proxyPassword === "string") { - if (body.proxyPassword.length > 255) { - return NextResponse.json( - { error: "Proxy password must be 255 characters or fewer" }, - { status: 400 } - ) - } + const proxyPasswordErr = validateMaxLength( + body.proxyPassword, + CREDENTIAL_MAX, + "Proxy password" + ) + if (proxyPasswordErr) return proxyPasswordErr const key = decodeKey(auth) updates.encryptedProxyPassword = encrypt(body.proxyPassword, key) } else { @@ -307,7 +344,7 @@ export async function PATCH(request: Request) { { status: 400 } ) } - if (tag.length === 0 || tag.length > 100) { + if (tag.length === 0 || tag.length > SHORT_NAME_MAX) { return NextResponse.json( { error: `qbitmanageTags.${key}.tag must be between 1 and 100 characters` }, { status: 400 } @@ -343,18 +380,17 @@ export async function PATCH(request: Request) { } if (body.backupRetentionCount !== undefined) { - if ( - typeof body.backupRetentionCount !== "number" || - !Number.isInteger(body.backupRetentionCount) - ) { + if (typeof body.backupRetentionCount !== "number") { return NextResponse.json({ error: "Invalid backup retention count" }, { status: 400 }) } - if (body.backupRetentionCount < 1 || body.backupRetentionCount > 365) { - return NextResponse.json( - { error: "Backup retention count must be between 1 and 365" }, - { status: 400 } - ) - } + const retentionErr = validateIntRange( + body.backupRetentionCount, + BACKUP_RETENTION_MIN, + BACKUP_RETENTION_MAX, + "backupRetentionCount", + `Backup retention count must be between ${BACKUP_RETENTION_MIN} and ${BACKUP_RETENTION_MAX}` + ) + if (retentionErr) return retentionErr updates.backupRetentionCount = body.backupRetentionCount } @@ -376,12 +412,12 @@ export async function PATCH(request: Request) { if (body.backupPassword === null || body.backupPassword === "") { updates.encryptedBackupPassword = null } else if (typeof body.backupPassword === "string") { - if (body.backupPassword.length > 255) { - return NextResponse.json( - { error: "Backup password must be 255 characters or fewer" }, - { status: 400 } - ) - } + const backupPasswordErr = validateMaxLength( + body.backupPassword, + BACKUP_PASSWORD_MAX, + "Backup password" + ) + if (backupPasswordErr) return backupPasswordErr const key = decodeKey(auth) updates.encryptedBackupPassword = encrypt(body.backupPassword, key) } else { @@ -394,12 +430,12 @@ export async function PATCH(request: Request) { if (body.ptpimgApiKey === null || body.ptpimgApiKey === "") { updates.encryptedPtpimgApiKey = null } else if (typeof body.ptpimgApiKey === "string") { - if (body.ptpimgApiKey.length > 500) { - return NextResponse.json( - { error: "PTPimg API key must be 500 characters or fewer" }, - { status: 400 } - ) - } + const ptpimgApiKeyErr = validateMaxLength( + body.ptpimgApiKey, + LONG_STRING_MAX, + "PTPimg API key" + ) + if (ptpimgApiKeyErr) return ptpimgApiKeyErr const key = decodeKey(auth) updates.encryptedPtpimgApiKey = encrypt(body.ptpimgApiKey, key) } else { @@ -411,12 +447,12 @@ export async function PATCH(request: Request) { if (body.oeimgApiKey === null || body.oeimgApiKey === "") { updates.encryptedOeimgApiKey = null } else if (typeof body.oeimgApiKey === "string") { - if (body.oeimgApiKey.length > 500) { - return NextResponse.json( - { error: "OnlyImage API key must be 500 characters or fewer" }, - { status: 400 } - ) - } + const oeimgApiKeyErr = validateMaxLength( + body.oeimgApiKey, + LONG_STRING_MAX, + "OnlyImage API key" + ) + if (oeimgApiKeyErr) return oeimgApiKeyErr const key = decodeKey(auth) updates.encryptedOeimgApiKey = encrypt(body.oeimgApiKey, key) } else { @@ -428,12 +464,8 @@ export async function PATCH(request: Request) { if (body.imgbbApiKey === null || body.imgbbApiKey === "") { updates.encryptedImgbbApiKey = null } else if (typeof body.imgbbApiKey === "string") { - if (body.imgbbApiKey.length > 500) { - return NextResponse.json( - { error: "ImgBB API key must be 500 characters or fewer" }, - { status: 400 } - ) - } + const imgbbApiKeyErr = validateMaxLength(body.imgbbApiKey, LONG_STRING_MAX, "ImgBB API key") + if (imgbbApiKeyErr) return imgbbApiKeyErr const key = decodeKey(auth) updates.encryptedImgbbApiKey = encrypt(body.imgbbApiKey, key) } else { @@ -446,12 +478,12 @@ export async function PATCH(request: Request) { updates.backupStoragePath = null } else if (typeof body.backupStoragePath === "string") { const trimmedPath = body.backupStoragePath.trim() - if (trimmedPath.length > 500) { - return NextResponse.json( - { error: "Backup storage path must be 500 characters or fewer" }, - { status: 400 } - ) - } + const backupStoragePathErr = validateMaxLength( + trimmedPath, + LONG_STRING_MAX, + "Backup storage path" + ) + if (backupStoragePathErr) return backupStoragePathErr if (!path.isAbsolute(trimmedPath) || trimmedPath.includes("..")) { return NextResponse.json( { error: "Backup storage path must be an absolute path with no '..' segments" }, @@ -478,28 +510,33 @@ export async function PATCH(request: Request) { return NextResponse.json({ error: "No valid fields to update" }, { status: 400 }) } - await db.update(appSettings).set(updates).where(eq(appSettings.id, settings.id)) - log.info({ route: "PATCH /api/settings", fields: Object.keys(updates) }, "settings updated") + try { + await db.update(appSettings).set(updates).where(eq(appSettings.id, settings.id)) + log.info({ route: "PATCH /api/settings", fields: Object.keys(updates) }, "settings updated") - // Re-fetch to return current state - const [updated] = await fetchSettings() - if (!updated) { - log.error({ route: "PATCH /api/settings" }, "settings re-fetch returned empty after update") - throw new Error("Settings update failed") - } + // Re-fetch to return current state + const [updated] = await fetchSettings() + if (!updated) { + log.error({ route: "PATCH /api/settings" }, "settings re-fetch returned empty after update") + return NextResponse.json({ error: "Settings update failed" }, { status: 500 }) + } - // Restart backup scheduler if schedule settings changed - if ( - updates.backupScheduleEnabled !== undefined || - updates.backupScheduleFrequency !== undefined - ) { - const { stopBackupScheduler, startBackupScheduler } = await import("@/lib/backup-scheduler") - stopBackupScheduler() - if (updated.backupScheduleEnabled) { - const key = decodeKey(auth) - startBackupScheduler(key) + // Restart backup scheduler if schedule settings changed + if ( + updates.backupScheduleEnabled !== undefined || + updates.backupScheduleFrequency !== undefined + ) { + const { stopBackupScheduler, startBackupScheduler } = await import("@/lib/backup-scheduler") + stopBackupScheduler() + if (updated.backupScheduleEnabled) { + const key = decodeKey(auth) + startBackupScheduler(key) + } } - } - return NextResponse.json(serializeSettingsResponse(updated)) + return NextResponse.json(serializeSettingsResponse(updated)) + } catch (err) { + log.error({ route: "PATCH /api/settings", error: errMsg(err) }, "Failed to save settings") + return NextResponse.json({ error: "Failed to save settings" }, { status: 500 }) + } } diff --git a/src/app/api/settings/settings-routes.test.ts b/src/app/api/settings/settings-routes.test.ts index 4deedfc5..a985997e 100644 --- a/src/app/api/settings/settings-routes.test.ts +++ b/src/app/api/settings/settings-routes.test.ts @@ -54,6 +54,54 @@ vi.mock("@/lib/db/schema", () => ({ updatedAt: "updatedAt", }, trackerSnapshots: {}, + downloadClients: { + id: "id", + name: "name", + type: "type", + enabled: "enabled", + host: "host", + port: "port", + useSsl: "useSsl", + encryptedUsername: "encryptedUsername", + encryptedPassword: "encryptedPassword", + pollIntervalSeconds: "pollIntervalSeconds", + isDefault: "isDefault", + crossSeedTags: "crossSeedTags", + lastPolledAt: "lastPolledAt", + lastError: "lastError", + errorSince: "errorSince", + createdAt: "createdAt", + updatedAt: "updatedAt", + }, + notificationTargets: { + id: "id", + name: "name", + type: "type", + enabled: "enabled", + encryptedConfig: "encryptedConfig", + notifyRatioDrop: "notifyRatioDrop", + notifyHitAndRun: "notifyHitAndRun", + notifyTrackerDown: "notifyTrackerDown", + notifyBufferMilestone: "notifyBufferMilestone", + notifyWarned: "notifyWarned", + notifyRatioDanger: "notifyRatioDanger", + notifyZeroSeeding: "notifyZeroSeeding", + notifyRankChange: "notifyRankChange", + notifyAnniversary: "notifyAnniversary", + notifyBonusCap: "notifyBonusCap", + notifyVipExpiring: "notifyVipExpiring", + notifyUnsatisfiedLimit: "notifyUnsatisfiedLimit", + notifyActiveHnrs: "notifyActiveHnrs", + notifyDownloadDisabled: "notifyDownloadDisabled", + thresholds: "thresholds", + includeTrackerName: "includeTrackerName", + scope: "scope", + lastDeliveryStatus: "lastDeliveryStatus", + lastDeliveryAt: "lastDeliveryAt", + lastDeliveryError: "lastDeliveryError", + createdAt: "createdAt", + updatedAt: "updatedAt", + }, })) vi.mock("@/lib/backup", () => ({ @@ -73,12 +121,12 @@ vi.mock("@/lib/privacy-db", () => ({ scrubSnapshotUsernames: vi.fn().mockResolvedValue(0), })) -vi.mock("@/lib/proxy", () => ({ +vi.mock("@/lib/tunnel", () => ({ PROXY_HOST_PATTERN: /^[\w.\-:[\]]+$/, VALID_PROXY_TYPES: new Set(["socks5", "http", "https"]), })) -vi.mock("@/lib/qbitmanage-defaults", () => ({ +vi.mock("@/lib/download-clients/qbt/qbitmanage-defaults", () => ({ parseQbitmanageTags: vi.fn(() => ({})), QBITMANAGE_KEYS: [], })) @@ -127,7 +175,9 @@ describe("PATCH /api/settings route validation", () => { const response = await PATCH(new Request("http://localhost/api/settings", { method: "PATCH" })) expect(response.status).toBe(400) - await expect(response.json()).resolves.toEqual({ error: "Invalid poll interval" }) + await expect(response.json()).resolves.toEqual({ + error: "Poll interval must be between 15 and 1440 minutes", + }) }) it("rejects trackerPollIntervalMinutes below 15 minutes", async () => { @@ -139,7 +189,7 @@ describe("PATCH /api/settings route validation", () => { expect(response.status).toBe(400) await expect(response.json()).resolves.toEqual({ - error: "Poll interval must be between 15 minutes and 24 hours", + error: "Poll interval must be between 15 and 1440 minutes", }) }) @@ -152,10 +202,56 @@ describe("PATCH /api/settings route validation", () => { expect(response.status).toBe(400) await expect(response.json()).resolves.toEqual({ - error: "Poll interval must be between 15 minutes and 24 hours", + error: "Poll interval must be between 15 and 1440 minutes", }) }) + // ─── Username validation ────────────────────────────────────── + + it("rejects username shorter than 3 characters", async () => { + ;(parseJsonBody as ReturnType).mockResolvedValue({ username: "ab" }) + + const response = await PATCH(new Request("http://localhost/api/settings", { method: "PATCH" })) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + error: "Username must be between 3 and 100 characters", + }) + }) + + it("accepts username of exactly 3 characters", async () => { + const set = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) + ;(db.update as ReturnType).mockReturnValue({ set }) + ;(parseJsonBody as ReturnType).mockResolvedValue({ username: "joe" }) + + const response = await PATCH(new Request("http://localhost/api/settings", { method: "PATCH" })) + + expect(response.status).toBe(200) + }) + + it("rejects username longer than 100 characters", async () => { + ;(parseJsonBody as ReturnType).mockResolvedValue({ + username: "a".repeat(101), + }) + + const response = await PATCH(new Request("http://localhost/api/settings", { method: "PATCH" })) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + error: "Username must be between 3 and 100 characters", + }) + }) + + it("accepts null username to clear it", async () => { + const set = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) + ;(db.update as ReturnType).mockReturnValue({ set }) + ;(parseJsonBody as ReturnType).mockResolvedValue({ username: null }) + + const response = await PATCH(new Request("http://localhost/api/settings", { method: "PATCH" })) + + expect(response.status).toBe(200) + }) + it("returns 401 when unauthenticated", async () => { ;(authenticate as ReturnType).mockResolvedValue( NextResponse.json({ error: "Unauthorized" }, { status: 401 }) diff --git a/src/app/api/tag-groups/[id]/members/[memberId]/route.ts b/src/app/api/tag-groups/[id]/members/[memberId]/route.ts index 3c942bf0..d07a19c3 100644 --- a/src/app/api/tag-groups/[id]/members/[memberId]/route.ts +++ b/src/app/api/tag-groups/[id]/members/[memberId]/route.ts @@ -4,13 +4,14 @@ import { and, eq } from "drizzle-orm" import { NextResponse } from "next/server" -import { authenticate, parseJsonBody, validateHexColor } from "@/lib/api-helpers" +import { authenticate, parseJsonBody, type RouteContext, validateHexColor } from "@/lib/api-helpers" import { db } from "@/lib/db" import { tagGroupMembers } from "@/lib/db/schema" +import { SHORT_NAME_MAX, SORT_ORDER_MAX } from "@/lib/limits" import { log } from "@/lib/logger" async function parseGroupAndMemberId( - params: Promise<{ id: string; memberId: string }> + params: RouteContext<{ id: string; memberId: string }>["params"] ): Promise { const { id, memberId } = await params const groupId = parseInt(id, 10) @@ -24,7 +25,7 @@ async function parseGroupAndMemberId( export async function PATCH( request: Request, - props: { params: Promise<{ id: string; memberId: string }> } + props: RouteContext<{ id: string; memberId: string }> ) { const auth = await authenticate() if (auth instanceof NextResponse) return auth @@ -52,7 +53,7 @@ export async function PATCH( if (body.tag.trim().length === 0) { return NextResponse.json({ error: "Tag cannot be empty" }, { status: 400 }) } - if (body.tag.length > 100) { + if (body.tag.length > SHORT_NAME_MAX) { return NextResponse.json({ error: "Tag must be 100 characters or fewer" }, { status: 400 }) } if (body.tag.trim() !== existing.tag) { @@ -76,7 +77,7 @@ export async function PATCH( if (body.label.trim().length === 0) { return NextResponse.json({ error: "Label cannot be empty" }, { status: 400 }) } - if (body.label.length > 100) { + if (body.label.length > SHORT_NAME_MAX) { return NextResponse.json({ error: "Label must be 100 characters or fewer" }, { status: 400 }) } updates.label = body.label.trim() @@ -91,9 +92,9 @@ export async function PATCH( } if (typeof body.sortOrder === "number") { - if (!Number.isFinite(body.sortOrder) || body.sortOrder < 0 || body.sortOrder > 9999) { + if (!Number.isFinite(body.sortOrder) || body.sortOrder < 0 || body.sortOrder > SORT_ORDER_MAX) { return NextResponse.json( - { error: "sortOrder must be a finite integer between 0 and 9999" }, + { error: `sortOrder must be a finite integer between 0 and ${SORT_ORDER_MAX}` }, { status: 400 } ) } @@ -114,7 +115,7 @@ export async function PATCH( export async function DELETE( _request: Request, - props: { params: Promise<{ id: string; memberId: string }> } + props: RouteContext<{ id: string; memberId: string }> ) { const auth = await authenticate() if (auth instanceof NextResponse) return auth diff --git a/src/app/api/tag-groups/[id]/members/route.ts b/src/app/api/tag-groups/[id]/members/route.ts index 0f8a63d3..30b05c21 100644 --- a/src/app/api/tag-groups/[id]/members/route.ts +++ b/src/app/api/tag-groups/[id]/members/route.ts @@ -4,12 +4,19 @@ import { and, asc, eq } from "drizzle-orm" import { NextResponse } from "next/server" -import { authenticate, parseJsonBody, parseRouteId, validateHexColor } from "@/lib/api-helpers" +import { + authenticate, + parseJsonBody, + parseRouteId, + type RouteContext, + validateHexColor, +} from "@/lib/api-helpers" import { db } from "@/lib/db" import { tagGroupMembers, tagGroups } from "@/lib/db/schema" +import { SHORT_NAME_MAX } from "@/lib/limits" import { log } from "@/lib/logger" -export async function GET(_request: Request, props: { params: Promise<{ id: string }> }) { +export async function GET(_request: Request, props: RouteContext) { const auth = await authenticate() if (auth instanceof NextResponse) return auth @@ -35,7 +42,7 @@ export async function GET(_request: Request, props: { params: Promise<{ id: stri return NextResponse.json(members) } -export async function POST(request: Request, props: { params: Promise<{ id: string }> }) { +export async function POST(request: Request, props: RouteContext) { const auth = await authenticate() if (auth instanceof NextResponse) return auth @@ -60,11 +67,11 @@ export async function POST(request: Request, props: { params: Promise<{ id: stri return NextResponse.json({ error: "label is required" }, { status: 400 }) } - if (tag.length > 100) { + if (tag.length > SHORT_NAME_MAX) { return NextResponse.json({ error: "Tag must be 100 characters or fewer" }, { status: 400 }) } - if (label.length > 100) { + if (label.length > SHORT_NAME_MAX) { return NextResponse.json({ error: "Label must be 100 characters or fewer" }, { status: 400 }) } diff --git a/src/app/api/tag-groups/[id]/route.ts b/src/app/api/tag-groups/[id]/route.ts index e50d3b0e..85ac7b2a 100644 --- a/src/app/api/tag-groups/[id]/route.ts +++ b/src/app/api/tag-groups/[id]/route.ts @@ -2,15 +2,75 @@ // // Functions: PATCH, DELETE -import { eq } from "drizzle-orm" +import { and, eq } from "drizzle-orm" import { NextResponse } from "next/server" -import { authenticate, parseJsonBody, parseRouteId } from "@/lib/api-helpers" +import { authenticate, parseJsonBody, parseRouteId, type RouteContext } from "@/lib/api-helpers" import { db } from "@/lib/db" -import { tagGroups } from "@/lib/db/schema" +import { tagGroupMembers, tagGroups } from "@/lib/db/schema" +import { + BATCH_MEMBERS_MAX, + COLOR_STRING_MAX, + EMOJI_MAX, + LONG_STRING_MAX, + SHORT_NAME_MAX, + SORT_ORDER_MAX, +} from "@/lib/limits" import { log } from "@/lib/logger" import { VALID_CHART_TYPES } from "@/types/api" -export async function PATCH(request: Request, props: { params: Promise<{ id: string }> }) { +// ─── Batch member mutation types ────────────────────────────────────────────── + +interface MemberUpdate { + id: number + tag?: string + label?: string + color?: string | null + sortOrder?: number +} + +interface MemberCreate { + tag: string + label: string + color?: string | null + sortOrder?: number +} + +interface BatchMembers { + removes?: number[] + updates?: MemberUpdate[] + creates?: MemberCreate[] +} + +// ─── Validation helpers ─────────────────────────────────────────────────────── + +function validateMemberTag(tag: unknown): string | null { + if (typeof tag !== "string" || tag.trim().length === 0) return "tag is required" + if (tag.length > SHORT_NAME_MAX) return "Tag must be 100 characters or fewer" + return null +} + +function validateMemberLabel(label: unknown): string | null { + if (typeof label !== "string" || label.trim().length === 0) return "label is required" + if (label.length > SHORT_NAME_MAX) return "Label must be 100 characters or fewer" + return null +} + +function validateSortOrder(sortOrder: unknown): string | null { + if (sortOrder === undefined || sortOrder === null) return null + if ( + typeof sortOrder !== "number" || + !Number.isInteger(sortOrder) || + sortOrder < 0 || + sortOrder > SORT_ORDER_MAX + ) { + return `sortOrder must be an integer between 0 and ${SORT_ORDER_MAX}` + } + return null +} + +// ─── PATCH ──────────────────────────────────────────────────────────────────── + +export async function PATCH(request: Request, props: RouteContext) { const auth = await authenticate() if (auth instanceof NextResponse) return auth @@ -20,20 +80,22 @@ export async function PATCH(request: Request, props: { params: Promise<{ id: str const body = await parseJsonBody(request) if (body instanceof NextResponse) return body + // --- Build group metadata updates --- + const updates: Record = { updatedAt: new Date() } if (typeof body.name === "string") { if (body.name.trim().length === 0) { return NextResponse.json({ error: "Name cannot be empty" }, { status: 400 }) } - if (body.name.length > 100) { + if (body.name.length > SHORT_NAME_MAX) { return NextResponse.json({ error: "Name must be 100 characters or fewer" }, { status: 400 }) } updates.name = body.name.trim() } if (typeof body.description === "string") { - if (body.description.length > 500) { + if (body.description.length > LONG_STRING_MAX) { return NextResponse.json( { error: "Description must be 500 characters or fewer" }, { status: 400 } @@ -45,7 +107,7 @@ export async function PATCH(request: Request, props: { params: Promise<{ id: str } if (typeof body.emoji === "string") { - if (body.emoji.length > 10) { + if (body.emoji.length > EMOJI_MAX) { return NextResponse.json({ error: "Emoji must be 10 characters or fewer" }, { status: 400 }) } updates.emoji = body.emoji.trim() || null @@ -64,7 +126,7 @@ export async function PATCH(request: Request, props: { params: Promise<{ id: str } if (typeof body.sortOrder === "number") { - if (!Number.isFinite(body.sortOrder) || body.sortOrder < 0 || body.sortOrder > 9999) { + if (!Number.isFinite(body.sortOrder) || body.sortOrder < 0 || body.sortOrder > SORT_ORDER_MAX) { return NextResponse.json( { error: "sortOrder must be a finite integer between 0 and 9999" }, { status: 400 } @@ -75,6 +137,8 @@ export async function PATCH(request: Request, props: { params: Promise<{ id: str if (typeof body.countUnmatched === "boolean") updates.countUnmatched = body.countUnmatched + // --- Validate group exists --- + const [existing] = await db .select({ id: tagGroups.id }) .from(tagGroups) @@ -85,12 +149,156 @@ export async function PATCH(request: Request, props: { params: Promise<{ id: str return NextResponse.json({ error: "Tag group not found" }, { status: 404 }) } + // --- Batch member mutations (optional) --- + + const members = body.members as BatchMembers | undefined + + if ( + members !== undefined && + (typeof members !== "object" || members === null || Array.isArray(members)) + ) { + return NextResponse.json( + { error: "members must be an object with removes, updates, and creates" }, + { status: 400 } + ) + } + + if (members) { + // Pre-validate all member mutations before touching the DB + const removes = members.removes ?? [] + const memberUpdates = members.updates ?? [] + const creates = members.creates ?? [] + + if ( + !Array.isArray(removes) || + !removes.every((id) => typeof id === "number" && Number.isInteger(id)) + ) { + return NextResponse.json( + { error: "members.removes must be an array of integer IDs" }, + { status: 400 } + ) + } + if (removes.length > BATCH_MEMBERS_MAX) { + return NextResponse.json({ error: "Too many removes" }, { status: 400 }) + } + + if (!Array.isArray(memberUpdates)) { + return NextResponse.json({ error: "members.updates must be an array" }, { status: 400 }) + } + if (memberUpdates.length > BATCH_MEMBERS_MAX) { + return NextResponse.json({ error: "Too many updates" }, { status: 400 }) + } + for (const u of memberUpdates) { + if (typeof u.id !== "number" || !Number.isInteger(u.id)) { + return NextResponse.json({ error: "Each update must have an integer id" }, { status: 400 }) + } + if (u.tag !== undefined) { + const err = validateMemberTag(u.tag) + if (err) return NextResponse.json({ error: err }, { status: 400 }) + } + if (u.label !== undefined) { + const err = validateMemberLabel(u.label) + if (err) return NextResponse.json({ error: err }, { status: 400 }) + } + if ( + u.color !== undefined && + u.color !== null && + (typeof u.color !== "string" || u.color.length > COLOR_STRING_MAX) + ) { + return NextResponse.json( + { error: "color must be a string of 20 characters or fewer" }, + { status: 400 } + ) + } + const sortErr = validateSortOrder(u.sortOrder) + if (sortErr) return NextResponse.json({ error: sortErr }, { status: 400 }) + } + + if (!Array.isArray(creates)) { + return NextResponse.json({ error: "members.creates must be an array" }, { status: 400 }) + } + if (creates.length > BATCH_MEMBERS_MAX) { + return NextResponse.json({ error: "Too many creates" }, { status: 400 }) + } + for (const c of creates) { + const tagErr = validateMemberTag(c.tag) + if (tagErr) return NextResponse.json({ error: tagErr }, { status: 400 }) + const labelErr = validateMemberLabel(c.label) + if (labelErr) return NextResponse.json({ error: labelErr }, { status: 400 }) + if ( + c.color !== undefined && + c.color !== null && + (typeof c.color !== "string" || c.color.length > COLOR_STRING_MAX) + ) { + return NextResponse.json( + { error: "color must be a string of 20 characters or fewer" }, + { status: 400 } + ) + } + const sortErr = validateSortOrder(c.sortOrder) + if (sortErr) return NextResponse.json({ error: sortErr }, { status: 400 }) + } + + // All valid — execute atomically + await db.transaction(async (tx) => { + // 1. Update group metadata + await tx.update(tagGroups).set(updates).where(eq(tagGroups.id, groupId)) + + // 2. Deletes first (must precede creates — tag duplicate check depends on this) + for (const memberId of removes) { + await tx + .delete(tagGroupMembers) + .where(and(eq(tagGroupMembers.id, memberId), eq(tagGroupMembers.groupId, groupId))) + } + + // 3. Updates second + for (const u of memberUpdates) { + const fields: Record = {} + if (u.tag !== undefined) fields.tag = u.tag.trim() + if (u.label !== undefined) fields.label = u.label.trim() + if (u.color !== undefined) fields.color = typeof u.color === "string" ? u.color : null + if (u.sortOrder !== undefined) fields.sortOrder = Math.floor(u.sortOrder) + if (Object.keys(fields).length > 0) { + await tx + .update(tagGroupMembers) + .set(fields) + .where(and(eq(tagGroupMembers.id, u.id), eq(tagGroupMembers.groupId, groupId))) + } + } + + // 4. Creates last (after deletes to avoid false duplicate-tag conflicts) + for (const c of creates) { + await tx.insert(tagGroupMembers).values({ + groupId, + tag: c.tag.trim(), + label: c.label.trim(), + color: typeof c.color === "string" ? c.color : null, + sortOrder: typeof c.sortOrder === "number" ? Math.floor(c.sortOrder) : 0, + }) + } + }) + + log.info( + { + route: "PATCH /api/tag-groups/[id]", + groupId, + removes: removes.length, + updates: memberUpdates.length, + creates: creates.length, + }, + "tag group batch saved" + ) + return NextResponse.json({ success: true }) + } + + // --- Non-batch path (group metadata only, backward-compatible) --- + await db.update(tagGroups).set(updates).where(eq(tagGroups.id, groupId)) return NextResponse.json({ success: true }) } -export async function DELETE(_request: Request, props: { params: Promise<{ id: string }> }) { +export async function DELETE(_request: Request, props: RouteContext) { const auth = await authenticate() if (auth instanceof NextResponse) return auth diff --git a/src/app/api/tag-groups/route.ts b/src/app/api/tag-groups/route.ts index 89729f46..567e4953 100644 --- a/src/app/api/tag-groups/route.ts +++ b/src/app/api/tag-groups/route.ts @@ -3,9 +3,10 @@ // Functions: GET, POST import { NextResponse } from "next/server" -import { authenticate, parseJsonBody } from "@/lib/api-helpers" +import { authenticate, parseJsonBody, validateMaxLength } from "@/lib/api-helpers" import { db } from "@/lib/db" import { tagGroups } from "@/lib/db/schema" +import { EMOJI_MAX, LONG_STRING_MAX, SHORT_NAME_MAX } from "@/lib/limits" import { log } from "@/lib/logger" import { getTagGroupsWithMembers } from "@/lib/server-data" import { VALID_CHART_TYPES } from "@/types/api" @@ -37,11 +38,10 @@ export async function POST(request: Request) { return NextResponse.json({ error: "name is required" }, { status: 400 }) } - if (name.length > 100) { - return NextResponse.json({ error: "Name must be 100 characters or fewer" }, { status: 400 }) - } + const nameErr = validateMaxLength(name, SHORT_NAME_MAX, "Name") + if (nameErr) return nameErr - if (typeof description === "string" && description.length > 500) { + if (typeof description === "string" && description.length > LONG_STRING_MAX) { return NextResponse.json( { error: "Description must be 500 characters or fewer" }, { status: 400 } @@ -58,7 +58,7 @@ export async function POST(request: Request) { ) } - if (typeof emoji === "string" && emoji.length > 10) { + if (typeof emoji === "string" && emoji.length > EMOJI_MAX) { return NextResponse.json({ error: "Emoji must be 10 characters or fewer" }, { status: 400 }) } diff --git a/src/app/api/trackers/[id]/avatar/route.ts b/src/app/api/trackers/[id]/avatar/route.ts index 3577789a..8a75c413 100644 --- a/src/app/api/trackers/[id]/avatar/route.ts +++ b/src/app/api/trackers/[id]/avatar/route.ts @@ -6,14 +6,49 @@ import { eq } from "drizzle-orm" import { NextResponse } from "next/server" -import { authenticate, decodeKey, parseTrackerId, validateHttpUrl } from "@/lib/api-helpers" +import { + authenticate, + decodeKey, + parseTrackerId, + type RouteContext, + validateHttpUrl, +} from "@/lib/api-helpers" import { db } from "@/lib/db" import { appSettings, trackers } from "@/lib/db/schema" +import { AVATAR_FETCH_MAX_BYTES } from "@/lib/limits" import { log } from "@/lib/logger" -import { buildProxyAgentFromSettings, proxyFetch } from "@/lib/proxy" +import { buildProxyAgentFromSettings, proxyFetch } from "@/lib/tunnel" const STALE_MS = 7 * 24 * 60 * 60 * 1000 -const MAX_AVATAR_BYTES = 5 * 1024 * 1024 + +/** Detect image format from magic bytes. Falls back to image/png. */ +function sniffImageMime(buf: Buffer): string { + if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "image/jpeg" + if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return "image/png" + if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return "image/gif" + if ( + buf[0] === 0x52 && + buf[1] === 0x49 && + buf[2] === 0x46 && + buf[3] === 0x46 && + buf[8] === 0x57 && + buf[9] === 0x45 && + buf[10] === 0x42 && + buf[11] === 0x50 + ) + return "image/webp" + if ( + buf[0] === 0x00 && + buf[1] === 0x00 && + buf[2] === 0x00 && + buf[4] === 0x66 && + buf[5] === 0x74 && + buf[6] === 0x79 && + buf[7] === 0x70 + ) + return "image/avif" + return "image/png" +} function avatarUrl( platformType: string, @@ -27,7 +62,7 @@ function avatarUrl( return null } -export async function GET(_request: Request, props: { params: Promise<{ id: string }> }) { +export async function GET(_request: Request, props: RouteContext) { const auth = await authenticate() if (auth instanceof NextResponse) return auth @@ -40,6 +75,7 @@ export async function GET(_request: Request, props: { params: Promise<{ id: stri remoteUserId: trackers.remoteUserId, useProxy: trackers.useProxy, avatarData: trackers.avatarData, + avatarMimeType: trackers.avatarMimeType, avatarCachedAt: trackers.avatarCachedAt, avatarRemoteUrl: trackers.avatarRemoteUrl, }) @@ -68,7 +104,7 @@ export async function GET(_request: Request, props: { params: Promise<{ id: stri const data = Buffer.from(tracker.avatarData, "base64") return new NextResponse(new Uint8Array(data), { headers: { - "Content-Type": "image/png", + "Content-Type": tracker.avatarMimeType ?? "image/png", "Cache-Control": "private, max-age=86400", }, }) @@ -78,6 +114,7 @@ export async function GET(_request: Request, props: { params: Promise<{ id: stri // Fetch from tracker try { let imageBuffer: Buffer + let mimeType = "image/png" if (tracker.useProxy) { const [settings] = await db @@ -101,8 +138,8 @@ export async function GET(_request: Request, props: { params: Promise<{ id: stri const result = await proxyFetch(url, agent, { timeoutMs: 10000, - maxBytes: MAX_AVATAR_BYTES, - headers: { Accept: "image/png,image/*" }, + maxBytes: AVATAR_FETCH_MAX_BYTES, + headers: { Accept: "image/*" }, }) if (!result.ok) { @@ -110,19 +147,23 @@ export async function GET(_request: Request, props: { params: Promise<{ id: stri } imageBuffer = await result.buffer() + // proxyFetch doesn't expose content-type; sniff from magic bytes + mimeType = sniffImageMime(imageBuffer) } else { const response = await fetch(url, { signal: AbortSignal.timeout(10000) }) if (!response.ok) { return NextResponse.json({ error: "Avatar not found" }, { status: 404 }) } const contentLength = response.headers.get("content-length") - if (contentLength && parseInt(contentLength, 10) > MAX_AVATAR_BYTES) { + if (contentLength && parseInt(contentLength, 10) > AVATAR_FETCH_MAX_BYTES) { return NextResponse.json({ error: "Avatar too large" }, { status: 413 }) } imageBuffer = Buffer.from(await response.arrayBuffer()) + mimeType = + response.headers.get("content-type")?.split(";")[0].trim() || sniffImageMime(imageBuffer) } - if (imageBuffer.length > MAX_AVATAR_BYTES) { + if (imageBuffer.length > AVATAR_FETCH_MAX_BYTES) { return NextResponse.json({ error: "Avatar too large" }, { status: 413 }) } @@ -131,13 +172,14 @@ export async function GET(_request: Request, props: { params: Promise<{ id: stri .update(trackers) .set({ avatarData: imageBuffer.toString("base64"), + avatarMimeType: mimeType, avatarCachedAt: new Date(), }) .where(eq(trackers.id, trackerId)) return new NextResponse(new Uint8Array(imageBuffer), { headers: { - "Content-Type": "image/png", + "Content-Type": mimeType, "Cache-Control": "private, max-age=86400", }, }) @@ -151,7 +193,7 @@ export async function GET(_request: Request, props: { params: Promise<{ id: stri const data = Buffer.from(tracker.avatarData, "base64") return new NextResponse(new Uint8Array(data), { headers: { - "Content-Type": "image/png", + "Content-Type": tracker.avatarMimeType ?? "image/png", "Cache-Control": "private, max-age=3600", }, }) diff --git a/src/app/api/trackers/[id]/debug/route.ts b/src/app/api/trackers/[id]/debug/route.ts index b415497b..f1a0286b 100644 --- a/src/app/api/trackers/[id]/debug/route.ts +++ b/src/app/api/trackers/[id]/debug/route.ts @@ -8,13 +8,13 @@ import { eq } from "drizzle-orm" import { NextResponse } from "next/server" import { buildFetchOptions, getAdapter } from "@/lib/adapters" import type { DebugApiCall, TrackerStats } from "@/lib/adapters/types" -import { authenticate, decodeKey, parseTrackerId } from "@/lib/api-helpers" +import { authenticate, decodeKey, parseTrackerId, type RouteContext } from "@/lib/api-helpers" import { decrypt } from "@/lib/crypto" import { db } from "@/lib/db" import { appSettings, trackers } from "@/lib/db/schema" import { log } from "@/lib/logger" -import { buildProxyAgentFromSettings } from "@/lib/proxy" import { scrubObject } from "@/lib/scrub-object" +import { buildProxyAgentFromSettings } from "@/lib/tunnel" function serializeStats(stats: TrackerStats): Record { return { @@ -33,13 +33,14 @@ function serializeStats(stats: TrackerStats): Record { freeleechTokens: stats.freeleechTokens, remoteUserId: stats.remoteUserId ?? null, joinedDate: stats.joinedDate ?? null, + lastAccessDate: stats.lastAccessDate ?? null, shareScore: stats.shareScore ?? null, avatarUrl: stats.avatarUrl ?? null, platformMeta: stats.platformMeta ?? null, } } -export async function POST(_request: Request, props: { params: Promise<{ id: string }> }) { +export async function POST(_request: Request, props: RouteContext) { const auth = await authenticate() if (auth instanceof NextResponse) return auth diff --git a/src/app/api/trackers/[id]/mousehole/route.ts b/src/app/api/trackers/[id]/mousehole/route.ts index 41fdd3f5..bfaa6783 100644 --- a/src/app/api/trackers/[id]/mousehole/route.ts +++ b/src/app/api/trackers/[id]/mousehole/route.ts @@ -4,22 +4,21 @@ import { eq } from "drizzle-orm" import { NextResponse } from "next/server" -import { authenticate, parseTrackerId } from "@/lib/api-helpers" +import { authenticate, parseTrackerId, type RouteContext } from "@/lib/api-helpers" import { db } from "@/lib/db" import { trackers } from "@/lib/db/schema" +import { MOUSEHOLE_BODY_MAX_BYTES } from "@/lib/limits" import { log } from "@/lib/logger" const GET_TIMEOUT_MS = 10_000 const POST_TIMEOUT_MS = 15_000 -type RouteContext = { params: Promise<{ id: string }> } - // --------------------------------------------------------------------------- // Shared guards // --------------------------------------------------------------------------- async function resolveMouseholeBase( - params: Promise<{ id: string }> + params: RouteContext["params"] ): Promise { const trackerId = await parseTrackerId(params) if (trackerId instanceof NextResponse) return trackerId @@ -134,9 +133,8 @@ export async function POST(request: Request, { params }: RouteContext) { const { mouseholeBase } = resolved - const MAX_BODY_SIZE = 256 const contentLength = Number(request.headers.get("content-length") ?? 0) - if (contentLength > MAX_BODY_SIZE) { + if (contentLength > MOUSEHOLE_BODY_MAX_BYTES) { return NextResponse.json({ error: "Request body too large" }, { status: 413 }) } diff --git a/src/app/api/trackers/[id]/poll/route.ts b/src/app/api/trackers/[id]/poll/route.ts index dcaed335..a130bb78 100644 --- a/src/app/api/trackers/[id]/poll/route.ts +++ b/src/app/api/trackers/[id]/poll/route.ts @@ -1,39 +1,40 @@ // src/app/api/trackers/[id]/poll/route.ts -import { eq } from "drizzle-orm" +import { and, eq, isNull, lte, or } from "drizzle-orm" import { NextResponse } from "next/server" -import { authenticate, decodeKey, parseTrackerId } from "@/lib/api-helpers" +import { authenticate, decodeKey, parseTrackerId, type RouteContext } from "@/lib/api-helpers" import { db } from "@/lib/db" import { appSettings, trackers } from "@/lib/db/schema" import { isDecryptionError } from "@/lib/error-utils" +import { POLL_MANUAL_COOLDOWN_MS } from "@/lib/limits" import { log } from "@/lib/logger" -import { buildProxyAgentFromSettings } from "@/lib/proxy" -import { pollTracker } from "@/lib/scheduler" +import { pollTracker } from "@/lib/tracker-scheduler" +import { buildProxyAgentFromSettings } from "@/lib/tunnel" -const POLL_COOLDOWN_MS = 10_000 - -export async function POST(_request: Request, props: { params: Promise<{ id: string }> }) { +export async function POST(_request: Request, props: RouteContext) { const auth = await authenticate() if (auth instanceof NextResponse) return auth const trackerId = await parseTrackerId(props.params) if (trackerId instanceof NextResponse) return trackerId - // Rate limit: reject if this tracker was polled within the last 60 seconds - const [tracker] = await db - .select({ lastPolledAt: trackers.lastPolledAt }) - .from(trackers) - .where(eq(trackers.id, trackerId)) - .limit(1) - - if (tracker?.lastPolledAt) { - const elapsed = Date.now() - tracker.lastPolledAt.getTime() - if (elapsed < POLL_COOLDOWN_MS) { - const waitSec = Math.ceil((POLL_COOLDOWN_MS - elapsed) / 1000) - return NextResponse.json( - { error: `Poll cooldown: try again in ${waitSec}s` }, - { status: 429 } + // Atomically claim poll slot — prevents TOCTOU race with multiple tabs + const threshold = new Date(Date.now() - POLL_MANUAL_COOLDOWN_MS) + const [claimed] = await db + .update(trackers) + .set({ lastPolledAt: new Date() }) + .where( + and( + eq(trackers.id, trackerId), + or(isNull(trackers.lastPolledAt), lte(trackers.lastPolledAt, threshold)) ) - } + ) + .returning({ id: trackers.id }) + + if (!claimed) { + return NextResponse.json( + { error: "Poll cooldown: try again in a few seconds" }, + { status: 429 } + ) } const key = decodeKey(auth) @@ -55,7 +56,7 @@ export async function POST(_request: Request, props: { params: Promise<{ id: str const proxyAgent = settings ? buildProxyAgentFromSettings(settings, key) : undefined try { - await pollTracker(trackerId, key, privacyMode, proxyAgent) + await pollTracker(trackerId, key, privacyMode, proxyAgent, undefined, undefined, true) return NextResponse.json({ success: true }) } catch (error) { if (isDecryptionError(error)) { @@ -63,7 +64,7 @@ export async function POST(_request: Request, props: { params: Promise<{ id: str { route: "POST /api/trackers/[id]/poll", trackerId }, "manual poll failed — stale session key" ) - return NextResponse.json({ error: "Session expired — please log in again" }, { status: 401 }) + return NextResponse.json({ error: "Session expired. Please log in again." }, { status: 401 }) } log.error( { route: "POST /api/trackers/[id]/poll", trackerId, error: String(error) }, diff --git a/src/app/api/trackers/[id]/resume/route.ts b/src/app/api/trackers/[id]/resume/route.ts index 9ee113e8..f347d775 100644 --- a/src/app/api/trackers/[id]/resume/route.ts +++ b/src/app/api/trackers/[id]/resume/route.ts @@ -1,12 +1,12 @@ // src/app/api/trackers/[id]/resume/route.ts import { eq } from "drizzle-orm" import { NextResponse } from "next/server" -import { authenticate, parseTrackerId } from "@/lib/api-helpers" +import { authenticate, parseTrackerId, type RouteContext } from "@/lib/api-helpers" import { db } from "@/lib/db" import { trackers } from "@/lib/db/schema" import { log } from "@/lib/logger" -export async function POST(_request: Request, props: { params: Promise<{ id: string }> }) { +export async function POST(_request: Request, props: RouteContext) { const auth = await authenticate() if (auth instanceof NextResponse) return auth @@ -14,7 +14,11 @@ export async function POST(_request: Request, props: { params: Promise<{ id: str if (trackerId instanceof NextResponse) return trackerId const [tracker] = await db - .select({ pausedAt: trackers.pausedAt }) + .select({ + pausedAt: trackers.pausedAt, + consecutiveFailures: trackers.consecutiveFailures, + lastError: trackers.lastError, + }) .from(trackers) .where(eq(trackers.id, trackerId)) .limit(1) @@ -24,18 +28,49 @@ export async function POST(_request: Request, props: { params: Promise<{ id: str } if (!tracker.pausedAt) { - return NextResponse.json({ error: "Tracker is not paused" }, { status: 400 }) + log.info( + { + route: "POST /api/trackers/[id]/resume", + trackerId, + consecutiveFailures: tracker.consecutiveFailures, + lastError: tracker.lastError, + }, + "resume called but tracker is not paused (idempotent OK)" + ) + return NextResponse.json({ success: true, alreadyResumed: true }) } + log.info( + { + route: "POST /api/trackers/[id]/resume", + trackerId, + pausedAt: tracker.pausedAt.toISOString(), + consecutiveFailures: tracker.consecutiveFailures, + lastError: tracker.lastError, + }, + "resuming auto-paused tracker" + ) + try { await db .update(trackers) - .set({ pausedAt: null, lastError: null, updatedAt: new Date() }) + .set({ + pausedAt: null, + lastError: null, + lastErrorAt: null, + consecutiveFailures: 0, + updatedAt: new Date(), + }) .where(eq(trackers.id, trackerId)) } catch (error) { log.error(error, `Failed to resume tracker ${trackerId}`) return NextResponse.json({ error: "Failed to resume tracker" }, { status: 500 }) } + log.info( + { route: "POST /api/trackers/[id]/resume", trackerId }, + "tracker resumed, consecutiveFailures reset to 0" + ) + return NextResponse.json({ success: true }) } diff --git a/src/app/api/trackers/[id]/roles/route.ts b/src/app/api/trackers/[id]/roles/route.ts index 9f7f4912..d043db04 100644 --- a/src/app/api/trackers/[id]/roles/route.ts +++ b/src/app/api/trackers/[id]/roles/route.ts @@ -1,12 +1,19 @@ // src/app/api/trackers/[id]/roles/route.ts import { desc, eq } from "drizzle-orm" import { NextResponse } from "next/server" -import { authenticate, parseJsonBody, parseTrackerId } from "@/lib/api-helpers" +import { + authenticate, + parseJsonBody, + parseTrackerId, + type RouteContext, + validateMaxLength, +} from "@/lib/api-helpers" import { db } from "@/lib/db" import { trackerRoles } from "@/lib/db/schema" +import { TRACKER_NOTES_MAX, TRACKER_ROLE_NAME_MAX } from "@/lib/limits" import { log } from "@/lib/logger" -export async function GET(_request: Request, props: { params: Promise<{ id: string }> }) { +export async function GET(_request: Request, props: RouteContext) { const auth = await authenticate() if (auth instanceof NextResponse) return auth @@ -19,10 +26,15 @@ export async function GET(_request: Request, props: { params: Promise<{ id: stri .where(eq(trackerRoles.trackerId, trackerId)) .orderBy(desc(trackerRoles.achievedAt)) - return NextResponse.json(roles) + return NextResponse.json( + roles.map((role) => ({ + ...role, + achievedAt: role.achievedAt?.toISOString() ?? null, + })) + ) } -export async function POST(request: Request, props: { params: Promise<{ id: string }> }) { +export async function POST(request: Request, props: RouteContext) { const auth = await authenticate() if (auth instanceof NextResponse) return auth @@ -42,12 +54,8 @@ export async function POST(request: Request, props: { params: Promise<{ id: stri return NextResponse.json({ error: "roleName is required" }, { status: 400 }) } - if (roleName.length > 255) { - return NextResponse.json( - { error: "Role name must be 255 characters or fewer" }, - { status: 400 } - ) - } + const roleNameErr = validateMaxLength(roleName, TRACKER_ROLE_NAME_MAX, "Role name") + if (roleNameErr) return roleNameErr if (achievedAt !== undefined) { if (typeof achievedAt !== "string" || Number.isNaN(new Date(achievedAt).getTime())) { @@ -55,8 +63,9 @@ export async function POST(request: Request, props: { params: Promise<{ id: stri } } - if (typeof notes === "string" && notes.length > 2000) { - return NextResponse.json({ error: "Notes must be 2000 characters or fewer" }, { status: 400 }) + if (typeof notes === "string") { + const notesErr = validateMaxLength(notes, TRACKER_NOTES_MAX, "Notes") + if (notesErr) return notesErr } const [role] = await db @@ -70,5 +79,8 @@ export async function POST(request: Request, props: { params: Promise<{ id: stri .returning() log.info({ route: "POST /api/trackers/[id]/roles", trackerId }, "role created") - return NextResponse.json(role, { status: 201 }) + return NextResponse.json( + { ...role, achievedAt: role.achievedAt?.toISOString() ?? null }, + { status: 201 } + ) } diff --git a/src/app/api/trackers/[id]/route.ts b/src/app/api/trackers/[id]/route.ts index bfd57ace..032f8db3 100644 --- a/src/app/api/trackers/[id]/route.ts +++ b/src/app/api/trackers/[id]/route.ts @@ -9,30 +9,48 @@ import { decodeKey, parseJsonBody, parseTrackerId, + type RouteContext, validateHexColor, validateHttpUrl, validateJoinedAt, + validateMaxLength, } from "@/lib/api-helpers" import { encrypt } from "@/lib/crypto" import { db } from "@/lib/db" import { trackers } from "@/lib/db/schema" +import { errMsg } from "@/lib/error-utils" +import { + AVISTAZ_TOKEN_MAX, + LONG_STRING_MAX, + TRACKER_NAME_MAX, + TRACKER_TAG_MAX, + TRACKER_TOKEN_MAX, + TRACKER_URL_MAX, +} from "@/lib/limits" import { log } from "@/lib/logger" import { getTrackerForClient } from "@/lib/server-data" -export async function GET(_request: Request, props: { params: Promise<{ id: string }> }) { +export async function GET(_request: Request, props: RouteContext) { const auth = await authenticate() if (auth instanceof NextResponse) return auth const trackerId = await parseTrackerId(props.params) if (trackerId instanceof NextResponse) return trackerId - const tracker = await getTrackerForClient(trackerId) - if (!tracker) return NextResponse.json({ error: "Tracker not found" }, { status: 404 }) - - return NextResponse.json(tracker) + try { + const tracker = await getTrackerForClient(trackerId) + if (!tracker) return NextResponse.json({ error: "Tracker not found" }, { status: 404 }) + return NextResponse.json(tracker) + } catch (err) { + log.error( + { route: "GET /api/trackers/[id]", trackerId, error: errMsg(err) }, + "Failed to fetch tracker" + ) + return NextResponse.json({ error: "Failed to load tracker" }, { status: 500 }) + } } -export async function PATCH(request: Request, props: { params: Promise<{ id: string }> }) { +export async function PATCH(request: Request, props: RouteContext) { const auth = await authenticate() if (auth instanceof NextResponse) return auth @@ -45,15 +63,13 @@ export async function PATCH(request: Request, props: { params: Promise<{ id: str const updates: Record = { updatedAt: new Date() } if (typeof body.name === "string") { - if (body.name.length > 100) { - return NextResponse.json({ error: "Name must be 100 characters or fewer" }, { status: 400 }) - } + const nameErr = validateMaxLength(body.name, TRACKER_NAME_MAX, "Name") + if (nameErr) return nameErr updates.name = body.name.trim() } if (typeof body.baseUrl === "string") { - if (body.baseUrl.length > 500) { - return NextResponse.json({ error: "URL must be 500 characters or fewer" }, { status: 400 }) - } + const urlLenErr = validateMaxLength(body.baseUrl, TRACKER_URL_MAX, "URL") + if (urlLenErr) return urlLenErr const urlErr = validateHttpUrl(body.baseUrl as string) if (urlErr) return urlErr updates.baseUrl = (body.baseUrl as string).trim() @@ -66,26 +82,18 @@ export async function PATCH(request: Request, props: { params: Promise<{ id: str } if (typeof body.qbtTag === "string") { - if (body.qbtTag.length > 100) { - return NextResponse.json( - { error: "qBittorrent tag must be 100 characters or fewer" }, - { status: 400 } - ) - } + const qbtTagErr = validateMaxLength(body.qbtTag, TRACKER_TAG_MAX, "qBittorrent tag") + if (qbtTagErr) return qbtTagErr updates.qbtTag = body.qbtTag.trim() || null } if (typeof body.mouseholeUrl === "string") { const trimmed = body.mouseholeUrl.trim() if (trimmed) { - try { - const parsed = new URL(trimmed) - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - return NextResponse.json({ error: "Mousehole URL must use http or https" }, { status: 400 }) - } - } catch { - return NextResponse.json({ error: "Invalid Mousehole URL format" }, { status: 400 }) - } + const mouseholeUrlErr = validateMaxLength(trimmed, LONG_STRING_MAX, "Mousehole URL") + if (mouseholeUrlErr) return mouseholeUrlErr + const mouseUrlErr = validateHttpUrl(trimmed, "Mousehole URL") + if (mouseUrlErr) return mouseUrlErr } updates.mouseholeUrl = trimmed || null } @@ -101,6 +109,7 @@ export async function PATCH(request: Request, props: { params: Promise<{ id: str updates.pausedAt = null updates.consecutiveFailures = 0 updates.lastError = null + updates.lastErrorAt = null } log.info( { @@ -126,30 +135,48 @@ export async function PATCH(request: Request, props: { params: Promise<{ id: str if (typeof body.apiToken === "string") { const trimmedToken = (body.apiToken as string).trim() - if (trimmedToken.length > 500) { - return NextResponse.json( - { error: "API token must be 500 characters or fewer" }, - { status: 400 } - ) - } + const [tracker] = await db + .select({ platformType: trackers.platformType }) + .from(trackers) + .where(eq(trackers.id, trackerId)) + .limit(1) + const maxTokenLength = + tracker?.platformType === "avistaz" ? AVISTAZ_TOKEN_MAX : TRACKER_TOKEN_MAX + const tokenErr = validateMaxLength(trimmedToken, maxTokenLength, "API token") + if (tokenErr) return tokenErr const key = decodeKey(auth) updates.encryptedApiToken = encrypt(trimmedToken, key) } - await db.update(trackers).set(updates).where(eq(trackers.id, trackerId)) - - return NextResponse.json({ success: true }) + try { + await db.update(trackers).set(updates).where(eq(trackers.id, trackerId)) + const updated = await getTrackerForClient(trackerId) + return NextResponse.json(updated ?? { success: true }) + } catch (err) { + log.error( + { route: "PATCH /api/trackers/[id]", trackerId, error: errMsg(err) }, + "Failed to update tracker" + ) + return NextResponse.json({ error: "Failed to update tracker" }, { status: 500 }) + } } -export async function DELETE(_request: Request, props: { params: Promise<{ id: string }> }) { +export async function DELETE(_request: Request, props: RouteContext) { const auth = await authenticate() if (auth instanceof NextResponse) return auth const trackerId = await parseTrackerId(props.params) if (trackerId instanceof NextResponse) return trackerId - await db.delete(trackers).where(eq(trackers.id, trackerId)) - - log.info({ route: "DELETE /api/trackers/[id]", trackerId }, "tracker deleted") - return NextResponse.json({ success: true }) + try { + await db.delete(trackers).where(eq(trackers.id, trackerId)) + log.info({ route: "DELETE /api/trackers/[id]", trackerId }, "tracker deleted") + return NextResponse.json({ success: true }) + } catch (err) { + log.error( + { route: "DELETE /api/trackers/[id]", trackerId, error: errMsg(err) }, + "Failed to delete tracker" + ) + return NextResponse.json({ error: "Failed to delete tracker" }, { status: 500 }) + } } diff --git a/src/app/api/trackers/[id]/snapshots/route.ts b/src/app/api/trackers/[id]/snapshots/route.ts index 21834923..106b7f5a 100644 --- a/src/app/api/trackers/[id]/snapshots/route.ts +++ b/src/app/api/trackers/[id]/snapshots/route.ts @@ -1,10 +1,14 @@ // src/app/api/trackers/[id]/snapshots/route.ts import { NextResponse } from "next/server" -import { authenticate, parseTrackerId } from "@/lib/api-helpers" +import { authenticate, parseTrackerId, type RouteContext } from "@/lib/api-helpers" +import { errMsg } from "@/lib/error-utils" +import { SNAPSHOT_QUERY_MAX } from "@/lib/limits" +import { log } from "@/lib/logger" import { getSnapshotsForTracker } from "@/lib/server-data" +import { parseIntClamped } from "@/lib/validators" -export async function GET(request: Request, props: { params: Promise<{ id: string }> }) { +export async function GET(request: Request, props: RouteContext) { const auth = await authenticate() if (auth instanceof NextResponse) return auth @@ -12,10 +16,16 @@ export async function GET(request: Request, props: { params: Promise<{ id: strin if (trackerId instanceof NextResponse) return trackerId const url = new URL(request.url) - const daysParam = url.searchParams.get("days") - const daysRaw = parseInt(daysParam ?? "30", 10) - const days = Number.isNaN(daysRaw) ? 30 : daysRaw + const days = parseIntClamped(url.searchParams.get("days"), 0, SNAPSHOT_QUERY_MAX, 30) - const snapshots = await getSnapshotsForTracker(trackerId, days) - return NextResponse.json(snapshots) + try { + const snapshots = await getSnapshotsForTracker(trackerId, days) + return NextResponse.json(snapshots) + } catch (err) { + log.error( + { route: "GET /api/trackers/[id]/snapshots", trackerId, error: errMsg(err) }, + "Failed to fetch snapshots" + ) + return NextResponse.json({ error: "Failed to load snapshots" }, { status: 500 }) + } } diff --git a/src/app/api/trackers/[id]/torrents/cached/route.ts b/src/app/api/trackers/[id]/torrents/cached/route.ts index 61bd8dfe..782aa168 100644 --- a/src/app/api/trackers/[id]/torrents/cached/route.ts +++ b/src/app/api/trackers/[id]/torrents/cached/route.ts @@ -1,122 +1,21 @@ // src/app/api/trackers/[id]/torrents/cached/route.ts // // Functions: GET -// -// Returns cached torrent data from the last successful deep poll. -// Used as fallback when live qBittorrent connection fails. -import { eq } from "drizzle-orm" import { NextResponse } from "next/server" -import { authenticate, parseTrackerId } from "@/lib/api-helpers" -import { db } from "@/lib/db" -import { downloadClients, trackers } from "@/lib/db/schema" -import { parseCrossSeedTags, type QbtTorrent } from "@/lib/qbt" -import { aggregateCrossSeedTags, mergeTorrentLists } from "@/lib/qbt/merge" +import { authenticate, parseTrackerId, type RouteContext } from "@/lib/api-helpers" +import { fetchTrackerTorrentsCached } from "@/lib/download-clients" -export async function GET(_request: Request, props: { params: Promise<{ id: string }> }) { +export async function GET(_request: Request, props: RouteContext) { const auth = await authenticate() if (auth instanceof NextResponse) return auth const trackerId = await parseTrackerId(props.params) if (trackerId instanceof NextResponse) return trackerId - // Look up tracker to get qbtTag - const [tracker] = await db - .select({ qbtTag: trackers.qbtTag }) - .from(trackers) - .where(eq(trackers.id, trackerId)) - .limit(1) - - if (!tracker) { - return NextResponse.json({ error: "Tracker not found" }, { status: 404 }) - } - - if (!tracker.qbtTag) { - return NextResponse.json({ error: "No qBittorrent tag configured" }, { status: 400 }) - } - - // Fetch all enabled clients that have cached data - const clients = await db - .select({ - id: downloadClients.id, - name: downloadClients.name, - cachedTorrents: downloadClients.cachedTorrents, - cachedTorrentsAt: downloadClients.cachedTorrentsAt, - crossSeedTags: downloadClients.crossSeedTags, - }) - .from(downloadClients) - .where(eq(downloadClients.enabled, true)) - - if (clients.length === 0) { - return NextResponse.json({ - torrents: [], - crossSeedTags: [], - clientErrors: [], - clientCount: 0, - cachedAt: null, - }) - } - - const tag = tracker.qbtTag.trim().toLowerCase() - const torrentLists: QbtTorrent[][] = [] - const crossSeedClients: { crossSeedTags: string[] }[] = [] - const hashClients = new Map() - let oldestCacheAt: Date | null = null - - // Drizzle returns jsonb columns already deserialized — no JSON.parse needed. - // Cached blobs can be 500 KB-5 MB; we map once and reuse for filtering + stamping. - const parsedCache = new Map() - for (const client of clients) { - if (!client.cachedTorrents || !Array.isArray(client.cachedTorrents)) continue - parsedCache.set(client.id, client.cachedTorrents as QbtTorrent[]) + const out = await fetchTrackerTorrentsCached(trackerId) + if ("error" in out) { + return NextResponse.json({ error: out.error }, { status: out.status }) } - - for (const client of clients) { - const torrents = parsedCache.get(client.id) - if (!torrents) continue - - // Re-filter by this tracker's tag (cache is per-client, not per-tracker) - const filtered = torrents.filter((t) => - t.tags - .split(",") - .map((s) => s.trim().toLowerCase()) - .includes(tag) - ) - - torrentLists.push(filtered) - - // Build hash->client name(s) map for client_name stamping (matches live endpoint) - for (const t of filtered) { - const names = hashClients.get(t.hash) ?? [] - names.push(client.name) - hashClients.set(t.hash, names) - } - - crossSeedClients.push({ crossSeedTags: parseCrossSeedTags(client.crossSeedTags) }) - - // Track the oldest cache timestamp for the stale indicator. - // We use the oldest (most pessimistic) timestamp across clients so the - // UI banner reflects the worst-case staleness. - if (client.cachedTorrentsAt) { - if (!oldestCacheAt || client.cachedTorrentsAt < oldestCacheAt) { - oldestCacheAt = client.cachedTorrentsAt - } - } - } - - const merged = mergeTorrentLists(torrentLists) - const crossSeedTags = aggregateCrossSeedTags(crossSeedClients) - - const stamped = merged.map((t) => ({ - ...t, - client_name: (hashClients.get(t.hash) ?? []).join(", "), - })) - - return NextResponse.json({ - torrents: stamped, - crossSeedTags, - clientErrors: [], - clientCount: clients.length, - cachedAt: oldestCacheAt?.toISOString() ?? null, - }) + return NextResponse.json(out.result) } diff --git a/src/app/api/trackers/[id]/torrents/route.ts b/src/app/api/trackers/[id]/torrents/route.ts index b3e19935..c03c4ee1 100644 --- a/src/app/api/trackers/[id]/torrents/route.ts +++ b/src/app/api/trackers/[id]/torrents/route.ts @@ -1,67 +1,30 @@ // src/app/api/trackers/[id]/torrents/route.ts // // Functions: GET -// -// Aggregated torrents endpoint — queries ALL enabled download clients -// for the tracker's qbtTag, merges results with deduplication by hash. -import { eq } from "drizzle-orm" import { NextResponse } from "next/server" -import { authenticate, decodeKey, parseTrackerId } from "@/lib/api-helpers" -import { db } from "@/lib/db" -import { downloadClients, trackers } from "@/lib/db/schema" +import { authenticate, decodeKey, parseTrackerId, type RouteContext } from "@/lib/api-helpers" +import { fetchTrackerTorrents } from "@/lib/download-clients" import { log } from "@/lib/logger" -import { fetchAndMergeTorrents } from "@/lib/qbt/fetch-merged" -export async function GET(request: Request, props: { params: Promise<{ id: string }> }) { +export async function GET(request: Request, props: RouteContext) { const auth = await authenticate() if (auth instanceof NextResponse) return auth const trackerId = await parseTrackerId(props.params) if (trackerId instanceof NextResponse) return trackerId - // Look up tracker to get qbtTag - const [tracker] = await db - .select({ qbtTag: trackers.qbtTag }) - .from(trackers) - .where(eq(trackers.id, trackerId)) - .limit(1) - - if (!tracker) { - return NextResponse.json({ error: "Tracker not found" }, { status: 404 }) - } - - if (!tracker.qbtTag) { - return NextResponse.json( - { error: "No qBittorrent tag configured for this tracker" }, - { status: 400 } - ) - } - - // Fetch only the columns needed — avoids loading cachedTorrents blob - // and keeps encrypted credentials scoped to this handler's memory. - const clients = await db - .select({ - name: downloadClients.name, - host: downloadClients.host, - port: downloadClients.port, - useSsl: downloadClients.useSsl, - encryptedUsername: downloadClients.encryptedUsername, - encryptedPassword: downloadClients.encryptedPassword, - crossSeedTags: downloadClients.crossSeedTags, - }) - .from(downloadClients) - .where(eq(downloadClients.enabled, true)) - const key = decodeKey(auth) - const tag = tracker.qbtTag.trim() const url = new URL(request.url) const activeOnly = url.searchParams.get("active") === "true" const qbtFilter = activeOnly ? "active" : undefined try { - const result = await fetchAndMergeTorrents(clients, [tag], key, qbtFilter) - return NextResponse.json(result) + const out = await fetchTrackerTorrents(trackerId, key, qbtFilter) + if ("error" in out) { + return NextResponse.json({ error: out.error }, { status: out.status }) + } + return NextResponse.json(out.result) } catch (error) { log.error( { route: "GET /api/trackers/[id]/torrents", trackerId, error: String(error) }, diff --git a/src/app/api/trackers/poll-all/route.ts b/src/app/api/trackers/poll-all/route.ts index 09b069ea..fd1af0aa 100644 --- a/src/app/api/trackers/poll-all/route.ts +++ b/src/app/api/trackers/poll-all/route.ts @@ -13,8 +13,8 @@ import { authenticate, decodeKey } from "@/lib/api-helpers" import { db } from "@/lib/db" import { appSettings, trackers } from "@/lib/db/schema" import { log } from "@/lib/logger" -import { buildProxyAgentFromSettings } from "@/lib/proxy" -import { pollTracker } from "@/lib/scheduler" +import { pollTracker } from "@/lib/tracker-scheduler" +import { buildProxyAgentFromSettings } from "@/lib/tunnel" export async function POST() { const auth = await authenticate() @@ -22,27 +22,25 @@ export async function POST() { const key = decodeKey(auth) - const [settings] = await db - .select({ - storeUsernames: appSettings.storeUsernames, - proxyEnabled: appSettings.proxyEnabled, - proxyType: appSettings.proxyType, - proxyHost: appSettings.proxyHost, - proxyPort: appSettings.proxyPort, - proxyUsername: appSettings.proxyUsername, - encryptedProxyPassword: appSettings.encryptedProxyPassword, - }) - .from(appSettings) - .limit(1) + const [[settings], activeTrackers] = await Promise.all([ + db + .select({ + storeUsernames: appSettings.storeUsernames, + proxyEnabled: appSettings.proxyEnabled, + proxyType: appSettings.proxyType, + proxyHost: appSettings.proxyHost, + proxyPort: appSettings.proxyPort, + proxyUsername: appSettings.proxyUsername, + encryptedProxyPassword: appSettings.encryptedProxyPassword, + }) + .from(appSettings) + .limit(1), + db.select({ id: trackers.id }).from(trackers).where(eq(trackers.isActive, true)), + ]) const privacyMode = settings ? !settings.storeUsernames : false const proxyAgent = settings ? buildProxyAgentFromSettings(settings, key) : undefined - const activeTrackers = await db - .select({ id: trackers.id }) - .from(trackers) - .where(eq(trackers.isActive, true)) - if (activeTrackers.length === 0) { return NextResponse.json({ total: 0, done: true, polled: 0, failed: 0 }) } diff --git a/src/app/api/trackers/reorder/route.ts b/src/app/api/trackers/reorder/route.ts index 74668d93..0fd39b37 100644 --- a/src/app/api/trackers/reorder/route.ts +++ b/src/app/api/trackers/reorder/route.ts @@ -4,6 +4,9 @@ import { NextResponse } from "next/server" import { authenticate, parseJsonBody } from "@/lib/api-helpers" import { db } from "@/lib/db" import { trackers } from "@/lib/db/schema" +import { errMsg } from "@/lib/error-utils" +import { REORDER_IDS_MAX } from "@/lib/limits" +import { log } from "@/lib/logger" export async function PATCH(request: Request) { const auth = await authenticate() @@ -18,7 +21,7 @@ export async function PATCH(request: Request) { return NextResponse.json({ error: "ids must be a non-empty array of numbers" }, { status: 400 }) } - if (ids.length > 500) { + if (ids.length > REORDER_IDS_MAX) { return NextResponse.json({ error: "Too many ids" }, { status: 400 }) } @@ -26,9 +29,22 @@ export async function PATCH(request: Request) { return NextResponse.json({ error: "All ids must be integers" }, { status: 400 }) } - await Promise.all( - ids.map((id, index) => db.update(trackers).set({ sortOrder: index }).where(eq(trackers.id, id))) - ) + if (new Set(ids).size !== ids.length) { + return NextResponse.json({ error: "Duplicate ids are not allowed" }, { status: 400 }) + } - return NextResponse.json({ ok: true }) + try { + await db.transaction(async (tx) => { + for (let i = 0; i < ids.length; i++) { + await tx.update(trackers).set({ sortOrder: i }).where(eq(trackers.id, ids[i])) + } + }) + return NextResponse.json({ success: true }) + } catch (err) { + log.error( + { route: "PATCH /api/trackers/reorder", error: errMsg(err) }, + "Failed to reorder trackers" + ) + return NextResponse.json({ error: "Failed to reorder trackers" }, { status: 500 }) + } } diff --git a/src/app/api/trackers/route.ts b/src/app/api/trackers/route.ts index fc2eacef..cb805191 100644 --- a/src/app/api/trackers/route.ts +++ b/src/app/api/trackers/route.ts @@ -12,10 +12,20 @@ import { validateHexColor, validateHttpUrl, validateJoinedAt, + validateMaxLength, } from "@/lib/api-helpers" import { encrypt } from "@/lib/crypto" import { db } from "@/lib/db" import { trackers } from "@/lib/db/schema" +import { errMsg } from "@/lib/error-utils" +import { + AVISTAZ_TOKEN_MAX, + LONG_STRING_MAX, + TRACKER_NAME_MAX, + TRACKER_TAG_MAX, + TRACKER_TOKEN_MAX, + TRACKER_URL_MAX, +} from "@/lib/limits" import { log } from "@/lib/logger" import { getTrackerListForDashboard } from "@/lib/server-data" @@ -23,8 +33,13 @@ export async function GET() { const auth = await authenticate() if (auth instanceof NextResponse) return auth - const trackerList = await getTrackerListForDashboard() - return NextResponse.json(trackerList) + try { + const trackerList = await getTrackerListForDashboard() + return NextResponse.json(trackerList) + } catch (err) { + log.error({ route: "GET /api/trackers", error: errMsg(err) }, "Failed to fetch trackers") + return NextResponse.json({ error: "Failed to load trackers" }, { status: 500 }) + } } export async function POST(request: Request) { @@ -62,21 +77,17 @@ export async function POST(request: Request) { const trimmedName = name.trim() const trimmedBaseUrl = baseUrl.trim() const trimmedApiToken = apiToken.trim() + const platform = typeof platformType === "string" ? platformType : "unit3d" - if (trimmedName.length > 100) { - return NextResponse.json({ error: "Name must be 100 characters or fewer" }, { status: 400 }) - } + const nameErr = validateMaxLength(trimmedName, TRACKER_NAME_MAX, "Name") + if (nameErr) return nameErr - if (trimmedBaseUrl.length > 500) { - return NextResponse.json({ error: "URL must be 500 characters or fewer" }, { status: 400 }) - } + const urlLenErr = validateMaxLength(trimmedBaseUrl, TRACKER_URL_MAX, "URL") + if (urlLenErr) return urlLenErr - if (trimmedApiToken.length > 500) { - return NextResponse.json( - { error: "API token must be 500 characters or fewer" }, - { status: 400 } - ) - } + const maxTokenLength = platform === "avistaz" ? AVISTAZ_TOKEN_MAX : TRACKER_TOKEN_MAX + const tokenErr = validateMaxLength(trimmedApiToken, maxTokenLength, "API token") + if (tokenErr) return tokenErr const urlErr = validateHttpUrl(trimmedBaseUrl) if (urlErr) return urlErr @@ -86,22 +97,16 @@ export async function POST(request: Request) { if (colorErr) return colorErr } - if (typeof qbtTag === "string" && qbtTag.length > 100) { - return NextResponse.json( - { error: "qBittorrent tag must be 100 characters or fewer" }, - { status: 400 } - ) + if (typeof qbtTag === "string") { + const qbtTagErr = validateMaxLength(qbtTag, TRACKER_TAG_MAX, "qBittorrent tag") + if (qbtTagErr) return qbtTagErr } if (typeof mouseholeUrl === "string" && mouseholeUrl.trim()) { - try { - const parsed = new URL(mouseholeUrl.trim()) - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - return NextResponse.json({ error: "Mousehole URL must use http or https" }, { status: 400 }) - } - } catch { - return NextResponse.json({ error: "Invalid Mousehole URL format" }, { status: 400 }) - } + const mouseholeUrlErr = validateMaxLength(mouseholeUrl.trim(), LONG_STRING_MAX, "Mousehole URL") + if (mouseholeUrlErr) return mouseholeUrlErr + const mouseUrlErr = validateHttpUrl(mouseholeUrl.trim(), "Mousehole URL") + if (mouseUrlErr) return mouseUrlErr } if (typeof joinedAt === "string" && joinedAt) { @@ -109,31 +114,38 @@ export async function POST(request: Request) { if (joinedAtErr) return joinedAtErr } - const platform = typeof platformType === "string" ? platformType : "unit3d" if (!VALID_PLATFORM_TYPES.includes(platform as (typeof VALID_PLATFORM_TYPES)[number])) { return NextResponse.json({ error: "Invalid platform type" }, { status: 400 }) } - const key = decodeKey(auth) - const encryptedApiToken = encrypt(trimmedApiToken, key) - - const [tracker] = await db - .insert(trackers) - .values({ - name: trimmedName, - baseUrl: trimmedBaseUrl, - apiPath: DEFAULT_API_PATHS[platform] ?? "/api/user", - encryptedApiToken, - platformType: platform, - color: (color as string) || CHART_THEME.accent, - qbtTag: typeof qbtTag === "string" ? qbtTag.trim() : null, - mouseholeUrl: - typeof mouseholeUrl === "string" && mouseholeUrl.trim() ? mouseholeUrl.trim() : null, - joinedAt: typeof joinedAt === "string" && joinedAt ? joinedAt : null, - }) - .returning() - - // SECURITY: Only return safe fields - log.info({ route: "POST /api/trackers", trackerId: tracker.id }, "tracker created") - return NextResponse.json({ id: tracker.id, name: tracker.name }, { status: 201 }) + try { + const key = decodeKey(auth) + const encryptedApiToken = encrypt(trimmedApiToken, key) + + const [tracker] = await db + .insert(trackers) + .values({ + name: trimmedName, + baseUrl: trimmedBaseUrl, + apiPath: DEFAULT_API_PATHS[platform] ?? "/api/user", + encryptedApiToken, + platformType: platform, + color: (color as string) || CHART_THEME.accent, + qbtTag: typeof qbtTag === "string" ? qbtTag.trim() : null, + mouseholeUrl: + typeof mouseholeUrl === "string" && mouseholeUrl.trim() ? mouseholeUrl.trim() : null, + joinedAt: typeof joinedAt === "string" && joinedAt ? joinedAt : null, + }) + .returning() + + // SECURITY: Only return safe fields + log.info( + { route: "POST /api/trackers", trackerId: tracker.id, trackerName: tracker.name }, + `tracker created: ${tracker.name}` + ) + return NextResponse.json({ id: tracker.id, name: tracker.name }, { status: 201 }) + } catch (err) { + log.error({ route: "POST /api/trackers", error: errMsg(err) }, "Failed to create tracker") + return NextResponse.json({ error: "Failed to create tracker" }, { status: 500 }) + } } diff --git a/src/app/api/trackers/snapshots/fleet/route.ts b/src/app/api/trackers/snapshots/fleet/route.ts new file mode 100644 index 00000000..797ea0f3 --- /dev/null +++ b/src/app/api/trackers/snapshots/fleet/route.ts @@ -0,0 +1,30 @@ +// src/app/api/trackers/snapshots/fleet/route.ts +// +// Functions: GET + +import { NextResponse } from "next/server" +import { authenticate } from "@/lib/api-helpers" +import { errMsg } from "@/lib/error-utils" +import { SNAPSHOT_QUERY_MAX } from "@/lib/limits" +import { log } from "@/lib/logger" +import { getFleetSnapshots } from "@/lib/server-data" +import { parseIntClamped } from "@/lib/validators" + +export async function GET(request: Request) { + const auth = await authenticate() + if (auth instanceof NextResponse) return auth + + const url = new URL(request.url) + const days = parseIntClamped(url.searchParams.get("days"), 0, SNAPSHOT_QUERY_MAX, 30) + + try { + const data = await getFleetSnapshots(days) + return NextResponse.json(data) + } catch (err) { + log.error( + { route: "GET /api/trackers/snapshots/fleet", error: errMsg(err) }, + "Failed to fetch fleet snapshots" + ) + return NextResponse.json({ error: "Failed to load fleet snapshots" }, { status: 500 }) + } +} diff --git a/src/app/api/trackers/test-connection/route.ts b/src/app/api/trackers/test-connection/route.ts new file mode 100644 index 00000000..09c2f682 --- /dev/null +++ b/src/app/api/trackers/test-connection/route.ts @@ -0,0 +1,122 @@ +// src/app/api/trackers/test-connection/route.ts +import { NextResponse } from "next/server" +import { + buildFetchOptions, + DEFAULT_API_PATHS, + getAdapter, + VALID_PLATFORM_TYPES, +} from "@/lib/adapters" +import { + authenticate, + decodeKey, + parseJsonBody, + validateHttpUrl, + validateMaxLength, +} from "@/lib/api-helpers" +import { db } from "@/lib/db" +import { appSettings } from "@/lib/db/schema" +import { sanitizeNetworkError } from "@/lib/error-utils" +import { + AVISTAZ_TOKEN_MAX, + LONG_STRING_MAX, + TRACKER_TOKEN_MAX, + TRACKER_URL_MAX, +} from "@/lib/limits" +import { log } from "@/lib/logger" +import { buildProxyAgentFromSettings } from "@/lib/tunnel" + +export async function POST(request: Request) { + const auth = await authenticate() + if (auth instanceof NextResponse) return auth + + const body = await parseJsonBody(request) + if (body instanceof NextResponse) return body + + const { baseUrl, apiToken, platformType, apiPath } = body as { + baseUrl?: string + apiToken?: string + platformType?: string + apiPath?: string + } + + if (!baseUrl || typeof baseUrl !== "string" || !apiToken || typeof apiToken !== "string") { + return NextResponse.json({ error: "baseUrl and apiToken are required" }, { status: 400 }) + } + + const trimmedBaseUrl = baseUrl.trim() + const trimmedApiToken = apiToken.trim() + const platform = typeof platformType === "string" ? platformType : "unit3d" + + const maxTokenLength = platform === "avistaz" ? AVISTAZ_TOKEN_MAX : TRACKER_TOKEN_MAX + const tokenErr = validateMaxLength(trimmedApiToken, maxTokenLength, "API token") + if (tokenErr) return tokenErr + + const urlLenErr = validateMaxLength(trimmedBaseUrl, TRACKER_URL_MAX, "URL") + if (urlLenErr) return urlLenErr + + const urlErr = validateHttpUrl(trimmedBaseUrl) + if (urlErr) return urlErr + + if (!VALID_PLATFORM_TYPES.includes(platform as (typeof VALID_PLATFORM_TYPES)[number])) { + return NextResponse.json({ error: "Invalid platform type" }, { status: 400 }) + } + + try { + const key = decodeKey(auth) + const [settings] = await db + .select({ + proxyEnabled: appSettings.proxyEnabled, + proxyType: appSettings.proxyType, + proxyHost: appSettings.proxyHost, + proxyPort: appSettings.proxyPort, + proxyUsername: appSettings.proxyUsername, + encryptedProxyPassword: appSettings.encryptedProxyPassword, + }) + .from(appSettings) + .limit(1) + + const proxyAgent = settings ? buildProxyAgentFromSettings(settings, key) : undefined + + const adapter = getAdapter(platform) + const defaultPath = DEFAULT_API_PATHS[platform] ?? "/api/user" + const rawPath = typeof apiPath === "string" && apiPath.startsWith("/") ? apiPath : defaultPath + const pathLenErr = validateMaxLength(rawPath, LONG_STRING_MAX, "API path") + if (pathLenErr) return pathLenErr + const path = rawPath + const fetchOptions = buildFetchOptions(trimmedBaseUrl, { + proxyAgent: proxyAgent ?? undefined, + }) + const stats = await adapter.fetchStats(trimmedBaseUrl, trimmedApiToken, path, fetchOptions) + + const result: Record = { + success: true, + username: stats.username, + group: stats.group, + } + + if (platform === "avistaz") { + log.debug( + { + route: "POST /api/trackers/test-connection", + userAgent: request.headers.get("user-agent") ?? "", + }, + "tracker test user agent captured" + ) + } + + return NextResponse.json(result) + } catch (error) { + const raw = error instanceof Error ? error.message : String(error) + const safeError = sanitizeNetworkError(raw, "Tracker test failed") + log.warn( + { + route: "POST /api/trackers/test-connection", + platform, + baseUrl: trimmedBaseUrl, + error: raw, + }, + `tracker test failed: ${safeError}` + ) + return NextResponse.json({ error: safeError }, { status: 422 }) + } +} diff --git a/src/app/api/trackers/test-connection/test-connection-route.test.ts b/src/app/api/trackers/test-connection/test-connection-route.test.ts new file mode 100644 index 00000000..16934a09 --- /dev/null +++ b/src/app/api/trackers/test-connection/test-connection-route.test.ts @@ -0,0 +1,506 @@ +// src/app/api/trackers/test-connection/test-connection-route.test.ts +// +// Tests for POST /api/trackers/test-connection +// Focuses on the three new behaviors added in the AvistaZ connection fix: +// 1. Returns sanitized actual error instead of generic "Tracker test failed" +// 2. Logs platform, baseUrl, and raw error in the msg field +// 3. Wires proxy settings from appSettings (DB load -> buildProxyAgentFromSettings) + +import { NextResponse } from "next/server" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { authenticate, decodeKey, parseJsonBody } from "@/lib/api-helpers" +import { db } from "@/lib/db" +import { log } from "@/lib/logger" +import { buildProxyAgentFromSettings } from "@/lib/tunnel" +import { POST } from "./route" + +// --------------------------------------------------------------------------- +// Module mocks +// --------------------------------------------------------------------------- + +vi.mock("@/lib/api-helpers", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + authenticate: vi.fn(), + parseJsonBody: vi.fn(), + decodeKey: vi + .fn() + .mockReturnValue( + Buffer.from("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "hex") + ), + validateHttpUrl: vi.fn().mockReturnValue(null), + validateMaxLength: vi.fn().mockReturnValue(null), + } +}) + +vi.mock("@/lib/db", () => ({ + db: { + select: vi.fn(), + }, +})) + +vi.mock("@/lib/db/schema", () => ({ + appSettings: { + proxyEnabled: "proxyEnabled", + proxyType: "proxyType", + proxyHost: "proxyHost", + proxyPort: "proxyPort", + proxyUsername: "proxyUsername", + encryptedProxyPassword: "encryptedProxyPassword", + }, +})) + +vi.mock("@/lib/tunnel", () => ({ + buildProxyAgentFromSettings: vi.fn().mockReturnValue(undefined), +})) + +vi.mock("@/lib/logger", () => ({ + log: { + warn: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }, +})) + +// Mock all adapters via the adapters barrel. The test-connection route calls +// getAdapter(platform).fetchStats(...). We intercept at the adapter level so +// we can control success vs failure per test. +vi.mock("@/lib/adapters", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getAdapter: vi.fn().mockReturnValue({ + fetchStats: vi.fn().mockResolvedValue({ username: "testuser", group: "Member" }), + }), + buildFetchOptions: vi.fn().mockReturnValue({}), + } +}) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const VALID_KEY = "a".repeat(64) + +function makeRequest(body: Record): Request { + return new Request("http://localhost/api/trackers/test-connection", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) +} + +const VALID_BODY = { + baseUrl: "https://avistaz.to", + apiToken: "some-token", + platformType: "unit3d", +} + +function mockDbSettings(settings?: Record | null) { + const row = + settings === null + ? [] + : [ + { + proxyEnabled: false, + proxyType: "socks5", + proxyHost: null, + proxyPort: null, + proxyUsername: null, + encryptedProxyPassword: null, + ...settings, + }, + ] + + const mockLimit = vi.fn().mockResolvedValue(row) + const mockFrom = vi.fn().mockReturnValue({ limit: mockLimit }) + ;(db.select as ReturnType).mockReturnValueOnce({ from: mockFrom }) +} + +// --------------------------------------------------------------------------- +// Auth guards +// --------------------------------------------------------------------------- + +describe("POST /api/trackers/test-connection — auth", () => { + beforeEach(() => { + vi.clearAllMocks() + ;(authenticate as ReturnType).mockResolvedValue({ + encryptionKey: VALID_KEY, + }) + ;(parseJsonBody as ReturnType).mockResolvedValue(VALID_BODY) + }) + + it("returns 401 when not authenticated", async () => { + ;(authenticate as ReturnType).mockResolvedValue( + NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + ) + + const res = await POST(makeRequest(VALID_BODY)) + expect(res.status).toBe(401) + }) +}) + +// --------------------------------------------------------------------------- +// Input validation +// --------------------------------------------------------------------------- + +describe("POST /api/trackers/test-connection — input validation", () => { + beforeEach(() => { + vi.clearAllMocks() + ;(authenticate as ReturnType).mockResolvedValue({ + encryptionKey: VALID_KEY, + }) + }) + + it("returns 400 when baseUrl is missing", async () => { + ;(parseJsonBody as ReturnType).mockResolvedValue({ + apiToken: "some-token", + platformType: "unit3d", + }) + const res = await POST(makeRequest({ apiToken: "some-token" })) + const body = await res.json() + expect(res.status).toBe(400) + expect(body.error).toMatch(/baseUrl.*required|required.*baseUrl/i) + }) + + it("returns 400 when apiToken is missing", async () => { + ;(parseJsonBody as ReturnType).mockResolvedValue({ + baseUrl: "https://avistaz.to", + platformType: "unit3d", + }) + const res = await POST(makeRequest({ baseUrl: "https://avistaz.to" })) + const body = await res.json() + expect(res.status).toBe(400) + expect(body.error).toMatch(/required/) + }) + + it("returns 400 when platformType is not in VALID_PLATFORM_TYPES", async () => { + // validateHttpUrl and validateMaxLength are vi.fn() returning null (pass-through), + // so only the platform check fires here. + ;(parseJsonBody as ReturnType).mockResolvedValue({ + baseUrl: "https://avistaz.to", + apiToken: "tok", + platformType: "notaplatform", + }) + const res = await POST( + makeRequest({ baseUrl: "https://avistaz.to", apiToken: "tok", platformType: "notaplatform" }) + ) + const body = await res.json() + expect(res.status).toBe(400) + expect(body.error).toMatch(/platform/i) + }) +}) + +// --------------------------------------------------------------------------- +// Happy path +// --------------------------------------------------------------------------- + +describe("POST /api/trackers/test-connection — success", () => { + beforeEach(() => { + vi.clearAllMocks() + ;(authenticate as ReturnType).mockResolvedValue({ + encryptionKey: VALID_KEY, + }) + ;(parseJsonBody as ReturnType).mockResolvedValue(VALID_BODY) + }) + + it("returns 200 with success flag, username, and group on happy path", async () => { + mockDbSettings() + + const res = await POST(makeRequest(VALID_BODY)) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.success).toBe(true) + expect(body.username).toBe("testuser") + expect(body.group).toBe("Member") + }) + + it("does not include encryptedApiToken or raw credentials in success response", async () => { + mockDbSettings() + + const res = await POST(makeRequest(VALID_BODY)) + const body = await res.json() + + expect(body).not.toHaveProperty("encryptedApiToken") + expect(body).not.toHaveProperty("apiToken") + expect(body).not.toHaveProperty("encryptedProxyPassword") + }) +}) + +// --------------------------------------------------------------------------- +// Error response — sanitized actual error (change 1 of 3) +// --------------------------------------------------------------------------- + +describe("POST /api/trackers/test-connection — sanitized error response", () => { + beforeEach(() => { + vi.clearAllMocks() + ;(authenticate as ReturnType).mockResolvedValue({ + encryptionKey: VALID_KEY, + }) + ;(parseJsonBody as ReturnType).mockResolvedValue(VALID_BODY) + }) + + it("returns sanitized ECONNREFUSED message instead of generic fallback", async () => { + mockDbSettings() + const { getAdapter } = await import("@/lib/adapters") + ;(getAdapter as ReturnType).mockReturnValueOnce({ + fetchStats: vi + .fn() + .mockRejectedValueOnce(new Error("Failed to connect to avistaz.to: ECONNREFUSED")), + }) + + const res = await POST(makeRequest(VALID_BODY)) + const body = await res.json() + + expect(res.status).toBe(422) + // sanitizeNetworkError maps ECONNREFUSED -> "Connection refused" + expect(body.error).toBe("Connection refused") + expect(body.error).not.toBe("Tracker test failed") + }) + + it("returns sanitized timeout message instead of generic fallback", async () => { + mockDbSettings() + const { getAdapter } = await import("@/lib/adapters") + ;(getAdapter as ReturnType).mockReturnValueOnce({ + fetchStats: vi.fn().mockRejectedValueOnce(new Error("Request to avistaz.to timed out")), + }) + + const res = await POST(makeRequest(VALID_BODY)) + const body = await res.json() + + expect(res.status).toBe(422) + expect(body.error).toBe("Request timed out") + expect(body.error).not.toBe("Tracker test failed") + }) + + it("returns sanitized auth failure message for 401 errors", async () => { + mockDbSettings() + const { getAdapter } = await import("@/lib/adapters") + ;(getAdapter as ReturnType).mockReturnValueOnce({ + fetchStats: vi.fn().mockRejectedValueOnce(new Error("HTTP 401 Unauthorized")), + }) + + const res = await POST(makeRequest(VALID_BODY)) + const body = await res.json() + + expect(res.status).toBe(422) + expect(body.error).toBe("Authentication failed") + }) + + it("returns sanitized proxy error message when error contains 'proxy'", async () => { + // Use a message that only triggers the proxy branch, not an earlier branch. + // sanitizeNetworkError checks ECONNREFUSED before proxy, so use a proxy-only phrase. + mockDbSettings() + const { getAdapter } = await import("@/lib/adapters") + ;(getAdapter as ReturnType).mockReturnValueOnce({ + fetchStats: vi.fn().mockRejectedValueOnce(new Error("Failed to connect via proxy server")), + }) + + const res = await POST(makeRequest(VALID_BODY)) + const body = await res.json() + + expect(res.status).toBe(422) + expect(body.error).toBe("Proxy connection failed") + }) + + it("returns 'Tracker test failed' fallback for truly unrecognized errors", async () => { + mockDbSettings() + const { getAdapter } = await import("@/lib/adapters") + ;(getAdapter as ReturnType).mockReturnValueOnce({ + fetchStats: vi + .fn() + .mockRejectedValueOnce( + new Error("Something completely unknown happened that matches no pattern") + ), + }) + + const res = await POST(makeRequest(VALID_BODY)) + const body = await res.json() + + expect(res.status).toBe(422) + expect(body.error).toBe("Tracker test failed") + }) + + it("does not leak raw error messages or internal details to the client", async () => { + const rawMessage = "ECONNREFUSED 104.21.0.1:443 — secret-internal-ip" + mockDbSettings() + const { getAdapter } = await import("@/lib/adapters") + ;(getAdapter as ReturnType).mockReturnValueOnce({ + fetchStats: vi.fn().mockRejectedValueOnce(new Error(rawMessage)), + }) + + const res = await POST(makeRequest(VALID_BODY)) + const body = await res.json() + + expect(res.status).toBe(422) + expect(body.error).not.toContain("104.21.0.1") + expect(body.error).not.toContain("secret-internal-ip") + }) +}) + +// --------------------------------------------------------------------------- +// Error logging — platform, baseUrl, raw error in log (change 2 of 3) +// --------------------------------------------------------------------------- + +describe("POST /api/trackers/test-connection — error log enrichment", () => { + beforeEach(() => { + vi.clearAllMocks() + ;(authenticate as ReturnType).mockResolvedValue({ + encryptionKey: VALID_KEY, + }) + }) + + it("logs platform in the warn payload when adapter throws", async () => { + ;(parseJsonBody as ReturnType).mockResolvedValue({ + ...VALID_BODY, + platformType: "nebulance", + }) + mockDbSettings() + const { getAdapter } = await import("@/lib/adapters") + ;(getAdapter as ReturnType).mockReturnValueOnce({ + fetchStats: vi.fn().mockRejectedValueOnce(new Error("ECONNREFUSED")), + }) + + await POST(makeRequest({ ...VALID_BODY, platformType: "nebulance" })) + + expect(log.warn).toHaveBeenCalledOnce() + const [payload] = (log.warn as ReturnType).mock.calls[0] + expect(payload).toMatchObject({ platform: "nebulance" }) + }) + + it("logs baseUrl in the warn payload when adapter throws", async () => { + ;(parseJsonBody as ReturnType).mockResolvedValue({ + ...VALID_BODY, + baseUrl: "https://nebulance.io", + }) + mockDbSettings() + const { getAdapter } = await import("@/lib/adapters") + ;(getAdapter as ReturnType).mockReturnValueOnce({ + fetchStats: vi.fn().mockRejectedValueOnce(new Error("ECONNREFUSED")), + }) + + await POST(makeRequest({ ...VALID_BODY, baseUrl: "https://nebulance.io" })) + + const [payload] = (log.warn as ReturnType).mock.calls[0] + expect(payload).toMatchObject({ baseUrl: "https://nebulance.io" }) + }) + + it("logs the raw (unsanitized) error in the warn payload", async () => { + const rawMsg = "Failed to connect to avistaz.to: ECONNREFUSED 104.21.0.1:443" + ;(parseJsonBody as ReturnType).mockResolvedValue(VALID_BODY) + mockDbSettings() + const { getAdapter } = await import("@/lib/adapters") + ;(getAdapter as ReturnType).mockReturnValueOnce({ + fetchStats: vi.fn().mockRejectedValueOnce(new Error(rawMsg)), + }) + + await POST(makeRequest(VALID_BODY)) + + const [payload] = (log.warn as ReturnType).mock.calls[0] + // The raw message (with IP, ECONNREFUSED) must appear in the log, NOT in the response + expect(payload.error).toContain("ECONNREFUSED") + expect(payload.error).toBe(rawMsg) + }) + + it("does not log warn on success", async () => { + ;(parseJsonBody as ReturnType).mockResolvedValue(VALID_BODY) + mockDbSettings() + + await POST(makeRequest(VALID_BODY)) + + expect(log.warn).not.toHaveBeenCalled() + }) +}) + +// --------------------------------------------------------------------------- +// Proxy wiring (change 3 of 3) +// --------------------------------------------------------------------------- + +describe("POST /api/trackers/test-connection — proxy wiring", () => { + beforeEach(() => { + vi.clearAllMocks() + ;(authenticate as ReturnType).mockResolvedValue({ + encryptionKey: VALID_KEY, + }) + ;(parseJsonBody as ReturnType).mockResolvedValue(VALID_BODY) + }) + + it("calls db.select to load proxy settings before running the adapter", async () => { + mockDbSettings() + + await POST(makeRequest(VALID_BODY)) + + expect(db.select).toHaveBeenCalled() + }) + + it("calls buildProxyAgentFromSettings with the fetched settings row", async () => { + const settingsRow = { + proxyEnabled: true, + proxyType: "socks5", + proxyHost: "proxy.internal", + proxyPort: 1080, + proxyUsername: null, + encryptedProxyPassword: null, + } + mockDbSettings(settingsRow) + + await POST(makeRequest(VALID_BODY)) + + expect(buildProxyAgentFromSettings).toHaveBeenCalledOnce() + const [calledSettings] = (buildProxyAgentFromSettings as ReturnType).mock.calls[0] + expect(calledSettings).toMatchObject(settingsRow) + }) + + it("calls buildProxyAgentFromSettings with the decrypted encryption key", async () => { + mockDbSettings() + + await POST(makeRequest(VALID_BODY)) + + expect(decodeKey).toHaveBeenCalled() + expect(buildProxyAgentFromSettings).toHaveBeenCalledOnce() + const [, calledKey] = (buildProxyAgentFromSettings as ReturnType).mock.calls[0] + // decodeKey mock returns a Buffer — verify that Buffer was passed + expect(Buffer.isBuffer(calledKey)).toBe(true) + }) + + it("passes the proxy agent from buildProxyAgentFromSettings into buildFetchOptions", async () => { + const fakeAgent = { isFakeAgent: true } + ;(buildProxyAgentFromSettings as ReturnType).mockReturnValueOnce(fakeAgent) + mockDbSettings({ proxyEnabled: true, proxyHost: "proxy.internal" }) + + const { buildFetchOptions } = await import("@/lib/adapters") + + await POST(makeRequest(VALID_BODY)) + + expect(buildFetchOptions).toHaveBeenCalledOnce() + const [, optsArg] = (buildFetchOptions as ReturnType).mock.calls[0] + expect(optsArg).toMatchObject({ proxyAgent: fakeAgent }) + }) + + it("proceeds without proxy agent when settings row is absent (empty DB)", async () => { + // Simulate no appSettings row at all (fresh install or missing row) + mockDbSettings(null) + + const res = await POST(makeRequest(VALID_BODY)) + const body = await res.json() + + // Should succeed — proxy is optional + expect(res.status).toBe(200) + expect(body.success).toBe(true) + // buildProxyAgentFromSettings should NOT have been called when settings row is absent + expect(buildProxyAgentFromSettings).not.toHaveBeenCalled() + }) + + it("proceeds without proxy when buildProxyAgentFromSettings returns undefined", async () => { + ;(buildProxyAgentFromSettings as ReturnType).mockReturnValueOnce(undefined) + mockDbSettings() + + const res = await POST(makeRequest(VALID_BODY)) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.success).toBe(true) + }) +}) diff --git a/src/app/api/trackers/test/route.ts b/src/app/api/trackers/test/route.ts deleted file mode 100644 index fec70b8f..00000000 --- a/src/app/api/trackers/test/route.ts +++ /dev/null @@ -1,73 +0,0 @@ -// src/app/api/trackers/test/route.ts -import { NextResponse } from "next/server" -import { - buildFetchOptions, - DEFAULT_API_PATHS, - getAdapter, - VALID_PLATFORM_TYPES, -} from "@/lib/adapters" -import { authenticate, parseJsonBody, validateHttpUrl } from "@/lib/api-helpers" -import { log } from "@/lib/logger" - -export async function POST(request: Request) { - const auth = await authenticate() - if (auth instanceof NextResponse) return auth - - const body = await parseJsonBody(request) - if (body instanceof NextResponse) return body - - const { baseUrl, apiToken, platformType, apiPath } = body as { - baseUrl?: string - apiToken?: string - platformType?: string - apiPath?: string - } - - if (!baseUrl || typeof baseUrl !== "string" || !apiToken || typeof apiToken !== "string") { - return NextResponse.json({ error: "baseUrl and apiToken are required" }, { status: 400 }) - } - - const trimmedBaseUrl = baseUrl.trim() - const trimmedApiToken = apiToken.trim() - - if (trimmedApiToken.length > 500) { - return NextResponse.json( - { error: "API token must be 500 characters or fewer" }, - { status: 400 } - ) - } - - if (trimmedBaseUrl.length > 500) { - return NextResponse.json({ error: "URL must be 500 characters or fewer" }, { status: 400 }) - } - - const urlErr = validateHttpUrl(trimmedBaseUrl) - if (urlErr) return urlErr - - const platform = typeof platformType === "string" ? platformType : "unit3d" - if (!VALID_PLATFORM_TYPES.includes(platform as (typeof VALID_PLATFORM_TYPES)[number])) { - return NextResponse.json({ error: "Invalid platform type" }, { status: 400 }) - } - - try { - const adapter = getAdapter(platform) - const defaultPath = DEFAULT_API_PATHS[platform] ?? "/api/user" - const path = typeof apiPath === "string" && apiPath.startsWith("/") ? apiPath : defaultPath - const fetchOptions = buildFetchOptions(trimmedBaseUrl) - const stats = await adapter.fetchStats(trimmedBaseUrl, trimmedApiToken, path, fetchOptions) - return NextResponse.json({ - success: true, - username: stats.username, - group: stats.group, - }) - } catch (error) { - log.warn( - { - route: "POST /api/trackers/test", - error: String(error), - }, - "tracker connection test failed" - ) - return NextResponse.json({ error: "Tracker test failed" }, { status: 422 }) - } -} diff --git a/src/app/api/trackers/tracker-routes.test.ts b/src/app/api/trackers/tracker-routes.test.ts index b933798d..6f6c62f7 100644 --- a/src/app/api/trackers/tracker-routes.test.ts +++ b/src/app/api/trackers/tracker-routes.test.ts @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import { CHART_THEME } from "@/components/charts/lib/theme" import { authenticate, parseJsonBody, parseTrackerId } from "@/lib/api-helpers" import { db } from "@/lib/db" -import { pollTracker } from "@/lib/scheduler" +import { pollTracker } from "@/lib/tracker-scheduler" import { POST as PollPOST } from "./[id]/poll/route" import { GET as RolesGET, POST as RolesPOST } from "./[id]/roles/route" import { DELETE, PATCH } from "./[id]/route" @@ -37,13 +37,13 @@ vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn().mockReturnValue("decrypted-value"), })) -vi.mock("@/lib/proxy", () => ({ +vi.mock("@/lib/tunnel", () => ({ createProxyAgent: vi.fn(), buildProxyAgentFromSettings: vi.fn().mockReturnValue(undefined), VALID_PROXY_TYPES: new Set(["socks5", "http", "https"]), })) -vi.mock("@/lib/scheduler", () => ({ +vi.mock("@/lib/tracker-scheduler", () => ({ pollTracker: vi.fn(), })) @@ -59,6 +59,39 @@ vi.mock("@/lib/privacy-db", () => ({ createPrivacyMaskSync: vi.fn().mockReturnValue((v: string | null | undefined) => v ?? null), })) +vi.mock("@/lib/server-data", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getTrackerForClient: vi.fn().mockResolvedValue({ + id: 1, + name: "Test Tracker", + baseUrl: "https://example.com", + platformType: "unit3d", + isActive: true, + lastPolledAt: null, + lastError: null, + consecutiveFailures: 0, + pausedAt: null, + userPausedAt: null, + color: "#00d4ff", + qbtTag: null, + mouseholeUrl: null, + useProxy: false, + countCrossSeedUnsatisfied: false, + hideUnreadBadges: false, + isFavorite: false, + sortOrder: null, + joinedAt: null, + lastAccessAt: null, + remoteUserId: null, + platformMeta: null, + createdAt: "2024-01-01T00:00:00.000Z", + latestStats: null, + }), + } +}) + const VALID_KEY = "abcd1234".repeat(8) function makeRequest(url: string, body?: Record, method = "GET"): Request { @@ -465,7 +498,7 @@ describe("PATCH /api/trackers/[id]", () => { const data = await response.json() expect(response.status).toBe(200) - expect(data.success).toBe(true) + expect(data.id).toBe(1) }) it("returns 400 when name exceeds 100 characters", async () => { @@ -555,11 +588,17 @@ describe("PATCH /api/trackers/[id]", () => { expect(data.error).toMatch(/color/i) }) - it("returns 400 when API token exceeds 500 characters", async () => { + it("returns 400 when API token exceeds limit for non-avistaz tracker", async () => { ;(parseJsonBody as ReturnType).mockResolvedValue({ apiToken: "t".repeat(501), }) + // Mock the platformType lookup: select → from → where → limit + const mockLimit = vi.fn().mockResolvedValue([{ platformType: "unit3d" }]) + const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit }) + const mockFrom = vi.fn().mockReturnValue({ where: mockWhere }) + ;(db.select as ReturnType).mockReturnValueOnce({ from: mockFrom }) + const request = makeRequest( "http://localhost/api/trackers/1", { apiToken: "t".repeat(501) }, @@ -588,7 +627,7 @@ describe("PATCH /api/trackers/[id]", () => { const data = await response.json() expect(response.status).toBe(200) - expect(data.success).toBe(true) + expect(data.id).toBe(1) }) it("returns 401 when unauthenticated", async () => { @@ -734,20 +773,14 @@ describe("POST /api/trackers/[id]/poll", () => { }) ;(parseTrackerId as ReturnType).mockResolvedValue(1) - // Poll route makes two db.select() calls: - // 1. Cooldown check: select({ lastPolledAt }).from(trackers).where(...).limit(1) - // 2. Settings: select({...}).from(appSettings).limit(1) - let selectCallCount = 0 + // Poll route: + // 1. db.update(trackers).set(...).where(...).returning(...) — atomic cooldown claim + // 2. db.select({...}).from(appSettings).limit(1) — settings query + const mockReturning = vi.fn().mockResolvedValue([{ id: 1 }]) + const mockUpdateWhere = vi.fn().mockReturnValue({ returning: mockReturning }) + const mockSet = vi.fn().mockReturnValue({ where: mockUpdateWhere }) + ;(db.update as ReturnType).mockReturnValue({ set: mockSet }) ;(db.select as ReturnType).mockImplementation(() => { - selectCallCount++ - if (selectCallCount === 1) { - // Cooldown check — return lastPolledAt far enough in the past - const mockLimit = vi.fn().mockResolvedValue([{ lastPolledAt: new Date(0) }]) - const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit }) - const mockFrom = vi.fn().mockReturnValue({ where: mockWhere }) - return { from: mockFrom } - } - // Settings query const mockLimit = vi.fn().mockResolvedValue([ { storeUsernames: true, @@ -801,12 +834,11 @@ describe("POST /api/trackers/[id]/poll", () => { }) it("returns 429 when tracker was polled within cooldown period", async () => { - ;(db.select as ReturnType).mockImplementation(() => { - const mockLimit = vi.fn().mockResolvedValue([{ lastPolledAt: new Date() }]) - const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit }) - const mockFrom = vi.fn().mockReturnValue({ where: mockWhere }) - return { from: mockFrom } - }) + // Atomic claim returns 0 rows = cooldown still active + const mockReturning = vi.fn().mockResolvedValue([]) + const mockUpdateWhere = vi.fn().mockReturnValue({ returning: mockReturning }) + const mockSet = vi.fn().mockReturnValue({ where: mockUpdateWhere }) + ;(db.update as ReturnType).mockReturnValue({ set: mockSet }) const request = makeRequest("http://localhost/api/trackers/1/poll", undefined, "POST") const params = Promise.resolve({ id: "1" }) @@ -850,17 +882,17 @@ describe("GET /api/trackers/[id]/snapshots", () => { }) function buildSnapshotDbMock(result: unknown[]) { - // Call 1: db.select().from(trackerSnapshots).where(...).orderBy(...) + // Call 1: db.selectDistinctOn([bucket], cols).from(...).where(...).orderBy(...) + // (getSnapshotsForTracker uses selectDistinctOn for the default 30-day range) const mockOrderBy = vi.fn().mockResolvedValue(result) const mockWhere = vi.fn().mockReturnValue({ orderBy: mockOrderBy }) const mockFrom = vi.fn().mockReturnValue({ where: mockWhere }) + ;(db.selectDistinctOn as ReturnType).mockReturnValueOnce({ from: mockFrom }) + // Call 2: db.select({storeUsernames}).from(appSettings).limit(1) const mockSettingsLimit = vi.fn().mockResolvedValue([{ storeUsernames: true }]) const mockSettingsFrom = vi.fn().mockReturnValue({ limit: mockSettingsLimit }) - - ;(db.select as ReturnType) - .mockReturnValueOnce({ from: mockFrom }) - .mockReturnValueOnce({ from: mockSettingsFrom }) + ;(db.select as ReturnType).mockReturnValueOnce({ from: mockSettingsFrom }) } it("returns snapshots with default 30 days and serialized bigints", async () => { @@ -905,30 +937,6 @@ describe("GET /api/trackers/[id]/snapshots", () => { expect(response.status).toBe(200) }) - it("clamps days to minimum 1", async () => { - // Smoke test: clamping logic is verified via Math.max(parseInt(...), 1) in source. - // The route returns 200 for any non-negative days value including 0. - buildSnapshotDbMock([]) - - const request = new Request("http://localhost/api/trackers/1/snapshots?days=0") - const params = Promise.resolve({ id: "1" }) - const response = await SnapshotsGET(request, { params }) - - expect(response.status).toBe(200) - }) - - it("clamps days to maximum 3650", async () => { - // Smoke test: clamping logic is verified via Math.min(..., 3650) in source. - // The route returns 200 for any days value, clamped internally. - buildSnapshotDbMock([]) - - const request = new Request("http://localhost/api/trackers/1/snapshots?days=9999") - const params = Promise.resolve({ id: "1" }) - const response = await SnapshotsGET(request, { params }) - - expect(response.status).toBe(200) - }) - it("defaults to 30 days for non-numeric days param", async () => { buildSnapshotDbMock([]) diff --git a/src/app/api/upload-image/route.ts b/src/app/api/upload-image/route.ts index 86d84d02..e0364764 100644 --- a/src/app/api/upload-image/route.ts +++ b/src/app/api/upload-image/route.ts @@ -9,9 +9,9 @@ import { db } from "@/lib/db" import { appSettings } from "@/lib/db/schema" import type { ImageHostId } from "@/lib/image-hosting" import { getImageHostAdapter } from "@/lib/image-hosting" +import { IMAGE_EXPIRATION_MAX, UPLOAD_IMAGE_MAX_BYTES } from "@/lib/limits" import { log } from "@/lib/logger" -const MAX_FILE_SIZE = 32 * 1024 * 1024 // 32 MB (ImgBB limit, lowest common denominator) const VALID_HOSTS = new Set(["ptpimg", "onlyimage", "imgbb"]) const VALID_MIME_TYPES = [ "image/jpeg", @@ -70,9 +70,9 @@ export async function POST(request: Request) { if (file.size === 0) { return NextResponse.json({ error: "image file is empty" }, { status: 400 }) } - if (file.size > MAX_FILE_SIZE) { + if (file.size > UPLOAD_IMAGE_MAX_BYTES) { return NextResponse.json( - { error: `File too large (max ${MAX_FILE_SIZE / 1024 / 1024} MB)` }, + { error: `File too large (max ${UPLOAD_IMAGE_MAX_BYTES / 1024 / 1024} MB)` }, { status: 400 } ) } @@ -88,7 +88,7 @@ export async function POST(request: Request) { const expirationRaw = formData.get("expiration") if (expirationRaw) { const parsed = Number(expirationRaw) - if (!Number.isFinite(parsed) || parsed < 0 || parsed > 31_536_000) { + if (!Number.isFinite(parsed) || parsed < 0 || parsed > IMAGE_EXPIRATION_MAX) { return NextResponse.json( { error: "expiration must be a positive number of seconds (max 31536000)" }, { status: 400 } diff --git a/src/app/globals.css b/src/app/globals.css index 3cff1f8b..bf9cddc8 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -107,6 +107,14 @@ --radius-lg: 20px; --radius-xl: 24px; --radius-pill: 9999px; + + /* Sub-xs font sizes for dense data UI. + * text-3xs and text-2xs are intentionally the same value today (10px). + * text-2xs covers former 11px contexts (table cells, pills). + * If dense tables feel too tight, bump --text-2xs to 11px — one-line change. */ + --text-4xs: 9px; + --text-3xs: 10px; + --text-2xs: 10px; } /* Custom radius utilities matching design system tokens */ @@ -174,13 +182,165 @@ /* #14151b */ inset -2px -2px 5px oklch(32.94% 0.0266 278.88); /* #323443 */ } +/* Tracker color accent glow — corner-pooling inset glow via pseudo-element + * so it doesn't conflict with the card's nm-raised box-shadow. Four offset + * shadows concentrate light in the corners and fade along the edges. + * Color passed as --card-accent via inline style. */ +@utility card-accent { + position: relative; + &::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + box-shadow: + inset 5px 5px 14px -5px var(--card-accent, transparent), + inset -3px -3px 14px -8px var(--card-accent, transparent), + inset 1px 1px 2px 0 rgba(255, 255, 255, 0.05), + inset -1px -1px 2px 0 rgba(255, 255, 255, 0.015); + pointer-events: none; + } +} + @utility slot-label { font-family: var(--font-sans); - font-size: 10px; + font-size: var(--text-3xs); font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; - color: var(--color-text-tertiary); + color: var(--color-tertiary); +} + +/* ─── Typography utilities ─── */ + +@utility card-heading { + font-size: var(--text-sm); + font-family: var(--font-sans); + font-weight: 600; + color: var(--color-primary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +@utility timestamp { + font-size: var(--text-3xs); + font-family: var(--font-mono); + color: var(--color-muted); +} + +@utility tabular-cell { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + color: var(--color-secondary); + font-size: var(--text-xs); +} + +@utility torrent-cell { + font-size: var(--text-2xs); + font-family: var(--font-mono); + color: var(--color-muted); +} + +@utility ghost-link { + font-size: var(--text-xs); + font-family: var(--font-mono); + color: var(--color-tertiary); + cursor: pointer; + transition: color 150ms; + &:hover { + color: var(--color-secondary); + } +} + +@utility description-text { + font-size: var(--text-xs); + font-family: var(--font-sans); + color: var(--color-tertiary); + line-height: 1.625; +} + +@utility danger-confirm-text { + font-size: var(--text-sm); + font-family: var(--font-sans); + color: var(--color-primary); + line-height: 1.625; +} + +@utility progress-footnote { + font-size: var(--text-3xs); + font-family: var(--font-mono); + color: var(--color-muted); + text-align: right; +} + +/* ─── Native dialog backdrop ─── */ + +dialog[data-overlay]::backdrop { + background: rgb(0 0 0 / 0.6); + opacity: 0; + transition: opacity 150ms ease-in; +} + +dialog[data-overlay][data-visible]::backdrop { + opacity: 1; + transition: opacity 200ms ease-out; +} + +/* ─── Interactive utilities ─── */ + +@utility help-icon { + cursor: help; + font-size: var(--text-4xs); + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; + width: 0.875rem; + height: 0.875rem; + border-radius: 9999px; + border: 1px solid currentColor; +} + +@utility tooltip-icon { + color: var(--color-muted); + cursor: help; + font-size: var(--text-sm); + transition: color 150ms; + &:hover { + color: var(--color-secondary); + } +} + +/* ─── Structural utilities ─── */ + +@utility color-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 9999px; + flex-shrink: 0; +} + +@utility form-responsive-row { + display: flex; + flex-direction: column; + gap: 1rem; + @media (min-width: 640px) { + flex-direction: row; + align-items: flex-end; + } +} + +@utility full-page-loader { + display: flex; + height: 100%; + min-height: calc(100vh - 6rem); + align-items: center; + justify-content: center; +} + +@utility lazy-card { + content-visibility: auto; + contain-intrinsic-size: 0 320px; } /* @@ -297,7 +457,7 @@ animation-play-state: paused; } -/* Carousel slide animations — used by ClientStatusWidget */ +/* Carousel slide animations — used by DownloadClientStatusWidget */ @keyframes slideInLeft { from { opacity: 0; @@ -430,7 +590,7 @@ select { background: oklch(40.24% 0.0439 279.12); /* #434660 */ } -/* react-colorful overrides — scoped under wrapper for specificity without !important */ +/* react-colorful overrides */ .color-picker-wrapper .react-colorful { width: 100%; height: 160px; @@ -453,7 +613,7 @@ select { box-shadow: 0 0 4px oklch(0% 0 0 / 0.4); /* #000000 40% */ } -/* Emoji picker theme overrides — scoped under wrapper for specificity without !important */ +/* Emoji picker theme overrides */ .emoji-picker-wrapper .EmojiPickerReact.epr-dark-theme { --epr-bg-color: oklch(31.52% 0.0323 279.44); /* #2e3042 */ --epr-category-label-bg-color: oklch(31.52% 0.0323 279.44); /* #2e3042 */ diff --git a/src/app/login/LoginForm.tsx b/src/app/login/LoginForm.tsx index f806dd6a..d4e45ff5 100644 --- a/src/app/login/LoginForm.tsx +++ b/src/app/login/LoginForm.tsx @@ -39,19 +39,20 @@ export function LoginForm({ hasUsername }: { hasUsername: boolean }) { if (!res.ok) { setError(data.error ?? "Login failed. Please try again.") + setIsSubmitting(false) return } if (data.requiresTotp) { setPendingToken(data.pendingToken) setStep("totp") + setIsSubmitting(false) return } router.push("/") } catch { setError("An unexpected error occurred. Please try again.") - } finally { setIsSubmitting(false) } } @@ -79,13 +80,13 @@ export function LoginForm({ hasUsername }: { hasUsername: boolean }) { if (!res.ok) { setError(data.error ?? "Verification failed.") + setIsSubmitting(false) return } router.push("/") } catch { setError("An unexpected error occurred. Please try again.") - } finally { setIsSubmitting(false) } } @@ -108,10 +109,11 @@ export function LoginForm({ hasUsername }: { hasUsername: boolean }) { alt="Tracker Tracker" width={160} height={40} - className="h-10 w-auto mx-auto" + className="mx-auto" + style={{ height: 40, width: "auto" }} priority /> -

+

{step === "password" ? "Enter your credentials to unlock." : "Enter the code from your authenticator app."} @@ -152,9 +154,8 @@ export function LoginForm({ hasUsername }: { hasUsername: boolean }) { size="md" className="w-full mt-1" disabled={isSubmitting} - > - {isSubmitting ? "Unlocking…" : "Unlock"} - + text={isSubmitting ? "Unlocking…" : "Unlock"} + /> )} @@ -198,28 +199,25 @@ export function LoginForm({ hasUsername }: { hasUsername: boolean }) { size="md" className="w-full" disabled={isSubmitting} - > - {isSubmitting ? "Verifying…" : "Verify"} - + text={isSubmitting ? "Verifying…" : "Verify"} + />

- - + text={showBackupInput ? "Use authenticator" : "Use backup code"} + />
)} diff --git a/src/app/setup/SetupForm.tsx b/src/app/setup/SetupForm.tsx index 7791eda1..7787872f 100644 --- a/src/app/setup/SetupForm.tsx +++ b/src/app/setup/SetupForm.tsx @@ -5,39 +5,46 @@ import { H2 } from "@typography" import Image from "next/image" import { useRouter } from "next/navigation" import { type SubmitEvent, useState } from "react" -import { Button, Card, Input } from "@/components/ui" +import { Button, Card, Input, Toggle } from "@/components/ui" +import { Notice } from "@/components/ui/Notice" +import { SNAPSHOT_RETENTION_MAX, SNAPSHOT_RETENTION_MIN } from "@/lib/limits" export function SetupForm() { const router = useRouter() const [username, setUsername] = useState("") const [password, setPassword] = useState("") const [confirmPassword, setConfirmPassword] = useState("") - const [error, setError] = useState(null) + const [retentionEnabled, setRetentionEnabled] = useState(false) + const [retentionDays, setRetentionDays] = useState(365) + const [errors, setErrors] = useState>({}) const [isSubmitting, setIsSubmitting] = useState(false) async function handleSubmit(e: SubmitEvent) { e.preventDefault() - setError(null) + setErrors({}) if (!username.trim() || username.trim().length < 3) { - setError("Username must be at least 3 characters.") + setErrors({ username: "Username must be at least 3 characters." }) return } if (password.length < 8) { - setError("Password must be at least 8 characters.") + setErrors({ password: "Password must be at least 8 characters." }) return } if (password !== confirmPassword) { - setError("Passwords do not match.") + setErrors({ confirmPassword: "Passwords do not match." }) return } setIsSubmitting(true) try { - const payload: Record = { password, username: username.trim() } + const payload: Record = { password, username: username.trim() } + if (retentionEnabled && retentionDays > 0) { + payload.snapshotRetentionDays = retentionDays + } const setupRes = await fetch("/api/auth/setup", { method: "POST", @@ -47,7 +54,17 @@ export function SetupForm() { if (!setupRes.ok) { const data = (await setupRes.json()) as { error?: string } - setError(data.error ?? "Setup failed. Please try again.") + const msg = data.error ?? "Setup failed. Please try again." + const lowerMsg = msg.toLowerCase() + if (lowerMsg.includes("username")) { + setErrors({ username: msg }) + } else if (lowerMsg.includes("match")) { + setErrors({ confirmPassword: msg }) + } else if (lowerMsg.includes("8 char") || lowerMsg.includes("password")) { + setErrors({ password: msg }) + } else { + setErrors({ form: msg }) + } return } @@ -59,13 +76,13 @@ export function SetupForm() { if (!loginRes.ok) { const data = (await loginRes.json()) as { error?: string } - setError(data.error ?? "Login after setup failed. Please go to the login page.") + setErrors({ form: data.error ?? "Login after setup failed. Please go to the login page." }) return } router.push("/") } catch { - setError("An unexpected error occurred. Please try again.") + setErrors({ form: "An unexpected error occurred. Please try again." }) } finally { setIsSubmitting(false) } @@ -80,7 +97,8 @@ export function SetupForm() { alt="Tracker Tracker" width={160} height={40} - className="h-10 w-auto mx-auto" + className="mx-auto" + style={{ height: 40, width: "auto" }} priority />

Create an account

@@ -96,7 +114,7 @@ export function SetupForm() { required value={username} onChange={(e) => setUsername(e.target.value)} - error={error?.toLowerCase().includes("username") ? error : undefined} + error={errors.username} disabled={isSubmitting} /> @@ -107,7 +125,7 @@ export function SetupForm() { placeholder="Min. 8 characters" value={password} onChange={(e) => setPassword(e.target.value)} - error={error?.toLowerCase().includes("8 char") ? error : undefined} + error={errors.password} disabled={isSubmitting} required /> @@ -119,19 +137,45 @@ export function SetupForm() { placeholder="Re-enter password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} - error={error?.toLowerCase().includes("match") ? error : undefined} + error={errors.confirmPassword} disabled={isSubmitting} required /> - {error && - !error?.toLowerCase().includes("8 char") && - !error?.toLowerCase().includes("match") && - !error?.toLowerCase().includes("username") && ( -

- {error} -

+
+ + {retentionEnabled && ( + + setRetentionDays( + Math.max( + SNAPSHOT_RETENTION_MIN, + Math.min(SNAPSHOT_RETENTION_MAX, Number(e.target.value) || 365) + ) + ) + } + disabled={isSubmitting} + className="mt-3" + /> )} +
+ + + text={isSubmitting ? "Setting up…" : "Create Account"} + />
diff --git a/src/components/AddTrackerDialog.tsx b/src/components/AddTrackerDialog.tsx index 57bd114f..414d3d00 100644 --- a/src/components/AddTrackerDialog.tsx +++ b/src/components/AddTrackerDialog.tsx @@ -1,36 +1,30 @@ // src/components/AddTrackerDialog.tsx "use client" -// -// Functions: AddTrackerDialog - import { H2 } from "@typography" import clsx from "clsx" import Image from "next/image" -import { - type KeyboardEvent, - type MouseEvent, - type SyntheticEvent, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react" +import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react" import { CHART_THEME } from "@/components/charts/lib/theme" +import { AreaInput } from "@/components/ui/AreaInput" import { Button } from "@/components/ui/Button" import { ColorPicker } from "@/components/ui/ColorPicker" +import { Dialog } from "@/components/ui/Dialog" import { TriangleWarningIcon } from "@/components/ui/Icons" +import { InfoTip } from "@/components/ui/InfoTip" import { Input } from "@/components/ui/Input" +import { Notice } from "@/components/ui/Notice" import { QbtTagWarning } from "@/components/ui/QbtTagWarning" import { Tooltip } from "@/components/ui/Tooltip" import type { TrackerRegistryEntry } from "@/data/tracker-registry" import { TRACKER_REGISTRY } from "@/data/tracker-registry" import { useClickOutside } from "@/hooks/useClickOutside" -import { normalizeUrl } from "@/lib/url" +import { DOCS } from "@/lib/constants" +import { normalizeUrl } from "@/lib/data-transforms" +import { localDateStr } from "@/lib/formatters" // --------------------------------------------------------------------------- -// Fuzzy match — matches if all query chars appear in order in the target +// Fuzzy match // --------------------------------------------------------------------------- function fuzzyMatch(query: string, target: string): boolean { @@ -44,7 +38,7 @@ function fuzzyMatch(query: string, target: string): boolean { } // --------------------------------------------------------------------------- -// TrackerCombobox — searchable dropdown for tracker selection +// TrackerCombobox // --------------------------------------------------------------------------- interface TrackerComboboxProps { @@ -71,10 +65,8 @@ function TrackerCombobox({ presets, value, onChange }: TrackerComboboxProps) { ) : presets - // Close on outside click useClickOutside(ref, () => setOpen(false), open) - // Scroll highlighted item into view useEffect(() => { if (!open || !listRef.current) return const item = listRef.current.children[highlightIndex] as HTMLElement | undefined @@ -160,7 +152,7 @@ function TrackerCombobox({ presets, value, onChange }: TrackerComboboxProps) { ✕ )} -
@@ -168,7 +160,7 @@ function TrackerCombobox({ presets, value, onChange }: TrackerComboboxProps) { {open && (
{filtered.length === 0 ? ( @@ -203,11 +195,8 @@ function TrackerCombobox({ presets, value, onChange }: TrackerComboboxProps) { {entry.name} {entry.warning && ( - + - {entry.warningNote && ( - {entry.warningNote} - )} )} @@ -240,12 +229,13 @@ function AddTrackerDialog({ onAdded, existingBaseUrls = [], }: AddTrackerDialogProps) { - const dialogRef = useRef(null) - const [selectedPreset, setSelectedPreset] = useState("") const [nickname, setNickname] = useState("") const [baseUrl, setBaseUrl] = useState("") const [apiToken, setApiToken] = useState("") + const [avistazUsername, setAvistazUsername] = useState("") + const [avistazCookies, setAvistazCookies] = useState("") + const [dcCookies, setDcCookies] = useState("") const [qbtTag, setQbtTag] = useState("") const [mouseholeUrl, setMouseholeUrl] = useState("") const [color, setColor] = useState(CHART_THEME.accent) @@ -259,6 +249,9 @@ function AddTrackerDialog({ setNickname("") setBaseUrl("") setApiToken("") + setAvistazUsername("") + setAvistazCookies("") + setDcCookies("") setQbtTag("") setMouseholeUrl("") setColor(CHART_THEME.accent) @@ -268,43 +261,10 @@ function AddTrackerDialog({ setTestResult(null) }, []) - useEffect(() => { - const dialog = dialogRef.current - if (!dialog) return - - if (open) { - dialog.showModal() - } else { - dialog.close() - } - }, [open]) - - useEffect(() => { - const dialog = dialogRef.current - if (!dialog) return - - function handleNativeClose() { - resetForm() - onClose() - } - - dialog.addEventListener("close", handleNativeClose) - return () => { - dialog.removeEventListener("close", handleNativeClose) - } - }, [onClose, resetForm]) - - function handleBackdropClick(e: MouseEvent) { - if (e.target === dialogRef.current) { - dialogRef.current?.close() - } - } - - function handleDialogKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") { - dialogRef.current?.close() - } - } + const handleDialogClose = useCallback(() => { + resetForm() + onClose() + }, [resetForm, onClose]) function handlePresetChange(slug: string) { setSelectedPreset(slug) @@ -318,6 +278,9 @@ function AddTrackerDialog({ setBaseUrl("") setColor(CHART_THEME.accent) } + setAvistazUsername("") + setAvistazCookies("") + setDcCookies("") } const availablePresets = useMemo(() => { @@ -331,28 +294,49 @@ function AddTrackerDialog({ function validate(): Record { const next: Record = {} - if (!selectedPreset && !baseUrl.trim()) { - next.preset = "Select a tracker or enter a Base URL" + if (!selectedPreset) { + next.preset = "Select a tracker" } - if (!baseUrl.trim()) { - next.baseUrl = "Base URL is required" - } else { - try { - new URL(baseUrl) - } catch { - next.baseUrl = "Invalid URL format" + + if (selectedEntry?.platform === "avistaz") { + if (!avistazUsername.trim()) { + next.apiToken = "Username is required" + } else if (!avistazCookies.trim()) { + next.apiToken = "Browser cookies are required" + } else if (!avistazCookies.includes("=")) { + next.apiToken = + "This doesn't look like a cookie string — it should contain key=value pairs (i.e. cf_clearance=abc123; session=xyz)" + } else if ( + /^(cf_clearance|[a-z]+x_session|remember_web_\w+|XSRF-TOKEN|love)$/i.test( + avistazCookies.trim() + ) + ) { + next.apiToken = + 'You pasted a cookie name, not the value. Copy the entire string after "Cookie:" in the request headers.' } - } - if (!apiToken.trim()) { + } else if (selectedEntry?.platform === "digitalcore") { + const trimmed = dcCookies.trim() + if (!trimmed) { + next.apiToken = "Session cookies are required" + } else { + const uidMatch = trimmed.match(/(?:^|;\s*)uid=([^;]+)/) + const passMatch = trimmed.match(/(?:^|;\s*)pass=([^;]+)/) + if (!uidMatch) { + next.apiToken = + "Cookie string is missing uid value. Paste the full Cookie header from DevTools." + } else if (!passMatch) { + next.apiToken = + "Cookie string is missing pass value. Paste the full Cookie header from DevTools." + } + } + } else if (!apiToken.trim()) { next.apiToken = "API token is required" } return next } - async function handleSubmit(e: SyntheticEvent) { - e.preventDefault() - + async function handleSubmit() { const validationErrors = validate() if (Object.keys(validationErrors).length > 0) { setErrors(validationErrors) @@ -363,13 +347,33 @@ function AddTrackerDialog({ setLoading(true) setTestResult(null) + const isAvistaz = selectedEntry?.platform === "avistaz" + const isDigitalCore = selectedEntry?.platform === "digitalcore" + let effectiveApiToken = apiToken + + if (isAvistaz) { + effectiveApiToken = JSON.stringify({ + cookies: avistazCookies.trim(), + userAgent: navigator.userAgent, + username: avistazUsername.trim(), + }) + } else if (isDigitalCore) { + const trimmed = dcCookies.trim() + const uidMatch = trimmed.match(/(?:^|;\s*)uid=([^;]+)/) + const passMatch = trimmed.match(/(?:^|;\s*)pass=([^;]+)/) + effectiveApiToken = JSON.stringify({ + uid: uidMatch?.[1]?.trim() ?? "", + pass: passMatch?.[1]?.trim() ?? "", + }) + } + try { - const testRes = await fetch("/api/trackers/test", { + const testRes = await fetch("/api/trackers/test-connection", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ baseUrl, - apiToken, + apiToken: effectiveApiToken, platformType: selectedEntry?.platform ?? "unit3d", apiPath: selectedEntry?.apiPath, }), @@ -379,19 +383,26 @@ function AddTrackerDialog({ if (!testRes.ok) { setErrors({ apiToken: testData.error ?? "Connection failed" }) - setLoading(false) return } setTestResult({ username: testData.username, group: testData.group }) + if (isAvistaz && testData.capturedUserAgent) { + effectiveApiToken = JSON.stringify({ + cookies: avistazCookies.trim(), + userAgent: testData.capturedUserAgent, + username: avistazUsername.trim(), + }) + } + const saveRes = await fetch("/api/trackers", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: trackerName, baseUrl, - apiToken, + apiToken: effectiveApiToken, platformType: selectedEntry?.platform ?? "unit3d", color, qbtTag: qbtTag.trim() || undefined, @@ -404,7 +415,6 @@ function AddTrackerDialog({ if (!saveRes.ok) { setErrors({ form: saveData.error ?? "Failed to add tracker" }) - setLoading(false) return } @@ -415,75 +425,151 @@ function AddTrackerDialog({ onAdded(saveData.id) } catch { setErrors({ form: "Network error — please try again" }) + } finally { setLoading(false) } } return ( - + } + title="Add Tracker" + maxWidth="max-w-lg" + busy={loading} + footer={ +
+
+ } > -
- {/* Header */} -
-

Add Tracker

- +
+
+

Tracker

+ +
- {/* Form */} -
+ )} + + {selectedEntry?.platform === "avistaz" && ( + + )} + + + value={nickname} + onChange={(e) => setNickname(e.target.value)} + placeholder={selectedEntry?.name ?? "Custom name for this tracker"} + /> + + {selectedEntry?.platform === "avistaz" ? ( +
+ setAvistazUsername(e.target.value)} + placeholder="Your username on this tracker" + /> +
+
+ + +
+ setAvistazCookies(e.target.value)} + placeholder="F12 → Network → any request → Cookie header → right-click → Copy Value" + rows={3} + /> + {avistazCookies.includes("\u2026") && ( + + Cookie string appears truncated (contains "…"). Firefox truncates + long values in the display. Right-click the Cookie header and select{" "} + Copy Value instead. + + )} + +
+ {testResult && ( + + Connected as {testResult.username} + {testResult.group ? ` (${testResult.group})` : ""} + + )} +
+ ) : selectedEntry?.platform === "digitalcore" ? (
- - Tracker - - + + +
+ setDcCookies(e.target.value)} + placeholder="uid=56954; pass=abc123def456..." + rows={2} /> - {errors.preset && ( -

- {errors.preset} -

+ + {testResult && ( + + Connected as {testResult.username} + {testResult.group ? ` (${testResult.group})` : ""} + )}
- - setNickname(e.target.value)} - placeholder={selectedEntry?.name ?? "Custom name for this tracker"} - /> - - setBaseUrl(e.target.value)} - placeholder="https://aither.cc" - error={errors.baseUrl} - /> - + ) : (
{testResult && ( -

+ Connected as {testResult.username} {testResult.group ? ` (${testResult.group})` : ""} -

+ )}
+ )} -
- setQbtTag(e.target.value)} - placeholder={selectedEntry ? `i.e, ${selectedEntry.slug}` : "i.e, tracker-name"} - /> - -
+
+ setQbtTag(e.target.value)} + placeholder={selectedEntry ? `i.e, ${selectedEntry.slug}` : "i.e, tracker-name"} + /> + +
- {selectedEntry?.platform === "mam" && ( -
-
- setMouseholeUrl(e.target.value)} - placeholder="http://localhost:7001" - /> - - - ⓘ - - -
+ {selectedEntry?.platform === "mam" && ( +
+
+ setMouseholeUrl(e.target.value)} + placeholder="http://localhost:7001" + /> +
- )} +
+ )} - + - {!(selectedEntry?.gazelleEnrich || selectedEntry?.platform === "ggn") && ( + {selectedEntry && + !selectedEntry.gazelleEnrich && + selectedEntry.platform !== "ggn" && + selectedEntry.platform !== "avistaz" && + selectedEntry.platform !== "digitalcore" && ( setJoinedAt(e.target.value)} placeholder="YYYY-MM-DD" /> )} - {errors.form && ( -

- {errors.form} -

- )} - - {/* Footer */} -
- - -
- +
-
+ ) } diff --git a/src/components/DownloadClients.tsx b/src/components/DownloadClients.tsx index 6be77475..f221ab36 100644 --- a/src/components/DownloadClients.tsx +++ b/src/components/DownloadClients.tsx @@ -1,47 +1,41 @@ // src/components/DownloadClients.tsx -// -// Functions: DownloadClients, ClientCard - "use client" -import { H3, Paragraph, Subheader, Subtext } from "@typography" -import { useCallback, useEffect, useState } from "react" -import { Badge } from "@/components/ui/Badge" -import { Button } from "@/components/ui/Button" -import { Card } from "@/components/ui/Card" -import { CollapsibleCard } from "@/components/ui/CollapsibleCard" -import { Input } from "@/components/ui/Input" -import { MaskedSecret } from "@/components/ui/MaskedSecret" -import { NumberInput } from "@/components/ui/NumberInput" -import { Select } from "@/components/ui/Select" -import { Toggle } from "@/components/ui/Toggle" -import { UptimeBar } from "@/components/ui/UptimeBar" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { H2, H3, Paragraph, Subheader, Subtext } from "@typography" +import { useCallback, useState } from "react" +import { + Badge, + Button, + Card, + CardListSkeleton, + CollapsibleCard, + ConfirmRemove, + Input, + MaskedSecret, + Notice, + NumberInput, + SaveDiscardBar, + Select, + Toggle, + UptimeBar, +} from "@/components/ui" + +import { useActionStatus } from "@/hooks/useActionStatus" +import { useCrudCard } from "@/hooks/useCrudCard" import { formatTimeAgo } from "@/lib/formatters" +import { PORT_MAX, PORT_MIN } from "@/lib/limits" +import { clientQueryOptions } from "@/lib/query-options" +import type { SafeDownloadClient } from "@/types/api" // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- type ClientType = "qbittorrent" | "deluge" | "transmission" | "rtorrent" +type DownloadClient = SafeDownloadClient -interface DownloadClient { - id: number - name: string - type: ClientType - enabled: boolean - host: string - port: number - useSsl: boolean - hasCredentials: boolean - pollIntervalSeconds: number - isDefault: boolean - crossSeedTags: string[] - lastPolledAt: string | null - lastError: string | null - errorSince: string | null -} - -type ConnectionStatus = "idle" | "testing" | "success" | "failed" +const EMPTY_TRACKERS: string[] = [] const CLIENT_TYPE_OPTIONS: { value: ClientType; label: string; disabled?: boolean }[] = [ { value: "qbittorrent", label: "qBittorrent" }, @@ -74,122 +68,69 @@ const DRAFT_KEYS: (keyof DownloadClient)[] = [ "crossSeedTags", ] -function isDirty(draft: DownloadClient, saved: DownloadClient): boolean { +function buildPatch(draft: DownloadClient, saved: DownloadClient): Record | null { + const patch: Record = {} for (const key of DRAFT_KEYS) { const a = draft[key] const b = saved[key] if (Array.isArray(a) && Array.isArray(b)) { - if (a.length !== b.length || a.some((v, i) => v !== b[i])) return true - } else if (a !== b) return true + if (a.length !== b.length || a.some((v, i) => v !== b[i])) patch[key] = a + } else if (a !== b) patch[key] = a } - return false + return Object.keys(patch).length > 0 ? patch : null } function ClientCard({ client, linkedTrackers, onSaved, onRemove, onSetDefault }: ClientCardProps) { - const [draft, setDraft] = useState(client) - const [saving, setSaving] = useState(false) - const [saveError, setSaveError] = useState(null) - const [expanded, setExpanded] = useState(false) - const [connectionStatus, setConnectionStatus] = useState("idle") - const [connectionError, setConnectionError] = useState(null) - const [confirmRemove, setConfirmRemove] = useState(false) + const { + draft, + updateDraft, + dirty, + saving, + saveError, + expanded, + toggleExpand, + handleSave, + handleDiscard, + } = useCrudCard({ + item: client, + apiEndpoint: "/api/clients", + buildPatch, + onSaved, + }) + const { + status: connectionStatus, + error: connectionError, + execute: executeTest, + } = useActionStatus() const [tagInput, setTagInput] = useState("") - const dirty = isDirty(draft, client) - - // Sync draft when parent pushes new server state (i.e after another card's setDefault) - useEffect(() => { - if (!dirty) setDraft(client) - }, [client, dirty]) - - function updateDraft(patch: Partial) { - setDraft((prev) => ({ ...prev, ...patch })) - } - - async function handleSave() { - setSaving(true) - setSaveError(null) - const patch: Record = {} - for (const key of DRAFT_KEYS) { - const a = draft[key] - const b = client[key] - if (Array.isArray(a) && Array.isArray(b)) { - if (a.length !== b.length || a.some((v, i) => v !== b[i])) patch[key] = a - } else if (a !== b) patch[key] = a - } - try { - const res = await fetch(`/api/clients/${client.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(patch), - }) - if (!res.ok) { - const data = await res.json().catch(() => ({})) - setSaveError(data.error || "Failed to save") - return - } - onSaved(client.id, draft) - } catch { - setSaveError("Network error") - } finally { - setSaving(false) - } - } - - function handleDiscard() { - setDraft(client) - setSaveError(null) - } - // Credential change state — show inputs for new clients (no creds yet) or after "Change" const [changingCredentials, setChangingCredentials] = useState(!client.hasCredentials) const [newUsername, setNewUsername] = useState("") const [newPassword, setNewPassword] = useState("") const [credError, setCredError] = useState(null) - const [uptimeData, setUptimeData] = useState<{ - buckets: { bucketTs: string; ok: number; fail: number }[] - uptimePercent: number | null - } | null>(null) - - useEffect(() => { - let cancelled = false - async function fetchUptime() { - try { - const res = await fetch(`/api/clients/${client.id}/uptime`) - if (!res.ok || cancelled) return - const data = await res.json() - if (!cancelled) setUptimeData(data) - } catch { - // uptime bar is non-critical - } - } - fetchUptime() - const interval = setInterval(fetchUptime, 5 * 60 * 1000) - return () => { - cancelled = true - clearInterval(interval) - } - }, [client.id]) - - const handleTestConnection = useCallback(async () => { - setConnectionStatus("testing") - setConnectionError(null) - try { + const { data: uptimeData = null } = useQuery({ + queryKey: ["client-uptime", client.id], + queryFn: async ({ signal }) => { + const res = await fetch(`/api/clients/${client.id}/uptime`, { signal }) + if (!res.ok) return null + return res.json() as Promise<{ + buckets: { bucketTs: string; ok: number; fail: number }[] + uptimePercent: number | null + }> + }, + refetchInterval: 5 * 60 * 1000, + }) + + const handleTestConnection = () => + executeTest(async () => { const res = await fetch(`/api/clients/${client.id}/test`, { method: "POST" }) - if (res.ok) { - setConnectionStatus("success") - setTimeout(() => setConnectionStatus("idle"), 3000) - } else { + if (!res.ok) { const data = await res.json().catch(() => ({})) - setConnectionError(data.error || "Connection failed") - setConnectionStatus("failed") + throw new Error(data.error || "Connection failed") } - } catch { - setConnectionError("Network error — could not reach server") - setConnectionStatus("failed") - } - }, [client.id]) + }) async function handleSaveCredentials() { if (!newUsername.trim() || !newPassword.trim()) return @@ -223,7 +164,7 @@ function ClientCard({ client, linkedTrackers, onSaved, onRemove, onSetDefault }: return ( setExpanded((e) => !e)} + onToggle={toggleExpand} header={
@@ -232,18 +173,18 @@ function ClientCard({ client, linkedTrackers, onSaved, onRemove, onSetDefault }: {client.isDefault && Default}
- + {client.lastPolledAt ? `Last seen: ${formatTimeAgo(client.lastPolledAt)}` : "Last seen: Never"} {client.errorSince && ( - + Down since {formatTimeAgo(client.errorSince)} )} {client.lastError && !client.errorSince && ( - {client.lastError} + {client.lastError} )}
@@ -258,7 +199,7 @@ function ClientCard({ client, linkedTrackers, onSaved, onRemove, onSetDefault }: } > {/* Row 1: Name + Type + Enabled */} -
+
{/* Row 2: Host + Port + SSL */} -
+
updateDraft({ port: v })} - min={1} - max={65535} + min={PORT_MIN} + max={PORT_MAX} />
@@ -315,23 +256,23 @@ function ClientCard({ client, linkedTrackers, onSaved, onRemove, onSetDefault }: /> {draft.useSsl && draft.port === 80 && ( -

- SSL is enabled but port is 80 (standard HTTP). Did you mean port 443? -

+ )} {!draft.useSsl && draft.port === 443 && ( -

- Port 443 is typically used with SSL. Did you mean to enable SSL? -

+ )}
{/* Row 3: Auth */}
- - Credentials - +

Credentials

{changingCredentials ? (
@@ -365,9 +306,8 @@ function ClientCard({ client, linkedTrackers, onSaved, onRemove, onSetDefault }: variant="primary" onClick={handleSaveCredentials} disabled={!newUsername.trim() || !newPassword.trim()} - > - Save Credentials - + text="Save Credentials" + /> {client.hasCredentials && ( )}
- {credError &&

{credError}

} +
) : ( setChangingCredentials(true)} /> @@ -446,16 +386,15 @@ function ClientCard({ client, linkedTrackers, onSaved, onRemove, onSetDefault }: } setTagInput("") }} - > - Add - + text="Add" + />
{draft.crossSeedTags.length > 0 && ( -
+
{draft.crossSeedTags.map((tag) => ( {tag} - - {saveError && {saveError}} -
- - )} +
@@ -524,9 +455,9 @@ function ClientCard({ client, linkedTrackers, onSaved, onRemove, onSetDefault }: size="sm" variant="secondary" onClick={handleTestConnection} - disabled={connectionStatus === "testing"} + disabled={connectionStatus === "pending"} > - {connectionStatus === "testing" + {connectionStatus === "pending" ? "Testing..." : connectionStatus === "success" ? "Connected" @@ -541,29 +472,17 @@ function ClientCard({ client, linkedTrackers, onSaved, onRemove, onSetDefault }:
{!client.isDefault && ( - + - -
- ) : ( - - )} + onRemove(client.id)} />
- {connectionStatus === "failed" && connectionError && ( -

{connectionError}

- )} + {connectionStatus === "failed" && connectionError && } ) } @@ -622,9 +541,9 @@ function AddClientForm({ } return ( - +

Add Download Client

-
+
- +
@@ -670,68 +589,68 @@ function AddClientForm({ />
- {error &&

{error}

} +
- - +
) } function DownloadClients() { - const [clients, setClients] = useState([]) - const [loading, setLoading] = useState(true) + const queryClient = useQueryClient() const [showAddForm, setShowAddForm] = useState(false) - const fetchClients = useCallback(async () => { - try { - const res = await fetch("/api/clients") - if (!res.ok) return - const data: DownloadClient[] = await res.json() - setClients(data) - } catch { - // silently fail - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { - fetchClients() - }, [fetchClients]) - - const handleSaved = useCallback((id: number, updated: DownloadClient) => { - setClients((prev) => prev.map((c) => (c.id === id ? updated : c))) - }, []) - - const handleRemove = useCallback(async (id: number) => { - await fetch(`/api/clients/${id}`, { method: "DELETE" }) - setClients((prev) => { - const next = prev.filter((c) => c.id !== id) - // If we removed the default, promote the first remaining client optimistically - if (next.length > 0 && !next.some((c) => c.isDefault)) { - next[0] = { ...next[0], isDefault: true } - } - return next - }) - }, []) + const { data: clients = [], isLoading: loading } = useQuery({ + ...clientQueryOptions, + }) + + const handleSaved = useCallback( + (id: number, updated: DownloadClient) => { + queryClient.setQueryData(clientQueryOptions.queryKey, (prev) => + prev?.map((c) => (c.id === id ? updated : c)) + ) + }, + [queryClient] + ) + + const handleRemove = useCallback( + async (id: number) => { + await fetch(`/api/clients/${id}`, { method: "DELETE" }) + queryClient.setQueryData(clientQueryOptions.queryKey, (prev) => { + if (!prev) return prev + const next = prev.filter((c) => c.id !== id) + if (next.length > 0 && !next.some((c) => c.isDefault)) { + next[0] = { ...next[0], isDefault: true } + } + return next + }) + }, + [queryClient] + ) - const handleSetDefault = useCallback((id: number) => { - setClients((prev) => prev.map((c) => ({ ...c, isDefault: c.id === id }))) - fetch(`/api/clients/${id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ isDefault: true }), - }).catch(() => {}) - }, []) + const handleSetDefault = useCallback( + (id: number) => { + queryClient.setQueryData(clientQueryOptions.queryKey, (prev) => + prev?.map((c) => ({ ...c, isDefault: c.id === id })) + ) + fetch(`/api/clients/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isDefault: true }), + }).catch(() => {}) + }, + [queryClient] + ) if (loading) { - return

Loading clients...

+ return } return ( @@ -748,9 +667,7 @@ function DownloadClients() { download activity.
- + + text="+ Add Client" + /> )} )} diff --git a/src/components/NotificationTargets.tsx b/src/components/NotificationTargets.tsx index a4a5ae19..c37c383f 100644 --- a/src/components/NotificationTargets.tsx +++ b/src/components/NotificationTargets.tsx @@ -1,55 +1,33 @@ // src/components/NotificationTargets.tsx // // Functions: NotificationTargets, NotificationCard, AddNotificationForm - "use client" -import { H2, H3, Paragraph, Subtext } from "@typography" -import { useCallback, useEffect, useState } from "react" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { H2, H3, Paragraph } from "@typography" +import { useCallback, useState } from "react" import { Badge } from "@/components/ui/Badge" import { Button } from "@/components/ui/Button" import { Card } from "@/components/ui/Card" import { CollapsibleCard } from "@/components/ui/CollapsibleCard" +import { ConfirmRemove } from "@/components/ui/ConfirmRemove" +import { InfoTip } from "@/components/ui/InfoTip" import { Input } from "@/components/ui/Input" import { MaskedSecret } from "@/components/ui/MaskedSecret" +import { Notice } from "@/components/ui/Notice" import { NumberInput } from "@/components/ui/NumberInput" +import { SaveDiscardBar } from "@/components/ui/SaveDiscardBar" import { Select } from "@/components/ui/Select" +import { CardListSkeleton } from "@/components/ui/skeletons" import { Toggle } from "@/components/ui/Toggle" -import { Tooltip } from "@/components/ui/Tooltip" +import { useActionStatus } from "@/hooks/useActionStatus" +import { useCrudCard } from "@/hooks/useCrudCard" import { DOCS } from "@/lib/constants" import { formatTimeAgo } from "@/lib/formatters" import type { NotificationTargetType } from "@/lib/notifications/types" +import type { SafeNotificationTarget } from "@/types/api" -interface NotificationTarget { - id: number - name: string - type: NotificationTargetType - enabled: boolean - hasConfig: boolean - notifyRatioDrop: boolean - notifyHitAndRun: boolean - notifyTrackerDown: boolean - notifyBufferMilestone: boolean - notifyWarned: boolean - notifyRatioDanger: boolean - notifyZeroSeeding: boolean - notifyRankChange: boolean - notifyAnniversary: boolean - notifyBonusCap: boolean - notifyVipExpiring: boolean - notifyUnsatisfiedLimit: boolean - notifyActiveHnrs: boolean - thresholds: { ratioDropDelta?: number; bufferMilestoneBytes?: number } | null - includeTrackerName: boolean - scope: number[] | null - lastDeliveryStatus: string | null - lastDeliveryAt: string | null - lastDeliveryError: string | null - createdAt: string - updatedAt: string -} - -type WebhookStatus = "idle" | "testing" | "success" | "failed" +type NotificationTarget = SafeNotificationTarget const NOTIFICATION_TYPE_OPTIONS: { value: NotificationTargetType @@ -91,42 +69,47 @@ const DRAFT_KEYS: (keyof NotificationTarget)[] = [ "notifyVipExpiring", "notifyUnsatisfiedLimit", "notifyActiveHnrs", + "notifyDownloadDisabled", "thresholds", "includeTrackerName", "scope", ] -function isDirty(draft: NotificationTarget, saved: NotificationTarget): boolean { +function buildPatch( + draft: NotificationTarget, + saved: NotificationTarget +): Record | null { + const patch: Record = {} for (const key of DRAFT_KEYS) { const a = draft[key] const b = saved[key] if (a === null && b === null) continue if (typeof a === "object" && typeof b === "object" && a !== null && b !== null) { - if (JSON.stringify(a) !== JSON.stringify(b)) return true - } else if (a !== b) return true + if (JSON.stringify(a) !== JSON.stringify(b)) patch[key] = a + } else if (a !== b) patch[key] = a } - return false + return Object.keys(patch).length > 0 ? patch : null } function NotificationCard({ target, onSaved, onRemove }: NotificationCardProps) { - const [draft, setDraft] = useState(target) - const [saving, setSaving] = useState(false) - const [saveError, setSaveError] = useState(null) - const [expanded, setExpanded] = useState(false) - const [webhookStatus, setWebhookStatus] = useState("idle") - const [webhookError, setWebhookError] = useState(null) - const [confirmRemove, setConfirmRemove] = useState(false) - - const dirty = isDirty(draft, target) - - // Sync draft when parent pushes new server state - useEffect(() => { - if (!dirty) setDraft(target) - }, [target, dirty]) - - function updateDraft(patch: Partial) { - setDraft((prev) => ({ ...prev, ...patch })) - } + const { + draft, + setDraft, + updateDraft, + dirty, + saving, + saveError, + expanded, + toggleExpand, + handleSave, + handleDiscard, + } = useCrudCard({ + item: target, + apiEndpoint: "/api/notifications", + buildPatch, + onSaved, + }) + const { status: webhookStatus, error: webhookError, execute: executeTest } = useActionStatus() function updateThreshold(patch: Partial>) { setDraft((prev) => ({ @@ -135,43 +118,6 @@ function NotificationCard({ target, onSaved, onRemove }: NotificationCardProps) })) } - async function handleSave() { - setSaving(true) - setSaveError(null) - const patch: Record = {} - for (const key of DRAFT_KEYS) { - const a = draft[key] - const b = target[key] - if (typeof a === "object" && typeof b === "object") { - if (JSON.stringify(a) !== JSON.stringify(b)) patch[key] = a - } else if (a !== b) { - patch[key] = a - } - } - try { - const res = await fetch(`/api/notifications/${target.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(patch), - }) - if (!res.ok) { - const data = await res.json().catch(() => ({})) - setSaveError(data.error || "Failed to save") - return - } - onSaved(target.id, draft) - } catch { - setSaveError("Network error") - } finally { - setSaving(false) - } - } - - function handleDiscard() { - setDraft(target) - setSaveError(null) - } - // Webhook config change state const [changingConfig, setChangingConfig] = useState(!target.hasConfig) const [newWebhookUrl, setNewWebhookUrl] = useState("") @@ -199,24 +145,14 @@ function NotificationCard({ target, onSaved, onRemove }: NotificationCardProps) } } - const handleTestWebhook = useCallback(async () => { - setWebhookStatus("testing") - setWebhookError(null) - try { + const handleTestWebhook = () => + executeTest(async () => { const res = await fetch(`/api/notifications/${target.id}/test`, { method: "POST" }) - if (res.ok) { - setWebhookStatus("success") - setTimeout(() => setWebhookStatus("idle"), 3000) - } else { + if (!res.ok) { const data = await res.json().catch(() => ({})) - setWebhookError(data.error || "Test failed") - setWebhookStatus("failed") + throw new Error(data.error || "Test failed") } - } catch { - setWebhookError("Network error — could not reach server") - setWebhookStatus("failed") - } - }, [target.id]) + }) const ratioDropDelta = draft.thresholds?.ratioDropDelta ?? 0.1 @@ -235,7 +171,7 @@ function NotificationCard({ target, onSaved, onRemove }: NotificationCardProps) return ( setExpanded((e) => !e)} + onToggle={toggleExpand} header={
@@ -243,7 +179,7 @@ function NotificationCard({ target, onSaved, onRemove }: NotificationCardProps) {typeBadge} {statusBadge}
- + {target.lastDeliveryAt ? `Last sent: ${formatTimeAgo(target.lastDeliveryAt)}` : "Last sent: Never"} @@ -252,7 +188,7 @@ function NotificationCard({ target, onSaved, onRemove }: NotificationCardProps) } > {/* Row 1: Name + Type + Enabled */} -
+
- +

Webhook URL - - ? - - + /> +

{changingConfig ? (
- Save URL - + text="Save URL" + /> {target.hasConfig && ( )}
- {configError &&

{configError}

} +
) : ( setChangingConfig(true)} /> @@ -338,15 +273,15 @@ function NotificationCard({ target, onSaved, onRemove }: NotificationCardProps) {/* Notify when section */}
- +

Notify when - - ? - - + /> +

updateDraft({ notifyRatioDrop: v })} /> {draft.notifyRatioDrop && ( -
+
Threshold (delta) updateDraft({ notifyBonusCap: v })} - description="Alert when seedbonus hits the cap (99,999 on MAM)" + description="Alert when seedbonus hits the cap (i.e. 99,999 on MAM)" /> updateDraft({ notifyVipExpiring: v })} - description="Alert when VIP status is about to expire" + description="Alert when VIP status is about to expire (MAM, AvistaZ network)" /> updateDraft({ notifyActiveHnrs: v })} description="Alert when inactive Hit & Runs are detected" /> + + updateDraft({ notifyDownloadDisabled: v })} + description="Alert when an AvistaZ network tracker revokes download access" + />
@@ -455,26 +397,19 @@ function NotificationCard({ target, onSaved, onRemove }: NotificationCardProps) checked={draft.includeTrackerName} onChange={(v) => updateDraft({ includeTrackerName: v })} /> - - Tracker names reveal which private trackers you use. Disable for maximum privacy. - +
- {/* Save / Discard bar — only visible when draft has changes */} - {dirty && ( - <> -
-
- - - {saveError && {saveError}} -
- - )} +
@@ -484,9 +419,9 @@ function NotificationCard({ target, onSaved, onRemove }: NotificationCardProps) size="sm" variant="secondary" onClick={handleTestWebhook} - disabled={webhookStatus === "testing" || !target.hasConfig} + disabled={webhookStatus === "pending" || !target.hasConfig} > - {webhookStatus === "testing" + {webhookStatus === "pending" ? "Sending..." : webhookStatus === "success" ? "Sent" @@ -500,24 +435,9 @@ function NotificationCard({ target, onSaved, onRemove }: NotificationCardProps)
- {confirmRemove ? ( -
- - -
- ) : ( - - )} + onRemove(target.id)} />
- {webhookStatus === "failed" && webhookError && ( -

{webhookError}

- )} + {webhookStatus === "failed" && webhookError && } ) } @@ -569,9 +489,9 @@ function AddNotificationForm({ } return ( - +

Add Notification Target

-
+
- {error &&

{error}

} +
- - +
) @@ -618,52 +539,64 @@ function AddNotificationForm({ // --------------------------------------------------------------------------- function NotificationTargets() { - const [targets, setTargets] = useState([]) - const [loading, setLoading] = useState(true) + const queryClient = useQueryClient() const [showAddForm, setShowAddForm] = useState(false) - const fetchTargets = useCallback(async () => { - try { - const res = await fetch("/api/notifications") - if (!res.ok) return - const data: NotificationTarget[] = await res.json() - setTargets(data) - } catch { - // silently fail - } finally { - setLoading(false) - } - }, []) - - useEffect(() => { - fetchTargets() - }, [fetchTargets]) - - const handleSaved = useCallback((id: number, updated: NotificationTarget) => { - setTargets((prev) => prev.map((t) => (t.id === id ? updated : t))) - }, []) + const { + data: targets = [], + isLoading: loading, + error: loadError, + } = useQuery({ + queryKey: ["notification-targets"], + queryFn: async ({ signal }) => { + const res = await fetch("/api/notifications", { signal }) + if (!res.ok) throw new Error("Failed to load notification targets") + return res.json() as Promise + }, + }) + + const handleSaved = useCallback( + (id: number, updated: NotificationTarget) => { + queryClient.setQueryData(["notification-targets"], (prev) => + prev?.map((t) => (t.id === id ? updated : t)) + ) + }, + [queryClient] + ) - const handleRemove = useCallback(async (id: number) => { - try { + const handleRemove = useCallback( + async (id: number) => { const res = await fetch(`/api/notifications/${id}`, { method: "DELETE" }) if (!res.ok) return - setTargets((prev) => prev.filter((t) => t.id !== id)) - } catch { - // Network error — leave state unchanged - } - }, []) + queryClient.setQueryData(["notification-targets"], (prev) => + prev?.filter((t) => t.id !== id) + ) + }, + [queryClient] + ) if (loading) { - return

Loading notification targets...

+ return + } + + if (loadError) { + return ( + + ) } return (

Webhooks - - - +

{targets.length === 0 && !showAddForm ? ( @@ -676,9 +609,7 @@ function NotificationTargets() { Add a notification target to receive alerts when your tracker stats change.
- + + text="+ Add Notification Target" + /> )} )} diff --git a/src/components/QbitmanageSettings.tsx b/src/components/QbitmanageSettings.tsx index 34739120..43e77c83 100644 --- a/src/components/QbitmanageSettings.tsx +++ b/src/components/QbitmanageSettings.tsx @@ -1,18 +1,15 @@ // src/components/QbitmanageSettings.tsx -// -// Functions: QbitmanageSettings - "use client" import { Subtext } from "@typography" import { useState } from "react" import { SettingsSection } from "@/components/settings/SettingsSection" -import { Button } from "@/components/ui/Button" import { Checkbox } from "@/components/ui/Checkbox" import { Input } from "@/components/ui/Input" +import { SaveDiscardBar } from "@/components/ui/SaveDiscardBar" import { Toggle } from "@/components/ui/Toggle" import { DOCS } from "@/lib/constants" -import { QBITMANAGE_TAG_DEFAULTS } from "@/lib/qbitmanage-defaults" +import { QBITMANAGE_TAG_DEFAULTS } from "@/lib/download-clients/qbt/qbitmanage-defaults" import type { QbitmanageTagConfig } from "@/types/api" const QBITMANAGE_STATUSES = [ @@ -138,15 +135,14 @@ function QbitmanageSettings({ initialEnabled, initialTags }: QbitmanageSettingsP
))} -
-
- - {saved && Saved} -
- {saveError && {saveError}} -
+ )} diff --git a/src/components/TagGroups.tsx b/src/components/TagGroups.tsx index 9bdaff97..b6e5b51a 100644 --- a/src/components/TagGroups.tsx +++ b/src/components/TagGroups.tsx @@ -1,6 +1,6 @@ // src/components/TagGroups.tsx // -// Functions: TagGroups, AddTagGroupForm, TagGroupCard, SortableMemberRow, MemberRow, NewMemberRow +// Functions: TagGroups, AddTagGroupForm, TagGroupCard, SortableMemberRow, MemberRow "use client" @@ -12,17 +12,26 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable" import { CSS } from "@dnd-kit/utilities" +import { useQuery, useQueryClient } from "@tanstack/react-query" import { H2, H3, Paragraph } from "@typography" import clsx from "clsx" -import { type KeyboardEvent, useCallback, useEffect, useRef, useState } from "react" -import { Button } from "@/components/ui/Button" -import { Card } from "@/components/ui/Card" -import { CollapsibleCard } from "@/components/ui/CollapsibleCard" +import { type KeyboardEvent, useEffect, useRef, useState } from "react" +import { + Button, + Card, + CardListSkeleton, + CollapsibleCard, + ConfirmRemove, + FilterPill, + InfoTip, + Input, + Notice, + Toggle, + Tooltip, +} from "@/components/ui" import { EmojiPickerPopover } from "@/components/ui/EmojiPickerPopover" -import { Input } from "@/components/ui/Input" import { QBT_TAG_WARN_PATTERN } from "@/components/ui/QbtTagWarning" -import { Toggle } from "@/components/ui/Toggle" -import { Tooltip } from "@/components/ui/Tooltip" +import { useEscapeKey } from "@/hooks/useEscapeKey" import { DOCS } from "@/lib/constants" import type { TagGroup, TagGroupChartType } from "@/types/api" @@ -80,9 +89,10 @@ function AddTagGroupForm({ onCreated, onCancel }: AddTagGroupFormProps) { } } + useEscapeKey(onCancel, true) + function handleKeyDown(e: KeyboardEvent) { if (e.key === "Enter") handleCreate() - if (e.key === "Escape") onCancel() } return ( @@ -90,9 +100,7 @@ function AddTagGroupForm({ onCreated, onCancel }: AddTagGroupFormProps) {

New Tag Group

- - Emoji - +

Emoji

@@ -113,37 +121,30 @@ function AddTagGroupForm({ onCreated, onCancel }: AddTagGroupFormProps) {
{/* Chart type selector */} -
- - Display Type - +
+

Display Type

{CHART_TYPE_OPTIONS.map((opt) => ( - + text={opt.label} + className="flex-1" + /> ))}
- - +
) @@ -159,6 +160,7 @@ interface MemberRowProps { onRemove: () => void disabled?: boolean dragHandle?: boolean + isNew?: boolean } function MemberRow({ @@ -169,14 +171,23 @@ function MemberRow({ onRemove, disabled, dragHandle, + isNew, }: MemberRowProps) { - const warn = tagWarning(tag) + const warn = tag ? tagWarning(tag) : null return (
-
- {dragHandle && ( +
+ {(dragHandle || isNew) && (
@@ -200,7 +211,7 @@ function MemberRow({ onChange={(e) => onLabelChange(e.target.value)} placeholder="Display Label" disabled={disabled} - aria-label="Display Label" + aria-label={isNew ? "New Display Label" : "Display Label"} />
- {warn &&

{warn}

} +
) } @@ -235,78 +246,13 @@ function SortableMemberRow({ sortId, ...props }: SortableMemberRowProps) { ) } -// ─── NewMemberRow (always visible) ──────────────────────────────────────────── - -interface NewMemberRowProps { - tag: string - label: string - onTagChange: (v: string) => void - onLabelChange: (v: string) => void - onRemove: () => void - disabled?: boolean -} - -function NewMemberRow({ - tag, - label, - onTagChange, - onLabelChange, - onRemove, - disabled, -}: NewMemberRowProps) { - const warn = tag ? tagWarning(tag) : null - - return ( -
-
- -
- onTagChange(e.target.value)} - placeholder="qbt-tag" - disabled={disabled} - aria-label="New qBT Tag" - /> -
-
-
- onLabelChange(e.target.value)} - placeholder="Display Label" - disabled={disabled} - aria-label="New Display Label" - /> -
- -
- {warn &&

{warn}

} -
- ) -} - // ─── TagGroupCard ───────────────────────────────────────────────────────────── interface EditableMember { id: number | null tag: string label: string + _clientId?: number } interface TagGroupCardProps { @@ -324,16 +270,14 @@ function TagGroupCard({ group, onUpdated }: TagGroupCardProps) { const [members, setMembers] = useState( group.members.map((m) => ({ id: m.id, tag: m.tag, label: m.label })) ) + const nextClientId = useRef(0) const [saving, setSaving] = useState(false) const [saveError, setSaveError] = useState(null) const [deleting, setDeleting] = useState(false) const [deleteError, setDeleteError] = useState(null) - const [confirmDelete, setConfirmDelete] = useState(false) // Sync from parent when group prop changes (e.g. after save + refetch) - const groupIdRef = useRef(group.id) useEffect(() => { - if (groupIdRef.current !== group.id) groupIdRef.current = group.id setName(group.name) setEmoji(group.emoji ?? "") setChartType(group.chartType) @@ -357,7 +301,8 @@ function TagGroupCard({ group, onUpdated }: TagGroupCardProps) { })() function handleAddRow() { - setMembers((prev) => [...prev, { id: null, tag: "", label: "" }]) + const clientId = ++nextClientId.current + setMembers((prev) => [...prev, { id: null, tag: "", label: "", _clientId: clientId }]) } function handleRemoveMember(index: number) { @@ -381,7 +326,26 @@ function TagGroupCard({ group, onUpdated }: TagGroupCardProps) { setSaving(true) setSaveError(null) try { - await fetch(`/api/tag-groups/${group.id}`, { + const currentIds = new Set(members.filter((m) => m.id !== null).map((m) => m.id)) + const removes = group.members.filter((m) => !currentIds.has(m.id)).map((m) => m.id) + + const updates: { id: number; tag: string; label: string; sortOrder: number }[] = [] + const creates: { tag: string; label: string; sortOrder: number }[] = [] + + for (let i = 0; i < members.length; i++) { + const m = members[i] + if (m.id !== null) { + const orig = group.members.find((om) => om.id === m.id) + const origIndex = group.members.findIndex((om) => om.id === m.id) + if (orig && (m.tag !== orig.tag || m.label !== orig.label || i !== origIndex)) { + updates.push({ id: m.id, tag: m.tag.trim(), label: m.label.trim(), sortOrder: i }) + } + } else if (m.tag.trim() && m.label.trim()) { + creates.push({ tag: m.tag.trim(), label: m.label.trim(), sortOrder: i }) + } + } + + const res = await fetch(`/api/tag-groups/${group.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -389,40 +353,12 @@ function TagGroupCard({ group, onUpdated }: TagGroupCardProps) { emoji: emoji.trim() || null, chartType, countUnmatched, + members: { removes, updates, creates }, }), - }).then((r) => { - if (!r.ok) throw new Error("Failed to save group") }) - - const currentIds = new Set(members.filter((m) => m.id !== null).map((m) => m.id)) - const removedMembers = group.members.filter((m) => !currentIds.has(m.id)) - for (const rm of removedMembers) { - await fetch(`/api/tag-groups/${group.id}/members/${rm.id}`, { method: "DELETE" }) - } - - for (let i = 0; i < members.length; i++) { - const m = members[i] - if (m.id === null) continue - const orig = group.members.find((om) => om.id === m.id) - const origIndex = group.members.findIndex((om) => om.id === m.id) - if (orig && (m.tag !== orig.tag || m.label !== orig.label || i !== origIndex)) { - await fetch(`/api/tag-groups/${group.id}/members/${m.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tag: m.tag.trim(), label: m.label.trim(), sortOrder: i }), - }) - } - } - - for (let i = 0; i < members.length; i++) { - const m = members[i] - if (m.id !== null) continue - if (!m.tag.trim() || !m.label.trim()) continue - await fetch(`/api/tag-groups/${group.id}/members`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tag: m.tag.trim(), label: m.label.trim(), sortOrder: i }), - }) + if (!res.ok) { + const data = await res.json().catch(() => ({ error: "Save failed" })) + throw new Error((data as { error?: string }).error ?? "Save failed") } onUpdated() @@ -442,12 +378,13 @@ function TagGroupCard({ group, onUpdated }: TagGroupCardProps) { setSaveError(null) } + useEscapeKey(() => { + setName(group.name) + setEditingName(false) + }, editingName) + function handleNameKeyDown(e: KeyboardEvent) { if (e.key === "Enter") setEditingName(false) - if (e.key === "Escape") { - setName(group.name) - setEditingName(false) - } } async function handleDelete() { @@ -526,30 +463,20 @@ function TagGroupCard({ group, onUpdated }: TagGroupCardProps) { {/* Emoji + display type row */}
- - Emoji - +

Emoji

- - Display Type - +

Display Type

{CHART_TYPE_OPTIONS.map((opt) => ( - + text={opt.label} + className="flex-1" + /> ))}
@@ -566,13 +493,9 @@ function TagGroupCard({ group, onUpdated }: TagGroupCardProps) { {/* Column headers */}