diff --git a/.env.example b/.env.example index ee1f7925..aaf22d7e 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,7 @@ TZ=America/Chicago # PORT=3000 # BASE_URL=https://trackertracker.example.com +# SECURE_COOKIES=true # LOG_LEVEL=debug # POSTGRES_DB=tracker_tracker diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 135d926d..8a497a4b 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -42,7 +42,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: "3.x" diff --git a/.github/workflows/knip-report.yml b/.github/workflows/knip-report.yml index cbebce4a..85bad135 100644 --- a/.github/workflows/knip-report.yml +++ b/.github/workflows/knip-report.yml @@ -105,7 +105,7 @@ jobs: fi - name: Post PR Comment - uses: marocchino/sticky-pull-request-comment@v2 + uses: marocchino/sticky-pull-request-comment@v3 with: header: knip-report path: knip-report.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb3fe6fb..e1d9495d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -105,7 +105,7 @@ jobs: - name: Generate SBOM if: steps.tag.outputs.should_release == 'true' - uses: anchore/sbom-action@v0.23.1 + uses: anchore/sbom-action@v0.24.0 with: image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.version }} artifact-name: sbom-tracker-tracker.spdx.json diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index d8119db8..c4c4f9a6 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -29,6 +29,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Check for known dependency vulnerabilities + run: pnpm audit --audit-level=high || echo "::warning::pnpm audit found high-severity vulnerabilities" + - name: Verify security test count run: | COUNT=$(pnpm test:run -- src/lib/__tests__/security.test.ts 2>&1 \ @@ -37,8 +40,8 @@ jobs: | grep -oE '[0-9]+ test' \ | grep -oE '[0-9]+') echo "Security tests: $COUNT" - if [ "$COUNT" -lt 98 ]; then - echo "::error::Security test count dropped below expected minimum (98). Was a test removed?" + if [ "$COUNT" -lt 106 ]; then + echo "::error::Security test count dropped below expected minimum (106). Was a test removed?" exit 1 fi @@ -46,10 +49,22 @@ jobs: id: security-audit continue-on-error: true run: | - pnpm exec tsx scripts/security-audit.ts > /tmp/security-results.json - echo "exit_code=$?" >> "$GITHUB_OUTPUT" + pnpm exec tsx scripts/security-audit.ts > /tmp/security-results.json; echo "audit_exit=$?" >> "$GITHUB_OUTPUT" cat /tmp/security-results.json + - name: Verify audit check count + run: | + if [ ! -f /tmp/security-results.json ]; then + echo "::error::Security audit produced no output" + exit 1 + fi + TOTAL=$(python3 -c "import json; print(json.load(open('/tmp/security-results.json'))['summary']['total'])") + echo "Audit checks: $TOTAL" + if [ "$TOTAL" -lt 36 ]; then + echo "::error::Audit check count dropped below expected minimum (36). Was a check removed?" + exit 1 + fi + - name: Comment on PR with security audit results if: github.event_name == 'pull_request' uses: actions/github-script@v8 @@ -214,5 +229,5 @@ jobs: } - name: Fail if security audit has critical findings - if: steps.security-audit.outcome == 'failure' + if: steps.security-audit.outputs.audit_exit != '0' run: exit 1 diff --git a/.markdownlint.json b/.markdownlint.json index af62806d..c5b3fce7 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -1,5 +1,7 @@ { "MD013": false, "MD024": false, - "MD025": false + "MD025": false, + "MD033": false, + "MD041": false } diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..e4741063 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +# docs/**/*.md diff --git a/.trivyignore b/.trivyignore index 6fcc4463..9caa1f77 100644 --- a/.trivyignore +++ b/.trivyignore @@ -1,19 +1,20 @@ -# Alpine zlib — fix requires base image rebuild. Will resolve when node:24-alpine or node:25-alpine ships with zlib 1.3.2-r0. +# Alpine zlib — fix requires base image rebuild. Will resolve when node:24-alpine ships with zlib 1.3.2-r0. CVE-2026-22184 -# minimatch — transitive via npm internals. No direct dep to bump. +# minimatch — transitive via npm internals baked into base image. Stripped from runner but may appear in intermediate stages. CVE-2026-26996 CVE-2026-27903 CVE-2026-27904 -# node-tar — transitive via npm internals. No direct dep to bump. +# node-tar — transitive via npm internals baked into base image. Stripped from runner but may appear in intermediate stages. CVE-2026-26960 CVE-2026-29786 CVE-2026-31802 -# esbuild Go stdlib — build tool binary, not runtime; CVEs apply to the Go runtime bundled in esbuild (transitive via drizzle-kit). -# Cannot fix until esbuild releases a version compiled with a patched Go toolchain. +# esbuild Go stdlib (1.23.x) — build tool binary in schema-sync, not a runtime service. Runs once at startup for drizzle-kit push. +# Cannot fix until esbuild ships a release built with Go 1.24.13+. CVE-2025-47912 +CVE-2025-58183 CVE-2025-58185 CVE-2025-58186 CVE-2025-58187 @@ -22,8 +23,12 @@ CVE-2025-58189 CVE-2025-61723 CVE-2025-61724 CVE-2025-61725 +CVE-2025-61726 CVE-2025-61727 +CVE-2025-61728 +CVE-2025-61729 CVE-2025-61730 +CVE-2025-68121 CVE-2026-25679 CVE-2026-27139 CVE-2026-27142 diff --git a/.vscode/settings.json b/.vscode/settings.json index 9e30bb88..f92fe287 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,5 +15,6 @@ }, "[css]": { "editor.defaultFormatter": "esbenp.prettier-vscode" - } + }, + "conventionalCommits.scopes": ["auth"] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bdcc602..e947f16b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,49 @@ # Changelog -## [2.4.1](https://github.com/jordanlambrecht/tracker-tracker/compare/v2.3.0...v2.4.1) (2026-03-23) +## [2.6.0](https://github.com/jordanlambrecht/tracker-tracker/compare/v2.4.1...v2.6.0) (2026-03-27) + + +### 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)) + + +### 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) ### Features @@ -10,30 +52,118 @@ - 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) + +### 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)) + +### 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)) ### 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.1](https://github.com/jordanlambrecht/tracker-tracker/compare/v2.3.0...v2.4.1) (2026-03-23) + +### 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)) + +### 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)) + +### 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 @@ -45,7 +175,6 @@ ## [2.4.0](https://github.com/jordanlambrecht/tracker-tracker/compare/v2.3.0...v2.4.0) (2026-03-23) - ### Features - add fetchTrackerStats for future live transit paper data ([ed6662f](https://github.com/jordanlambrecht/tracker-tracker/commit/ed6662f0ac0c9990b6c96e8ae5616452385b6c26)) @@ -56,7 +185,6 @@ - **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)) @@ -69,13 +197,11 @@ - 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)) diff --git a/Dockerfile b/Dockerfile index 1ebeb2d4..1d45ac35 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,4 @@ # Dockerfile -# -# Multi-stage build for Tracker Tracker. -# Stages: deps → builder → prod-deps → runner -# -# Uses Next.js standalone output for minimal image size (~150MB vs ~1GB). # --------------------------------------------------------------------------- # Base @@ -23,7 +18,7 @@ COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile # --------------------------------------------------------------------------- -# Stage 2 — Build the Next.js application +# Stage 2 — Build the Next.js app # --------------------------------------------------------------------------- FROM base AS builder WORKDIR /app @@ -31,23 +26,25 @@ COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 # Dummy DATABASE_URL so Next.js can evaluate route modules during build -# (the DB is never actually queried at build time) +# (In case DB is never actually queried at build time) ENV DATABASE_URL=postgresql://build:build@localhost:5432/build RUN pnpm build # --------------------------------------------------------------------------- -# Stage 3 — Prune to production deps for schema-sync +# Stage 3 — Minimal deps for drizzle-kit # --------------------------------------------------------------------------- -FROM deps AS prod-deps -WORKDIR /app -RUN pnpm prune --prod --ignore-scripts +FROM base AS schema-deps +WORKDIR /schema-sync +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile # --------------------------------------------------------------------------- # Stage 4 — Production runner # --------------------------------------------------------------------------- FROM node:24-alpine AS runner -# Targeted upgrade for CVE-2026-22184 (zlib). Remove once node:24-alpine ships zlib >= 1.3.2-r0. -RUN apk add --no-cache libc6-compat bash && apk upgrade --no-cache zlib +RUN apk add --no-cache libc6-compat bash && apk upgrade --no-cache \ + && rm -rf /usr/lib/node_modules /usr/local/lib/node_modules \ + /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack WORKDIR /app ENV NODE_ENV=production @@ -55,22 +52,21 @@ ENV NEXT_TELEMETRY_DISABLED=1 ENV PORT=3000 ENV HOSTNAME=0.0.0.0 -# Non-root user for security +# Non-root user RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs RUN mkdir -p /data/backups /data/logs && chown -R nextjs:nodejs /data -# --- Standalone server (traced dependencies only) --- +# --- Standalone server --- COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/public ./public # --- Drizzle schema-sync (for drizzle-kit push at startup) --- -# Use production-only dependencies to minimize image size and CVE surface. RUN mkdir -p /schema-sync/src/lib -COPY --from=prod-deps /app/node_modules /schema-sync/node_modules -COPY --from=builder /app/package.json /schema-sync/ +COPY --from=schema-deps /schema-sync/node_modules /schema-sync/node_modules +COPY --from=schema-deps /schema-sync/package.json /schema-sync/ COPY --from=builder /app/drizzle.config.ts /schema-sync/ COPY --from=builder /app/src/lib/db /schema-sync/src/lib/db COPY --from=builder /app/tsconfig.json /schema-sync/ @@ -86,6 +82,6 @@ USER nextjs EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1 + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1 ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/README.md b/README.md index 1791a47a..4dddab7c 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ pnpm dev | `POSTGRES_USER` | No | `postgres` | Database user | | `POSTGRES_DB` | No | `tracker_tracker` | Database name | | `DATABASE_URL` | No\* | _(auto-built)_ | Override to use an external Postgres instance | +| `SECURE_COOKIES` | No | _(auto)_ | Set `true` for HTTPS. Auto-enabled by `BASE_URL`. | \* Set either `POSTGRES_PASSWORD` (bundled DB) or `DATABASE_URL` (external DB). diff --git a/SECURITY.md b/SECURITY.md index e4f87644..2cc996a6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -62,7 +62,7 @@ Last audited: `2026-03-17` - **Password hashing**: Argon2 (memory-hard KDF) — `src/lib/auth.ts` - **Session tokens**: Encrypted JWE (A256GCM) via `jose` — `src/lib/auth.ts` -- **Cookie security**: httpOnly, secure (in production), sameSite=strict, 7-day hard expiry +- **Cookie security**: httpOnly, secure (when BASE_URL is HTTPS or SECURE_COOKIES=true), sameSite=strict, 7-day hard expiry - **Password policy**: 8-128 characters enforced on setup and login - **Username**: Optional login username (6-100 chars), case-insensitive - **TOTP 2FA**: Optional TOTP via `otpauth` (SHA1, 6 digits, 30s period, ±1 window). Secret encrypted at rest with AES-256-GCM. Stateless enrollment via JWE setup tokens (5min TTL). @@ -307,7 +307,7 @@ When `backupEncryptionEnabled` is true, the entire backup JSON is wrapped in an - **Read-only filesystem:** Mount the application container root as read-only (`read_only: true` in docker-compose) with tmpfs for `/tmp`. - **Network isolation:** Place PostgreSQL on an internal Docker network with no published ports. - **Reverse proxy:** Deploy behind Nginx, Caddy, or Traefik with TLS termination and rate limiting on `/api/auth/login`. -- **`NODE_ENV=production`:** Required for the `secure` cookie flag and Next.js production optimizations. +- **`NODE_ENV=production`:** Required for Next.js production optimizations. Cookie `secure` flag is controlled separately via `BASE_URL` scheme or `SECURE_COOKIES=true`. - **`SESSION_SECRET`:** Minimum 32 characters of cryptographically random data. Generate with: `openssl rand -base64 48`. --- @@ -474,7 +474,7 @@ npx tsx scripts/security-audit.ts # Static security audit (28 checks) #### 5. Session Security -- [ ] Session cookie set with: `httpOnly: true`, `sameSite: "strict"`, `secure: NODE_ENV === "production"`, `path: "/"` +- [ ] Session cookie set with: `httpOnly: true`, `sameSite: "strict"`, `secure: shouldSecureCookies()`, `path: "/"` - [ ] Session has hard expiry (7 days) encoded in the JWE payload — not just cookie `maxAge` - [ ] Destructive operations (lockdown, nuke, password change, restore) zero-fill the encryption key buffer and stop the scheduler. Logout preserves the scheduler for 24/7 polling. - [ ] Login returns the encryption key only inside the JWE session — never in the response body @@ -516,7 +516,7 @@ Verify in `next.config.ts`: #### 10. Docker & Deployment - [ ] Container runs as non-root user (UID 1001 `nextjs`) -- [ ] `NODE_ENV=production` set in Docker Compose +- [ ] `NODE_ENV=production` set in Dockerfile (cookie `secure` flag derived from `BASE_URL`/`SECURE_COOKIES`, not `NODE_ENV`) - [ ] PostgreSQL on internal network — no published ports - [ ] No `.env` files tracked by git (checked by security audit #7) - [ ] `scripts/reset-password-nuclear.mjs` not included in production Docker image diff --git a/biome.json b/biome.json index a5af73b4..2c54afc6 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.8/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.9/schema.json", "files": { "includes": ["**", "!.next", "!node_modules", "!public", "!.history", "!.claude"] }, diff --git a/docker-compose.yml b/docker-compose.yml index 583cbb6f..8e214687 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,7 @@ services: # LOG_LEVEL: ${LOG_LEVEL} # optional, defaults to 'info' # LOG_FILE: /data/logs/tracker-tracker.log # optional BASE_URL: ${BASE_URL:-} # optional, i.e https://trackertracker.example.com + # SECURE_COOKIES: ${SECURE_COOKIES:-} # set to 'true' if serving over HTTPS volumes: - ./data:/data depends_on: diff --git a/docs/adding-trackers.md b/docs/adding-trackers.md deleted file mode 100644 index 143ab43b..00000000 --- a/docs/adding-trackers.md +++ /dev/null @@ -1,221 +0,0 @@ -# Adding a Tracker - -There are two levels of contribution: adding a **registry entry** (metadata only) or writing a **platform adapter** (code that talks to the tracker's API). - -Most trackers run on UNIT3D or Gazelle, which already have adapters. For those, you only need a registry entry. - ---- - -## Registry Entry (no code required) - -If the tracker runs on UNIT3D, Gazelle, or GGn, the existing adapter handles the API calls. You just need to describe the tracker. - -### 1. Create the tracker file - -Create `src/data/trackers/.ts`: - -```ts -// src/data/trackers/.ts - -import type { TrackerRegistryEntry } from "@/data/tracker-registry" - -export const mytracker: TrackerRegistryEntry = { - slug: "mytracker", - name: "MyTracker", - abbreviation: "MT", - url: "https://mytracker.org", - description: "Brief description of what the tracker is known for.", - platform: "unit3d", // "unit3d" | "gazelle" | "ggn" | "custom" - apiPath: "/api/user", // UNIT3D default; Gazelle uses "/ajax.php" - specialty: "General / HD", - contentCategories: ["Movies", "TV"], - userClasses: [ - { name: "User", requirements: "Default class" }, - { name: "Power User", requirements: "Upload >= 50 GiB, ratio >= 1.0, age >= 1 month" }, - ], - releaseGroups: ["GroupA", "GroupB"], - notableMembers: [], - rules: { - minimumRatio: 0.6, // 0 = no minimum - seedTimeHours: 72, // 0 = no minimum - loginIntervalDays: 90, // days before account prune/disable - }, - language: "English", - color: "#00d4ff", // hex color used as the tracker's accent throughout the UI -} -``` - -### 2. Register it - -Add your import and entry to `src/data/trackers/index.ts`: - -```ts -import { mytracker } from "./mytracker" - -export const ALL_TRACKERS: TrackerRegistryEntry[] = [ - // ... existing trackers (alphabetical) - mytracker, - // ... -] -``` - -That's it. The tracker will appear in the Add Tracker dialog and use the existing platform adapter. - -### Field reference - -| Field | Required | Description | -| ------------------- | -------- | ----------------------------------------------------------------------------- | -| `slug` | Yes | Unique lowercase identifier, used in URLs | -| `name` | Yes | Display name | -| `abbreviation` | No | Short form (i.e "RED", "OPS") | -| `url` | Yes | Tracker homepage URL | -| `description` | Yes | What the tracker is known for | -| `platform` | Yes | `"unit3d"`, `"gazelle"`, `"ggn"`, or `"custom"` | -| `apiPath` | Yes | API endpoint path. UNIT3D: `/api/user`, Gazelle: `/ajax.php`, GGn: `/api.php` | -| `specialty` | Yes | Content focus (i.e "Anime", "Music", "General / HD") | -| `contentCategories` | Yes | Array of content types | -| `userClasses` | Yes | Array of `{ name, requirements? }` — the tracker's user class ladder | -| `releaseGroups` | Yes | Array of group names or `{ name, description }` objects | -| `notableMembers` | Yes | Array of notable community members (can be empty) | -| `bannedGroups` | No | Groups banned from uploading | -| `rules` | No | See TrackerRules below | -| `stats` | No | `{ userCount?, torrentCount?, seedSize?, statsUpdatedAt? }` | -| `language` | No | Primary language | -| `color` | Yes | Hex color for the tracker's accent theme | -| `logo` | No | Path to logo file in `public/tracker-logos/` | -| `trackerHubSlug` | No | Slug on TrackerHub for status monitoring | -| `statusPageUrl` | No | External status page URL | -| `draft` | No | Set `true` if the platform adapter doesn't exist yet | - -### TrackerRules - -| Field | Type | Description | -| ------------------------ | --------- | ------------------------------------------------------- | -| `minimumRatio` | `number` | Minimum ratio before penalties. 0 = no minimum | -| `seedTimeHours` | `number` | Required seed time per torrent in hours. 0 = no minimum | -| `loginIntervalDays` | `number` | Days of inactivity before account prune/disable | -| `fulfillmentPeriodHours` | `number?` | Time allowed to complete seeding requirement | -| `hnrBanLimit` | `number?` | Number of H&R warnings before ban | -| `fullRulesMarkdown` | `string?` | Detailed rules text (shown in tracker detail page) | - ---- - -## Platform Adapter (code required) - -If the tracker doesn't run on UNIT3D, Gazelle, or GGn, you need to write an adapter that knows how to call its API and normalize the response. - -### 1. Create the adapter - -Create `src/lib/adapters/.ts`: - -```ts -// src/lib/adapters/.ts - -import type { FetchOptions, TrackerAdapter, TrackerStats } from "./types" - -export class MyPlatformAdapter implements TrackerAdapter { - async fetchStats( - baseUrl: string, - apiToken: string, - apiPath: string, - options?: FetchOptions - ): Promise { - // 1. Build the request URL - const url = new URL(apiPath, baseUrl) - - // 2. Make the API call (handle proxy if options.proxyAgent is set) - const response = await fetch(url.toString(), { - headers: { - Authorization: `Bearer ${apiToken}`, - Accept: "application/json", - }, - signal: AbortSignal.timeout(15000), - }) - - if (!response.ok) { - throw new Error(`Tracker API error: ${response.status} ${response.statusText}`) - } - - const data = await response.json() - - // 3. Map the API response to TrackerStats - return { - username: data.username, - group: data.class_name, - uploadedBytes: BigInt(data.uploaded), - downloadedBytes: BigInt(data.downloaded), - ratio: data.ratio, - bufferBytes: BigInt(data.uploaded) - BigInt(data.downloaded), - seedingCount: data.seeding ?? 0, - leechingCount: data.leeching ?? 0, - seedbonus: data.bonus ?? 0, - hitAndRuns: data.hnrs ?? 0, - requiredRatio: null, // set if the API provides it - warned: null, // set if the API provides it - freeleechTokens: null, // set if the API provides it - } - } -} -``` - -### TrackerStats fields - -Every adapter must return all 13 fields. Use `null` for fields the API doesn't provide. - -| Field | Type | Description | -| ----------------- | ----------------- | ------------------------------------ | -| `username` | `string` | Current username | -| `group` | `string` | User class / rank name | -| `uploadedBytes` | `bigint` | Total uploaded in bytes | -| `downloadedBytes` | `bigint` | Total downloaded in bytes | -| `ratio` | `number` | Upload/download ratio | -| `bufferBytes` | `bigint` | uploaded - downloaded | -| `seedingCount` | `number` | Active seeding torrents | -| `leechingCount` | `number` | Active leeching torrents | -| `seedbonus` | `number` | Bonus points / freeleech tokens | -| `hitAndRuns` | `number` | Active H&R warnings | -| `requiredRatio` | `number \| null` | Required ratio (Gazelle-specific) | -| `warned` | `boolean \| null` | Whether the user has a ratio warning | -| `freeleechTokens` | `number \| null` | Available freeleech tokens | - -### 2. Register the adapter - -Add it to `src/lib/adapters/index.ts`: - -```ts -import { MyPlatformAdapter } from "./myplatform" - -const adapters: Record = { - gazelle: new GazelleAdapter(), - ggn: new GGnAdapter(), - unit3d: new Unit3dAdapter(), - myplatform: new MyPlatformAdapter(), // add here -} - -export const DEFAULT_API_PATHS: Record = { - unit3d: "/api/user", - gazelle: "/ajax.php", - ggn: "/api.php", - myplatform: "/api/endpoint", // add here -} -``` - -### 3. Update the platform type - -Add your platform to the union type in `src/data/tracker-registry.ts`: - -```ts -platform: "unit3d" | "gazelle" | "ggn" | "myplatform" | "custom" -``` - -### 4. Create the registry entry - -Follow the registry entry steps above, setting `platform` to your new platform name. - -### Proxy support - -If you want proxy support, use `proxyFetch` from `src/lib/proxy.ts` when `options.proxyAgent` is set. See the UNIT3D adapter (`src/lib/adapters/unit3d.ts`) for the pattern — it falls back to regular `fetch` when no proxy is configured. - -### Byte parsing - -UNIT3D returns formatted byte strings like `"500.25 GiB"`. If your tracker's API does the same, use `parseBytes()` from `src/lib/parser.ts` to convert them to `bigint`. If the API returns raw byte counts, convert directly with `BigInt()`. diff --git a/docs/api-comparison-unit3d-gazelle.md b/docs/api-comparison-unit3d-gazelle.md deleted file mode 100644 index 940962b1..00000000 --- a/docs/api-comparison-unit3d-gazelle.md +++ /dev/null @@ -1,243 +0,0 @@ -# API Comparison: UNIT3D vs Gazelle vs GGn - -Reference document for adapter development. Compares the three tracker platform APIs and maps their fields to the shared `TrackerStats` interface. - ---- - -## Connection Details - -| Aspect | UNIT3D | Gazelle (RED/OPS) | GGn | -| ----------------------- | ----------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------- | -| **Base endpoint** | `/api/user` | `/ajax.php?action=index` | `/api.php?request=quick_user` + `/api.php?request=user&id=X` | -| **Auth method** | Query parameter: `?api_token=TOKEN` | Header: `Authorization: token TOKEN` | Query parameter: `?key=TOKEN` | -| **Response format** | Flat JSON object | Nested: `{ status, response: { userstats } }` | Nested: `{ status, response: { stats, personal, community } }` | -| **Status indication** | HTTP status codes only | HTTP codes + `status` field (`"success"` / `"failure"`) | HTTP codes + `status` field | -| **Byte representation** | Formatted strings (`"500.25 GiB"`) | Raw integers (bytes) | Raw integers (bytes) | -| **API calls per poll** | 1 | 1 | 2 (quick_user → user) | -| **Timeout** | 15s `AbortSignal` | 15s `AbortSignal` | 15s `AbortSignal` per call | - -## Field Mapping - -### Core Fields (always available) - -| TrackerStats field | UNIT3D source | Gazelle source | GGn source | Notes | -| ------------------ | ----------------------------- | ----------------------------------------- | ----------------------------------- | -------------------------------- | -| `username` | `username` (string) | `response.username` (string) | `response.username` (string) | Direct on all platforms | -| `group` | `group` (string) | `userstats.class` (string) | `personal.class` (string) | Different locations per platform | -| `uploadedBytes` | `uploaded` → `parseBytes()` | `userstats.uploaded` → `BigInt()` | `stats.uploaded` → `BigInt()` | UNIT3D needs string parsing | -| `downloadedBytes` | `downloaded` → `parseBytes()` | `userstats.downloaded` → `BigInt()` | `stats.downloaded` → `BigInt()` | UNIT3D needs string parsing | -| `ratio` | `ratio` → `parseFloat()` | `userstats.ratio` (number) | `stats.ratio` (string or number) | GGn full response returns string | -| `bufferBytes` | `buffer` → `parseBytes()` | Calculated: `uploaded - downloaded` | Calculated: `uploaded - downloaded` | Only UNIT3D provides directly | -| `seedbonus` | `seedbonus` → `parseFloat()` | `userstats.bonusPoints` or `.bonuspoints` | `stats.gold` (number) | GGn uses "gold" currency system | - -### Platform-Dependent Fields - -| TrackerStats field | UNIT3D | Gazelle (RED/OPS) | GGn | Notes | -| ------------------ | ------------------------ | -------------------------------------- | ------------------------------- | -------------------------------- | -| `seedingCount` | `seeding` (number) | `userstats.seedingcount` (optional) | `community.seeding` (nullable) | GGn depends on paranoia settings | -| `leechingCount` | `leeching` (number) | `userstats.leechingcount` (optional) | `community.leeching` (nullable) | GGn depends on paranoia settings | -| `hitAndRuns` | `hit_and_runs` (number) | **Not available** (0) | `personal.hnrs` (nullable) | Defaults to 0 when absent | -| `requiredRatio` | **Not available** (null) | `userstats.requiredratio` (number) | `stats.requiredRatio` (number) | UNIT3D returns null | -| `warned` | **Not available** (null) | **Requires 2nd call** (false) | `personal.warned` (boolean) | GGn gets it in the user call | -| `freeleechTokens` | **Not available** (null) | `userstats.freeleechTokens` (optional) | **Not available** (null) | GGn does not expose FL tokens | - -### Platform Capabilities Summary - -| Capability | UNIT3D | Gazelle | GGn | Status | -| ------------------------- | ------------ | -------------------- | ------------------ | ---------------------------------------- | -| Upload / Download / Ratio | Yes | Yes | Yes | **Implemented** | -| Buffer | Yes (direct) | Yes (calculated) | Yes (calculated) | **Implemented** | -| Seedbonus / Gold | Yes | Yes (most forks) | Yes (gold) | **Implemented** | -| Seeding / Leeching counts | Yes | Partial (some forks) | Paranoia-dependent | **Implemented** (defaults to 0) | -| Hit & Runs | Yes | No | Partial (nullable) | **Implemented** | -| Required Ratio | No | Yes | Yes | **Implemented** | -| Freeleech Tokens | No | Yes (some forks) | No | **Implemented** | -| Warned status | No | Needs 2nd call | Yes | **Implemented** (Gazelle defaults false) | -| Snatched count | No | Needs 2nd call | Available | **Not implemented** — future | -| Last access time | No | Needs 2nd call | Available | **Not implemented** — future | -| Upload/Download buffs | No | No | Yes (multipliers) | **Not implemented** — GGn-specific | - -## Response Shapes - -### UNIT3D `/api/user` - -```json -{ - "username": "JohnDoe", - "group": "Power User", - "uploaded": "500.25 GiB", - "downloaded": "125.50 GiB", - "ratio": "3.99", - "buffer": "374.75 GiB", - "seeding": 156, - "leeching": 2, - "seedbonus": "12500.00", - "hit_and_runs": 0 -} -``` - -### Gazelle `?action=index` - -```json -{ - "status": "success", - "response": { - "username": "JohnDoe", - "id": 12345, - "authkey": "...", - "passkey": "...", - "notifications": { - "messages": 0, - "notifications": 0, - "newAnnouncement": false, - "newBlog": false - }, - "userstats": { - "uploaded": 536870912000, - "downloaded": 134217728000, - "ratio": 4.0, - "requiredratio": 0.6, - "class": "Power User", - "bonusPoints": 12500, - "freeleechTokens": 3 - } - } -} -``` - -### GGn `?request=quick_user` - -```json -{ - "status": "success", - "response": { - "username": "thesneakyrobot", - "id": 74360, - "authkey": "...", - "passkey": "...", - "notifications": { "messages": 0, "notifications": 0, "newAnnouncement": 0 }, - "userstats": { - "uploaded": 372518353895, - "downloaded": 373640248681, - "ratio": 0.99, - "requiredratio": 0.012, - "class": "Elite Gamer" - } - } -} -``` - -### GGn `?request=user&id=X` - -```json -{ - "status": "success", - "response": { - "id": 12345, - "username": "ExampleUser", - "avatar": "", - "avatarType": 1, - "isFriend": false, - "bbProfileText": "", - "profileText": "", - "bbTitle": "", - "title": "", - "stats": { - "joinedDate": "2025-02-18 08:59:21", - "lastAccess": "2026-03-07 17:03:24", - "onIRC": true, - "uploaded": 372518353895, - "downloaded": 373640248681, - "fullDownloaded": 2854978605675, - "purchasedDownload": null, - "ratio": "0.99699", - "requiredRatio": 0.012, - "shareScore": 10.82, - "gold": 39858 - }, - "personal": { - "class": "Elite Gamer", - "facilitator": false, - "hnrs": null, - "paranoia": [], - "paranoiaText": "Off", - "donor": false, - "warned": false, - "enabled": true, - "publicKey": "", - "parked": false, - "ip": "xxx.xxx.xxx.xxx", - "passkey": "...", - "donated": "", - "invites": 2 - }, - "community": { - "clan": "None", - "profileViews": 14, - "hourlyGold": 9, - "posts": 2, - "actualPosts": 2, - "threads": null, - "forumLikes": 0, - "forumDislikes": 0, - "ircLines": 0, - "ircActualLines": null, - "torrentComments": 1, - "collections": null, - "requestsFilled": null, - "bountyEarnedUpload": null, - "bountyEarnedGold": null, - "requestsVoted": 11, - "bountySpentUpload": null, - "bountySpentGold": 260, - "reviews": 1, - "uploaded": null, - "seeding": null, - "leeching": null, - "snatched": 4425, - "uniqueSnatched": 4413, - "seedSize": null, - "invited": null - }, - "buffs": { - "Upload": 2, - "Download": 0.5, - "ForumPosts": 1, - "IRCLines": 1, - "IRCBonus": 2, - "CommunityXP": 1, - "TorrentsXP": 1, - "CommunityGold": 1.2, - "TorrentsGold": 1, - "ItemCost": 1, - "BountyFrom": 1, - "BountyOn": 1, - "Chance": 2 - }, - "achievements": { - "userLevel": "Elite Gamer", - "nextLevel": "Legendary Gamer", - "totalPoints": 2100, - "pointsToNextLvl": 900 - } - } -} -``` - -## Known Gazelle Fork Variations - -| Fork / Site | Platform | Auth Method | bonusPoints field | freeleechTokens | seedingcount in index | -| -------------------- | --------- | ------------ | ----------------- | --------------- | --------------------- | -| Redacted (RED) | `gazelle` | Header token | `bonusPoints` | Sometimes | No | -| Orpheus (OPS) | `gazelle` | Header token | `bonusPoints` | Sometimes | No | -| GazelleGames (GGn) | `ggn` | Query key | N/A (uses `gold`) | No | No (paranoia) | -| BroadcasTheNet (BTN) | `gazelle` | Header token | Varies | No | No | -| PassThePopcorn (PTP) | `gazelle` | Header token | Varies | No | No | -| AnimeBytes (AB) | `gazelle` | Header token | Varies | Varies | No | - -## Future Work - -- **Gazelle enrichment**: Add optional `?action=user&id=X` call to fetch warned/snatched for standard Gazelle sites -- **GGn buffs tracking**: Store upload/download multipliers for buffer projection -- **Per-site overrides**: Allow registry entries to specify custom field mappings -- **Rate limiting**: Some sites (especially RED) have strict API rate limits; adapter should respect them diff --git a/docs/kb/docs/contributing/adding-a-tracker.md b/docs/kb/docs/contributing/adding-a-tracker.md index e255060a..47ffc4bc 100644 --- a/docs/kb/docs/contributing/adding-a-tracker.md +++ b/docs/kb/docs/contributing/adding-a-tracker.md @@ -59,11 +59,13 @@ Here is the full template for reference: // // Validator checks: // - slug: lowercase letters and hyphens only -// - platform: "unit3d" | "gazelle" | "ggn" | "nebulance" | "custom" +// - platform: "unit3d" | "gazelle" | "ggn" | "nebulance" | "mam" | "custom" // - apiPath must match platform default: -// unit3d → "/api/user" -// gazelle → "/ajax.php" -// ggn → "/api.php" +// unit3d → "/api/user" +// gazelle → "/ajax.php" +// ggn → "/api.php" +// nebulance → "/api.php" +// mam → "/jsonLoad.php" // - url: https only // - contentCategories: values must come from the allowed list above // - language: required @@ -80,12 +82,12 @@ export const mytracker: TrackerRegistryEntry = { description: "TODO", // 1-2 sentence overview // ── Platform & API ────────────────────────────────────────────────── - platform: "unit3d", // "unit3d" | "gazelle" | "ggn" | "nebulance" | "custom" + platform: "unit3d", // "unit3d" | "gazelle" | "ggn" | "nebulance" | "mam" | "custom" // Platform-specific fields (uncomment for your platform): // gazelleAuthStyle: "token", // gazelle only — "token" | "raw" // gazelleEnrich: true, // gazelle only — enables enrichment call // unit3dAuthStyle: "bearer", // unit3d only — "bearer" | "query" - apiPath: "/api/user", // unit3d: "/api/user" | gazelle: "/ajax.php" | ggn: "/api.php" + apiPath: "/api/user", // unit3d: "/api/user" | gazelle: "/ajax.php" | ggn: "/api.php" | mam: "/jsonLoad.php" // ── Content ───────────────────────────────────────────────────────── specialty: "", // what the tracker is known for (e.g. "HD Movies", "Anime") @@ -206,7 +208,7 @@ description: "The largest general music tracker (also has some software). Has an #### `platform` -Type: `"unit3d" | "gazelle" | "ggn" | "nebulance" | "custom"` +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. @@ -216,6 +218,7 @@ Which adapter handles API requests for this tracker. This controls how the sched | `"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 | #### `gazelleAuthStyle` diff --git a/docs/kb/docs/contributing/tracker-responses-mam.md b/docs/kb/docs/contributing/tracker-responses-mam.md new file mode 100644 index 00000000..60b86d1f --- /dev/null +++ b/docs/kb/docs/contributing/tracker-responses-mam.md @@ -0,0 +1,361 @@ +# 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. + +## Endpoint + +One request per poll: + +``` +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. + +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. + +**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. + +## Authentication + +MAM uses a `mam_id` session cookie instead of an API key or authorization header: + +``` +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. + +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. + +## Example Response + +```json +{ + "username": "thesneakyrobot", + "uid": 230500, + "classname": "VIP", + "ratio": 26.7, + "uploaded": "5.125 TiB", + "downloaded": "196.47 GiB", + "uploaded_bytes": 5635036489461, + "downloaded_bytes": 210957629456, + "seedbonus": 99999, + "wedges": 207, + "vip_until": "2026-06-10 06:44:38", + "connectable": "offline", + "country_code": "us", + "country_name": "United States", + "created": 1774543581, + "update": 1774543581, + "ipv6_mac": false, + "v6_connectable": null, + "partial": false, + "recently_deleted": 45, + "leeching": { "name": "Leeching Torrents", "count": 0, "red": false, "size": null }, + "sSat": { "name": "Seeding - Satisfied", "count": 0, "red": false, "size": null }, + "seedHnr": { "name": "Seeding - H&R - Not Yet Satisfied", "count": 0, "red": true, "size": null }, + "seedUnsat": { "name": "Seeding - pre-H&R - Not Yet Satisfied", "count": 0, "red": false, "size": null }, + "upAct": { "name": "Seeding - Uploads", "count": 0, "red": false, "size": null }, + "upInact": { "name": "Not Seeding - Uploads", "count": 0, "red": false, "size": null }, + "inactHnr": { "name": "Not Seeding - H&R - Not Yet Satisfied", "count": 0, "red": true, "size": null }, + "inactSat": { "name": "Not Seeding - Satisfied", "count": 2306, "red": false, "size": 477079402691 }, + "inactUnsat": { "name": "Not Seeding - pre-H&R - Not Yet Satisfied", "count": 0, "red": true, "size": null }, + "unsat": { "name": "Unsatisfied", "count": 0, "red": false, "limit": 150, "size": null }, + "duplicates": { "name": "Duplicate peer entries", "count": 0, "red": true }, + "reseed": { "name": "Reseed requests", "count": 0, "inactive": 0, "red": false }, + "ite": { "name": "Important Tracker Errors", "count": 0, "latest": 0 } +} +``` + +## Field Mapping + +| TrackerStats field | MAM path | Type | Notes | +| --- | --- | --- | --- | +| `username` | `username` | `string` | Direct | +| `group` | `classname` | `string` | Falls back to `"Unknown"` | +| `remoteUserId` | `uid` | `number` | Cached for future use | +| `uploadedBytes` | `uploaded_bytes` | `number` | `BigInt()` — raw bytes, no parsing needed | +| `downloadedBytes` | `downloaded_bytes` | `number` | `BigInt()` — raw bytes, no parsing needed | +| `ratio` | `ratio` | `number` | Direct float value | +| `bufferBytes` | — | — | Calculated: `uploadedBytes - downloadedBytes` (min `0`) | +| `seedingCount` | `sSat.count + seedHnr.count + seedUnsat.count + upAct.count` | `number` | Sum of all seeding snatch categories | +| `leechingCount` | `leeching.count` | `number` | Direct | +| `seedbonus` | `seedbonus` | `number` | Direct; MAM caps at 99,999 | +| `hitAndRuns` | `inactHnr.count` | `number` | Only inactive (not seeding) HnR torrents | +| `requiredRatio` | — | — | Always `null` — MAM uses class-based ratio thresholds | +| `warned` | — | — | Always `null` — not exposed in API | +| `freeleechTokens` | `wedges` | `number` | MAM calls them "FL Wedges" | +| `joinedDate` | — | — | Not available from this endpoint | +| `lastAccessDate` | — | — | Not available from this endpoint | +| `platformMeta` | Multiple fields | — | `MamPlatformMeta` object (see below) | + +## Platform Meta (MamPlatformMeta) + +| Field | MAM path | Type | Notes | +| --- | --- | --- | --- | +| `vipUntil` | `vip_until` | `string \| null` | VIP expiry date (`"2026-06-10 06:44:38"`) | +| `connectable` | `connectable` | `string` | `"offline"` or `"online"` | +| `unsatisfiedCount` | `unsat.count` | `number` | Current unsatisfied torrent count | +| `unsatisfiedLimit` | `unsat.limit` | `number` | Class-dependent limit (User=50, PU=100, VIP=150, above VIP=200) | +| `inactiveSatisfiedCount` | `inactSat.count` | `number` | Completed torrents no longer seeding | +| `seedingHnrCount` | `seedHnr.count` | `number` | HnR torrents being actively resolved | +| `inactiveUnsatisfiedCount` | `inactUnsat.count` | `number` | Pre-HnR torrents not being seeded (ticking clock) | +| `trackerErrorCount` | `ite.count` | `number` | Important Tracker Errors | +| `recentlyDeleted` | `recently_deleted` | `number` | Recently deleted torrents | + +## Snatch Summary Categories + +MAM's snatch summary groups all torrents into categories based on seeding status and satisfaction: + +| Category | Field | Meaning | Red flag? | +| --- | --- | --- | --- | +| 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). + +## 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. + +**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. + +**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. + +**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. + +**`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. + +**`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. + +**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. + +## 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. + +### `/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. + +When called without `?id=`, `clientStats` is an empty array. When called with `?id={uid}` (your own user ID), it returns the full client breakdown: + +```json +{ + "clientStats": { + "uid": 230500, + "username": "thesneakyrobot", + "seedbonus": 99999, + "uploaded": "5.125 TiB", + "downloaded": "196.47 GiB", + "uploaded_bytes": 5635036489461, + "downloaded_bytes": 210957629456, + "classname": "VIP", + "wedges": 207, + "vip_until": "2026-06-10 06:44:38", + "ratio": 26.7, + "country_name": "United States", + "country_code": "us", + "clientStats": [ + { + "ip": "79.127.136.26", + "port": 37649, + "agent": "qBittorrent/5.1.4", + "connectable": "no", + "subResponse": "timeout", + "startTime": 1774550236, + "count": 17, + "lastcheck": 1774552228, + "timeTaken": 7166 + }, + { + "ip": "79.127.136.70", + "port": 42068, + "agent": "qBittorrent/5.1.4", + "connectable": "yes", + "subResponse": "Connect", + "startTime": 1774550262, + "count": 2106, + "lastcheck": 1774551105, + "timeTaken": 250 + } + ] + } +} +``` + +**Client entry fields:** + +| Field | Type | Description | +| --- | --- | --- | +| `ip` | string | IP address MAM sees the client connecting from | +| `port` | number | Port the client announces | +| `agent` | string | Torrent client user agent string | +| `connectable` | string | `"yes"` or `"no"` — whether MAM can reach the client | +| `subResponse` | string | `"Connect"` (success) or `"timeout"` (unreachable) | +| `startTime` | number | Unix timestamp of first seen | +| `count` | number | Number of torrents associated with this client entry | +| `lastcheck` | number | Unix timestamp of last connectivity check | +| `timeTaken` | number | Milliseconds for the connectivity check | + +Note: A single user can have multiple client entries across different IPs/ports (e.g., home connection + VPN + seedbox). The top-level `"connectable"` field on the main response is `"yes"` if ANY client entry is connectable. + +### `/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. + +The `notifs` object appears when `?notif` is included: + +```json +{ + "notifs": { + "pms": 0, + "aboutToDropClient": 0, + "tickets": 0, + "waiting_tickets": 0, + "requests": 0, + "topics": 0 + } +} +``` + +These fields are merged into the standard `/jsonLoad.php` response alongside the other user data fields. + +### `/json/userBonusHistory.php` + +Shows a history of bonus points and wedge transactions. 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` | + +**Example request:** + +``` +GET /json/userBonusHistory.php?type[]=giftWedge&type[]=wedgePF&type[]=wedgeGFL +``` + +**Example response:** + +```json +[ + { + "timestamp": 1644804607.0995, + "amount": -1, + "type": "wedgePF", + "tid": 330896, + "title": "1984", + "other_userid": null, + "other_name": null + }, + { + "timestamp": 1641914230.1078, + "amount": -1, + "type": "giftWedge", + "tid": null, + "title": null, + "other_userid": 192566, + "other_name": "pezzap7" + }, + { + "timestamp": 1610486584.1027, + "amount": -10, + "type": "wedgeGFL", + "tid": 220870, + "title": "Asimov's Robot, Empire, and Foundation Series", + "other_userid": null, + "other_name": null + } +] +``` + +**Transaction types:** + +| Type | Meaning | +| --- | --- | +| `giftPoints` | Bonus points gifted to/from another user | +| `giftWedge` | FL Wedge gifted to/from another user | +| `wedgePF` | Wedge spent on Personal Freeleech | +| `wedgeGFL` | Wedges spent on Staff Freeleech pick | +| `torrentThanks` | Points received from torrent "thank you" | +| `millionaires` | Wedges earned from Millionaire's Vault | + +### `/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`. + +**Rate limit:** Once per hour (rolling window). + +**Example response (success):** + +```json +{ + "Success": true, + "msg": "Completed", + "ip": "10.2.3.4", + "ASN": 1234, + "AS": "Org for 1234" +} +``` + +**Example response (rate limited):** + +```json +{ + "Success": false, + "msg": "Last change too recent", + "ip": "10.2.3.4", + "ASN": 1234, + "AS": "Org for 1234" +} +``` + +**Error codes:** + +| HTTP | Message | Meaning | +| --- | --- | --- | +| 200 | `No Change` | IP already set to this address | +| 200 | `Completed` | IP updated successfully | +| 429 | `Last change too recent` | Rate limited (1 hour window) | +| 403 | `No Session Cookie` | Missing `mam_id` cookie | +| 403 | `Invalid session` | Bad cookie value or IP/ASN mismatch | +| 403 | `Incorrect session type - not allowed this function` | Session lacks Dynamic Seedbox permission | +| 403 | `Incorrect session type - non-API session` | Browser session used instead of API session | + +### `/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. + +```json +{ + "ip": "10.2.3.4", + "ASN": 123, + "AS": "Some Provider Here" +} +``` + +## Supported Trackers + +- MyAnonaMouse diff --git a/docs/kb/docs/contributing/tracker-responses.md b/docs/kb/docs/contributing/tracker-responses.md index b37ee1e6..ee223c15 100644 --- a/docs/kb/docs/contributing/tracker-responses.md +++ b/docs/kb/docs/contributing/tracker-responses.md @@ -32,7 +32,7 @@ interface TrackerStats { lastAccessDate?: string shareScore?: number avatarUrl?: string - platformMeta?: GGnPlatformMeta | GazellePlatformMeta | NebulancePlatformMeta + platformMeta?: GGnPlatformMeta | GazellePlatformMeta | NebulancePlatformMeta | MamPlatformMeta } ``` @@ -43,6 +43,7 @@ Fields marked `null` in the platform pages mean the platform does not expose tha - [UNIT3D API Response](tracker-responses-unit3d.md) - [Gazelle API Response](tracker-responses-gazelle.md) - [GGn API Response](tracker-responses-ggn.md) +- [MAM API Response](tracker-responses-mam.md) --- diff --git a/docs/kb/docs/contributing/trackers/seedpool.md b/docs/kb/docs/contributing/trackers/seedpool.md index ccc48a7e..42029d5d 100644 --- a/docs/kb/docs/contributing/trackers/seedpool.md +++ b/docs/kb/docs/contributing/trackers/seedpool.md @@ -1,13 +1,13 @@ # Seed Pool (SP) -| Field | Value | -| ------------ | -------------------------------------- | -| Platform | UNIT3D | -| Base URL | `https://seedpool.org` | -| API Endpoint | `https://seedpool.org/api/user` | -| Auth Method | Query parameter: `?api_token=TOKEN` | -| Enrichment | N/A | -| Auth Style | N/A | +| Field | Value | +| ------------ | ----------------------------------- | +| Platform | UNIT3D | +| Base URL | `https://seedpool.org` | +| API Endpoint | `https://seedpool.org/api/user` | +| Auth Method | Query parameter: `?api_token=TOKEN` | +| Enrichment | N/A | +| Auth Style | N/A | ## Notes diff --git a/docs/kb/docs/features/backups.md b/docs/kb/docs/features/backups.md index b7a7c8a2..3ec49882 100644 --- a/docs/kb/docs/features/backups.md +++ b/docs/kb/docs/features/backups.md @@ -88,7 +88,7 @@ If you're restoring a backup from a different Tracker Tracker installation — o 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. !!! 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 on the source instance but can't be carried over, it will be disabled. Re-enroll in **Settings → Security** after the restore. ![Backup configuration — encryption, scheduling, and storage path](../assets/images/backups-configuration.png) diff --git a/docs/kb/docs/features/download-clients.md b/docs/kb/docs/features/download-clients.md index 0dfab7e5..a3dca79a 100644 --- a/docs/kb/docs/features/download-clients.md +++ b/docs/kb/docs/features/download-clients.md @@ -30,7 +30,7 @@ Go to **Settings → Download Clients** and fill in the connection details: | Use SSL | Enable if your qBittorrent Web UI is served over HTTPS | !!! warning "SSL/port mismatch" -The form will warn you if SSL is on with port 80, or SSL is off with port 443. These combinations are usually misconfigured. You can still save, but double-check your settings. + The form will warn you if SSL is on with port 80, or SSL is off with port 443. These combinations are usually misconfigured. You can still save, but double-check your settings. After saving, use the **Test Connection** button to confirm Tracker Tracker can reach and authenticate with qBittorrent. diff --git a/docs/kb/docs/features/proxies.md b/docs/kb/docs/features/proxies.md index d23abe0b..482d8775 100644 --- a/docs/kb/docs/features/proxies.md +++ b/docs/kb/docs/features/proxies.md @@ -6,7 +6,7 @@ description: Route tracker polling through SOCKS5, HTTP, or HTTPS proxies on a p # Proxies !!! warning "Experimental" -Proxy support is experimental and may not work with all trackers or proxy configurations. Use at your own risk. + 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. @@ -23,10 +23,10 @@ The proxy type controls how your traffic reaches the proxy server — SOCKS5 for When a proxy is enabled for a tracker, the tracker sees the proxy's IP address — not 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 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. !!! 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 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. ## Setup diff --git a/docs/kb/docs/features/qbitmanage.md b/docs/kb/docs/features/qbitmanage.md index 5cd522b6..ceeaf467 100644 --- a/docs/kb/docs/features/qbitmanage.md +++ b/docs/kb/docs/features/qbitmanage.md @@ -39,7 +39,7 @@ Here's what the qbitmanage status breakdown looks like on a tracker's Torrents t ![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 any emoji or special characters. Copy them directly from your qbitmanage `config.yml`. ## Tag Group Examples diff --git a/docs/kb/docs/features/tag-groups.md b/docs/kb/docs/features/tag-groups.md index 046b1a95..e721908d 100644 --- a/docs/kb/docs/features/tag-groups.md +++ b/docs/kb/docs/features/tag-groups.md @@ -61,7 +61,7 @@ When enabled, the chart includes an extra segment for torrents that don't match 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. !!! 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. + 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. ## Automating with qbitmanage diff --git a/docs/kb/docs/features/totp.md b/docs/kb/docs/features/totp.md index b2f4882f..ad657533 100644 --- a/docs/kb/docs/features/totp.md +++ b/docs/kb/docs/features/totp.md @@ -33,7 +33,7 @@ When you enable 2FA, you get 8 backup codes. Each one looks like this: 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. + 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 @@ -61,7 +61,7 @@ Failed TOTP attempts count toward the same lockout limit as failed password atte ## 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. + 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. diff --git a/docs/kb/docs/features/transit-papers.md b/docs/kb/docs/features/transit-papers.md index ddb14e3f..d6af957b 100644 --- a/docs/kb/docs/features/transit-papers.md +++ b/docs/kb/docs/features/transit-papers.md @@ -8,14 +8,14 @@ description: Generate tamper-resistant proof-of-membership images for private tr !!! danger "This feature is currently unreleased" !!! 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 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**. !!! 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. + 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). @@ -62,7 +62,7 @@ If you are not using an integration, share the original PNG file directly: - 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. + 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. --- @@ -136,7 +136,7 @@ Upload or drag-and-drop the PNG onto the verification page. : The image is too degraded or has been 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. + 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. --- @@ -174,7 +174,7 @@ Verification confirms the image is internally consistent and has not been tamper | 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 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. --- diff --git a/docs/kb/docs/features/webhooks.md b/docs/kb/docs/features/webhooks.md index fac917f0..8fbd1d99 100644 --- a/docs/kb/docs/features/webhooks.md +++ b/docs/kb/docs/features/webhooks.md @@ -59,7 +59,7 @@ Each target subscribes to any combination of these events: Cooldowns prevent spam — if a condition persists across multiple polls, you get one alert per cooldown period, not one per 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. + 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. ## Thresholds diff --git a/docs/kb/docs/getting-started/docker-config.md b/docs/kb/docs/getting-started/docker-config.md index ff5bf27f..4a79db4d 100644 --- a/docs/kb/docs/getting-started/docker-config.md +++ b/docs/kb/docs/getting-started/docker-config.md @@ -20,21 +20,22 @@ Everything you need to customize how Tracker Tracker runs: environment variables ### 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. | -| `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. 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`. | !!! 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. + 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. --- @@ -49,7 +50,7 @@ Most day-to-day settings — polling interval, privacy mode, proxy config, backu | `./postgres/postgresql.conf` | `/etc/postgresql/postgresql.conf` | Custom PostgreSQL config. Included in the repo and required for the bundled database to start. | !!! 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. + 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. --- @@ -69,7 +70,7 @@ 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. + 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. --- @@ -143,7 +144,7 @@ If Tracker Tracker sits behind Nginx, Caddy, or Traefik, you don't need to expos 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` so backup files and notification links use your public address instead of localhost. + 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. --- @@ -169,7 +170,7 @@ docker compose pull && docker compose up -d The database schema updates automatically on startup. No manual steps required. !!! 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. + 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. To pin to a specific version and update deliberately: diff --git a/docs/kb/docs/getting-started/first-setup.md b/docs/kb/docs/getting-started/first-setup.md index a0182ec7..c52958fb 100644 --- a/docs/kb/docs/getting-started/first-setup.md +++ b/docs/kb/docs/getting-started/first-setup.md @@ -19,10 +19,10 @@ Your password does two things: 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 "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. + 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 stays on your machine" -It's hashed on the server before being stored. The raw password is never saved anywhere. + 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. @@ -41,7 +41,7 @@ The left sidebar has three sections: 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**. + 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**. --- diff --git a/docs/kb/docs/getting-started/installation.md b/docs/kb/docs/getting-started/installation.md index a66e544f..072783e7 100644 --- a/docs/kb/docs/getting-started/installation.md +++ b/docs/kb/docs/getting-started/installation.md @@ -15,7 +15,7 @@ Tracker Tracker runs as a Docker image. The easiest way to get it running is wit Nothing else needs to be installed on your host. !!! 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. + 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. --- @@ -53,7 +53,7 @@ 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`. + `SESSION_SECRET` protects your session cookies. `POSTGRES_PASSWORD` protects your database. Generate fresh values — never copy the placeholder text from `.env.example`. ## Step 4 — Start the stack @@ -149,4 +149,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. + You only need to create the database itself beforehand. The app handles the rest on first startup — no manual SQL required. diff --git a/docs/kb/docs/reference/platform-differences.md b/docs/kb/docs/reference/platform-differences.md index 55d105da..3a7b25f2 100644 --- a/docs/kb/docs/reference/platform-differences.md +++ b/docs/kb/docs/reference/platform-differences.md @@ -5,7 +5,7 @@ description: Stat availability and behavior differences across UNIT3D, Gazelle, # Platform Differences -Tracker Tracker supports three tracker platforms: **UNIT3D**, **Gazelle**, and **GGn**. 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 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. --- @@ -18,6 +18,7 @@ How you authenticate with each platform's API depends on the platform type. In a | **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. | --- @@ -25,26 +26,26 @@ How you authenticate with each platform's API depends on the platform type. In a 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 | -| ------------------------- | ------------------------ | -------------------------------- | -------------------------------- | -| Upload / Download / Ratio | Yes | Yes | Yes | -| Buffer | Yes (tracker-calculated) | Approximate (calculated locally) | Approximate (calculated locally) | -| Seeding count | Yes | Some forks only | Paranoia-dependent | -| Leeching count | Yes | Some forks only | Paranoia-dependent | -| Seedbonus / Bonus Points | Yes | Yes (most forks) | Yes (called "gold") | -| Required Ratio | No | Yes | Yes | -| Hit & Runs | Yes | No | Partial (may be null) | -| Freeleech Tokens | No | Some forks only | No | -| Warned status | No | Some sites only | Yes | -| Class / Rank | Yes | Yes | Yes | -| Join date | No | Some sites only | Yes | -| Last access date | No | Some sites only | Yes | -| Share Score | No | No | Yes | -| Donor status | No | Some sites only | Yes | -| Snatched count | No | Some sites only | Yes | -| Community / rank data | No | Some sites only | Yes | -| Upload / download buffs | No | No | Yes | -| Avatar | No | Some sites only | No | +| 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 on specific cells @@ -108,3 +109,17 @@ GGn's full user profile includes: - Total and unique snatch counts - Active multiplier buffs (upload, download, forum posts, etc.) - Achievement level, points, and progress toward next level + +### MAM (MyAnonaMouse) + +MAM uses a single `/jsonLoad.php` endpoint with `?snatch_summary` to return everything in one call. MAM-specific extras include: + +- VIP status and expiry date +- Connectivity status (connectable/offline) +- Unsatisfied torrent count and limit (class-dependent: User=50, PU=100, VIP=150, above VIP=200) +- Detailed snatch summary breakdown (seeding satisfied, seeding HnR, inactive satisfied, etc.) +- Tracker error count (important tracker errors) +- 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. diff --git a/docs/kb/docs/reference/settings.md b/docs/kb/docs/reference/settings.md index a9be5140..ad296585 100644 --- a/docs/kb/docs/reference/settings.md +++ b/docs/kb/docs/reference/settings.md @@ -90,3 +90,4 @@ Notification targets are configured individually. Each target is an independent - **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. diff --git a/docs/kb/docs/trackers/adding-a-tracker.md b/docs/kb/docs/trackers/adding-a-tracker.md index 595f4111..d4481e91 100644 --- a/docs/kb/docs/trackers/adding-a-tracker.md +++ b/docs/kb/docs/trackers/adding-a-tracker.md @@ -61,7 +61,7 @@ Click the **+** button next to "Trackers" in the sidebar, or go to `/trackers/ne | Nebulance | NBL | !!! 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 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. Trackers you've already added are hidden from the list automatically. @@ -101,7 +101,7 @@ Where to find it depends on the platform: GGn keys don't expire on their own, but they can be regenerated 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. + Your API token acts like a password for your account. Tracker Tracker encrypts it before storing it. ## Optional fields @@ -121,7 +121,7 @@ If the tracker needs a proxy (e.g., for geo-restrictions), toggle **Use Proxy** ![PulseDot states showing healthy, warning, and error](../assets/images/pulsedots-states.png) !!! 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. + Check your API token. The most common cause is a copy-paste error or a token that was rotated after you copied it. ## Polling manually diff --git a/docs/kb/docs/troubleshooting/common-errors.md b/docs/kb/docs/troubleshooting/common-errors.md index df447152..bdb14e44 100644 --- a/docs/kb/docs/troubleshooting/common-errors.md +++ b/docs/kb/docs/troubleshooting/common-errors.md @@ -99,7 +99,7 @@ These appear in the **Poll Error Banner** on the tracker detail page, under the **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. + 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. --- @@ -122,7 +122,7 @@ These appear on the **Download Clients** panel in Settings, on individual client **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. + 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. --- @@ -139,7 +139,7 @@ Session expiry is handled transparently. If you see persistent errors in the **D **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. + 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. --- @@ -148,7 +148,7 @@ Use the container name (if qBittorrent is in the same Docker Compose stack) or a **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. + Wait for qBittorrent to finish processing and retry. If timeouts are persistent, check qBittorrent's CPU and memory usage. --- @@ -161,7 +161,16 @@ Wait for qBittorrent to finish processing and retry. If timeouts are persistent, **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. + 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. --- @@ -178,7 +187,7 @@ Wait for the lockout duration to expire — the remaining time is shown on the l **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: + 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; @@ -197,7 +206,7 @@ There is no in-app recovery path for this situation. You will need to connect to **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://`. + Prefix the URL with `https://` or `http://`. --- @@ -206,4 +215,4 @@ Prefix the URL with `https://` or `http://`. **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. + 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 index 4359d59c..a63bbccd 100644 --- a/docs/kb/docs/troubleshooting/ratio-not-updating.md +++ b/docs/kb/docs/troubleshooting/ratio-not-updating.md @@ -16,7 +16,7 @@ Go to **Settings → General** and find the **Tracker Poll Interval** field. The 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. + 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. --- @@ -82,4 +82,4 @@ If you have configured a **Snapshot Retention** period in Settings → General, 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. + 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 index db485b05..e1dd772d 100644 --- a/docs/kb/docs/troubleshooting/tracker-offline.md +++ b/docs/kb/docs/troubleshooting/tracker-offline.md @@ -38,7 +38,7 @@ When this happens: 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. + 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. --- @@ -91,7 +91,8 @@ 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. +!!! 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. --- @@ -101,7 +102,8 @@ The connection was established but the tracker API did not respond within 15 sec **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. +!!! 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. --- @@ -111,7 +113,8 @@ Some trackers block repeated requests from a single IP if polls come in too freq **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. +!!! 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. --- @@ -120,4 +123,4 @@ Some trackers block repeated requests from a single IP if polls come in too freq 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. + 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 79d2f96a..8efeb1fc 100644 --- a/docs/kb/mkdocs.yml +++ b/docs/kb/mkdocs.yml @@ -69,6 +69,7 @@ nav: - UNIT3D: contributing/tracker-responses-unit3d.md - Gazelle: contributing/tracker-responses-gazelle.md - GGn: contributing/tracker-responses-ggn.md + - MAM: contributing/tracker-responses-mam.md - Individual Trackers: - Aither: contributing/trackers/aither.md - AlphaRatio: contributing/trackers/alpharatio.md diff --git a/package.json b/package.json index 09df15c7..37acf9ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "private-tracker-tracker", - "version": "2.4.1", + "version": "2.6.0", "description": "Self-hosted dashboard for monitoring private tracker stats over time", "license": "GPL-3.0", "repository": { @@ -48,7 +48,7 @@ "@dnd-kit/utilities": "^3.2.2", "@formkit/auto-animate": "^0.9.0", "@tailwindcss/typography": "^0.5.19", - "@tanstack/react-query": "^5.91.2", + "@tanstack/react-query": "^5.95.2", "argon2": "^0.44.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -59,8 +59,8 @@ "echarts-gl": "^2.0.9", "emoji-picker-react": "^4.18.0", "https-proxy-agent": "^8.0.0", - "jose": "^6.2.1", - "next": "16.2.0", + "jose": "^6.2.2", + "next": "16.2.1", "node-cron": "^4.2.1", "otpauth": "^9.5.0", "pino": "^10.3.1", @@ -75,7 +75,7 @@ "socks-proxy-agent": "^9.0.0" }, "devDependencies": { - "@biomejs/biome": "^2.4.8", + "@biomejs/biome": "^2.4.9", "@commitlint/cli": "^20.5.0", "@commitlint/config-conventional": "^20.5.0", "@tailwindcss/postcss": "^4.2.2", @@ -90,12 +90,12 @@ "dotenv": "^17.3.1", "husky": "^9.1.7", "jsdom": "^29.0.1", - "knip": "^6.0.2", + "knip": "^6.0.6", "postcss": "^8.5.8", "prettier": "^3.8.1", "tailwindcss": "^4.2.2", "tsx": "^4.21.0", - "typescript": "^5.9.3", - "vitest": "^4.1.0" + "typescript": "^6.0.2", + "vitest": "^4.1.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 172ef70c..e1afbeee 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.91.2 - version: 5.91.2(react@19.2.4) + specifier: ^5.95.2 + version: 5.95.2(react@19.2.4) argon2: specifier: ^0.44.0 version: 0.44.0 @@ -60,11 +60,11 @@ importers: specifier: ^8.0.0 version: 8.0.0 jose: - specifier: ^6.2.1 - version: 6.2.1 + specifier: ^6.2.2 + version: 6.2.2 next: - specifier: 16.2.0 - version: 16.2.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 16.2.1 + version: 16.2.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) node-cron: specifier: ^4.2.1 version: 4.2.1 @@ -103,11 +103,11 @@ importers: version: 9.0.0 devDependencies: '@biomejs/biome': - specifier: ^2.4.8 - version: 2.4.8 + specifier: ^2.4.9 + version: 2.4.9 '@commitlint/cli': specifier: ^20.5.0 - version: 20.5.0(@types/node@25.5.0)(conventional-commits-parser@6.3.0)(typescript@5.9.3) + version: 20.5.0(@types/node@25.5.0)(conventional-commits-parser@6.3.0)(typescript@6.0.2) '@commitlint/config-conventional': specifier: ^20.5.0 version: 20.5.0 @@ -148,8 +148,8 @@ importers: specifier: ^29.0.1 version: 29.0.1(@noble/hashes@2.0.1) knip: - specifier: ^6.0.2 - version: 6.0.2 + specifier: ^6.0.6 + version: 6.0.6 postcss: specifier: ^8.5.8 version: 8.5.8 @@ -163,11 +163,11 @@ importers: specifier: ^4.21.0 version: 4.21.0 typescript: - specifier: ^5.9.3 - version: 5.9.3 + specifier: ^6.0.2 + version: 6.0.2 vitest: - specifier: ^4.1.0 - version: 4.1.0(@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.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)) packages: @@ -201,59 +201,59 @@ packages: resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} - '@biomejs/biome@2.4.8': - resolution: {integrity: sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA==} + '@biomejs/biome@2.4.9': + resolution: {integrity: sha512-wvZW92FrwitTcacvCBT8xdAbfbxWfDLwjYMmU3djjqQTh7Ni4ZdiWIT/x5VcZ+RQuxiKzIOzi5D+dcyJDFZMsA==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.4.8': - resolution: {integrity: sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ==} + '@biomejs/cli-darwin-arm64@2.4.9': + resolution: {integrity: sha512-d5G8Gf2RpH5pYwiHLPA+UpG3G9TLQu4WM+VK6sfL7K68AmhcEQ9r+nkj/DvR/GYhYox6twsHUtmWWWIKfcfQQA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.4.8': - resolution: {integrity: sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ==} + '@biomejs/cli-darwin-x64@2.4.9': + resolution: {integrity: sha512-LNCLNgqDMG7BLdc3a8aY/dwKPK7+R8/JXJoXjCvZh2gx8KseqBdFDKbhrr7HCWF8SzNhbTaALhTBoh/I6rf9lA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.4.8': - resolution: {integrity: sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA==} + '@biomejs/cli-linux-arm64-musl@2.4.9': + resolution: {integrity: sha512-8RCww5xnPn2wpK4L/QDGDOW0dq80uVWfppPxHIUg6mOs9B6gRmqPp32h1Ls3T8GnW8Wo5A8u7vpTwz4fExN+sw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [musl] - '@biomejs/cli-linux-arm64@2.4.8': - resolution: {integrity: sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig==} + '@biomejs/cli-linux-arm64@2.4.9': + resolution: {integrity: sha512-4adnkAUi6K4C/emPRgYznMOcLlUqZdXWM6aIui4VP4LraE764g6Q4YguygnAUoxKjKIXIWPteKMgRbN0wsgwcg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [glibc] - '@biomejs/cli-linux-x64-musl@2.4.8': - resolution: {integrity: sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw==} + '@biomejs/cli-linux-x64-musl@2.4.9': + resolution: {integrity: sha512-5TD+WS9v5vzXKzjetF0hgoaNFHMcpQeBUwKKVi3JbG1e9UCrFuUK3Gt185fyTzvRdwYkJJEMqglRPjmesmVv4A==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [musl] - '@biomejs/cli-linux-x64@2.4.8': - resolution: {integrity: sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw==} + '@biomejs/cli-linux-x64@2.4.9': + resolution: {integrity: sha512-L10na7POF0Ks/cgLFNF1ZvIe+X4onLkTi5oP9hY+Rh60Q+7fWzKDDCeGyiHUFf1nGIa9dQOOUPGe2MyYg8nMSQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [glibc] - '@biomejs/cli-win32-arm64@2.4.8': - resolution: {integrity: sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ==} + '@biomejs/cli-win32-arm64@2.4.9': + resolution: {integrity: sha512-aDZr0RBC3sMGJOU10BvG7eZIlWLK/i51HRIfScE2lVhfts2dQTreowLiJJd+UYg/tHKxS470IbzpuKmd0MiD6g==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.4.8': - resolution: {integrity: sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg==} + '@biomejs/cli-win32-x64@2.4.9': + resolution: {integrity: sha512-NS4g/2G9SoQ4ktKtz31pvyc/rmgzlcIDCGU/zWbmHJAqx6gcRj2gj5Q/guXhoWTzCUaQZDIqiCQXHS7BcGYc0w==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -367,8 +367,8 @@ packages: peerDependencies: '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-syntax-patches-for-csstree@1.1.1': - resolution: {integrity: sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==} + '@csstools/css-syntax-patches-for-csstree@1.1.2': + resolution: {integrity: sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==} peerDependencies: css-tree: ^3.2.1 peerDependenciesMeta: @@ -768,57 +768,57 @@ packages: '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} - '@next/env@16.2.0': - resolution: {integrity: sha512-OZIbODWWAi0epQRCRjNe1VO45LOFBzgiyqmTLzIqWq6u1wrxKnAyz1HH6tgY/Mc81YzIjRPoYsPAEr4QV4l9TA==} + '@next/env@16.2.1': + resolution: {integrity: sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==} - '@next/swc-darwin-arm64@16.2.0': - resolution: {integrity: sha512-/JZsqKzKt01IFoiLLAzlNqys7qk2F3JkcUhj50zuRhKDQkZNOz9E5N6wAQWprXdsvjRP4lTFj+/+36NSv5AwhQ==} + '@next/swc-darwin-arm64@16.2.1': + resolution: {integrity: sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.0': - resolution: {integrity: sha512-/hV8erWq4SNlVgglUiW5UmQ5Hwy5EW/AbbXlJCn6zkfKxTy/E/U3V8U1Ocm2YCTUoFgQdoMxRyRMOW5jYy4ygg==} + '@next/swc-darwin-x64@16.2.1': + resolution: {integrity: sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.0': - resolution: {integrity: sha512-GkjL/Q7MWOwqWR9zoxu1TIHzkOI2l2BHCf7FzeQG87zPgs+6WDh+oC9Sw9ARuuL/FUk6JNCgKRkA6rEQYadUaw==} + '@next/swc-linux-arm64-gnu@16.2.1': + resolution: {integrity: sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.2.0': - resolution: {integrity: sha512-1ffhC6KY5qWLg5miMlKJp3dZbXelEfjuXt1qcp5WzSCQy36CV3y+JT7OC1WSFKizGQCDOcQbfkH/IjZP3cdRNA==} + '@next/swc-linux-arm64-musl@16.2.1': + resolution: {integrity: sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.2.0': - resolution: {integrity: sha512-FmbDcZQ8yJRq93EJSL6xaE0KK/Rslraf8fj1uViGxg7K4CKBCRYSubILJPEhjSgZurpcPQq12QNOJQ0DRJl6Hg==} + '@next/swc-linux-x64-gnu@16.2.1': + resolution: {integrity: sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.2.0': - resolution: {integrity: sha512-HzjIHVkmGAwRbh/vzvoBWWEbb8BBZPxBvVbDQDvzHSf3D8RP/4vjw7MNLDXFF9Q1WEzeQyEj2zdxBtVAHu5Oyw==} + '@next/swc-linux-x64-musl@16.2.1': + resolution: {integrity: sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.2.0': - resolution: {integrity: sha512-UMiFNQf5H7+1ZsZPxEsA064WEuFbRNq/kEXyepbCnSErp4f5iut75dBA8UeerFIG3vDaQNOfCpevnERPp2V+nA==} + '@next/swc-win32-arm64-msvc@16.2.1': + resolution: {integrity: sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.0': - resolution: {integrity: sha512-DRrNJKW+/eimrZgdhVN1uvkN1OI4j6Lpefwr44jKQ0YQzztlmOBUUzHuV5GxOMPK3nmodAYElUVCY8ZXo/IWeA==} + '@next/swc-win32-x64-msvc@16.2.1': + resolution: {integrity: sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1333,11 +1333,11 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' - '@tanstack/query-core@5.91.2': - resolution: {integrity: sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw==} + '@tanstack/query-core@5.95.2': + resolution: {integrity: sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==} - '@tanstack/react-query@5.91.2': - resolution: {integrity: sha512-GClLPzbM57iFXv+FlvOUL56XVe00PxuTaVEyj1zAObhRiKF008J5vedmaq7O6ehs+VmPHe8+PUQhMuEyv8d9wQ==} + '@tanstack/react-query@5.95.2': + resolution: {integrity: sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==} peerDependencies: react: ^18 || ^19 @@ -1379,8 +1379,8 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@types/debug@4.1.12': - resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -1429,34 +1429,34 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - '@vitest/expect@4.1.0': - resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==} + '@vitest/expect@4.1.2': + resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==} - '@vitest/mocker@4.1.0': - resolution: {integrity: sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==} + '@vitest/mocker@4.1.2': + resolution: {integrity: sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@4.1.0': - resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==} + '@vitest/pretty-format@4.1.2': + resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==} - '@vitest/runner@4.1.0': - resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==} + '@vitest/runner@4.1.2': + resolution: {integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==} - '@vitest/snapshot@4.1.0': - resolution: {integrity: sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==} + '@vitest/snapshot@4.1.2': + resolution: {integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==} - '@vitest/spy@4.1.0': - resolution: {integrity: sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==} + '@vitest/spy@4.1.2': + resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==} - '@vitest/utils@4.1.0': - resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==} + '@vitest/utils@4.1.2': + resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==} JSONStream@1.3.5: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} @@ -1523,8 +1523,8 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - baseline-browser-mapping@2.10.9: - resolution: {integrity: sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==} + baseline-browser-mapping@2.10.11: + resolution: {integrity: sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==} engines: {node: '>=6.0.0'} hasBin: true @@ -1553,8 +1553,8 @@ packages: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} - caniuse-lite@1.0.30001780: - resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==} + caniuse-lite@1.0.30001781: + resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -2026,8 +2026,8 @@ packages: fast-xml-builder@1.1.4: resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} - fast-xml-parser@5.5.7: - resolution: {integrity: sha512-LteOsISQ2GEiDHZch6L9hB0+MLoYVLToR7xotrzU0opCICBkxOPgHAy1HxAvtxfJNXDJpgAsQN30mkrfpO2Prg==} + fast-xml-parser@5.5.9: + resolution: {integrity: sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==} hasBin: true fastq@1.20.1: @@ -2094,9 +2094,6 @@ packages: engines: {node: '>=6.9.0'} hasBin: true - get-tsconfig@4.13.6: - resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} - get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} @@ -2135,8 +2132,8 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - handlebars@4.7.8: - resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} engines: {node: '>=0.4.7'} hasBin: true @@ -2276,8 +2273,8 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true - jose@6.2.1: - resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==} + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} @@ -2319,8 +2316,8 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} - knip@6.0.2: - resolution: {integrity: sha512-W17Bo5N9AYn0ZkgWHGBmK/01SrSmr3B6iStr3zudDa2eqi+Kc8VmPjSpTYKDV2Uy/kojrlcH/gS1wypAXfXRRA==} + knip@6.0.6: + resolution: {integrity: sha512-PA+r1mTDLHH3eShlffn2ZDyH1hHvmgDj7JsTP3JKuhV/jZTyHbRkGcOd+uaSxfJZmcZyOE5zw3naP33WllTIlA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -2648,8 +2645,8 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - next@16.2.0: - resolution: {integrity: sha512-NLBVrJy1pbV1Yn00L5sU4vFyAHt5XuSjzrNyFnxo6Com0M0KrL6hHM5B99dbqXb2bE9pm4Ow3Zl1xp6HVY9edQ==} + next@16.2.1: + resolution: {integrity: sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -2669,8 +2666,8 @@ packages: sass: optional: true - node-addon-api@8.6.0: - resolution: {integrity: sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==} + node-addon-api@8.7.0: + resolution: {integrity: sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==} engines: {node: ^18 || ^20 || >= 21} node-cron@4.2.1: @@ -2770,8 +2767,8 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} - path-expression-matcher@1.1.3: - resolution: {integrity: sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==} + path-expression-matcher@1.2.0: + resolution: {integrity: sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==} engines: {node: '>=14.0.0'} path-key@3.1.1: @@ -2791,14 +2788,10 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} - picomatch@4.0.4: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} @@ -3121,8 +3114,8 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} - strnum@2.2.0: - resolution: {integrity: sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==} + strnum@2.2.2: + resolution: {integrity: sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==} style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -3157,8 +3150,8 @@ packages: tailwindcss@4.2.2: resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} - tapable@2.3.0: - resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + tapable@2.3.2: + resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} engines: {node: '>=6'} text-extensions@1.9.0: @@ -3245,8 +3238,8 @@ packages: typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + typescript@6.0.2: + resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==} engines: {node: '>=14.17'} hasBin: true @@ -3262,8 +3255,8 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - undici@7.24.5: - resolution: {integrity: sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==} + undici@7.24.6: + resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==} engines: {node: '>=20.18.1'} unified@11.0.5: @@ -3336,21 +3329,21 @@ packages: yaml: optional: true - vitest@4.1.0: - resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==} + vitest@4.1.2: + resolution: {integrity: sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==} 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.0 - '@vitest/browser-preview': 4.1.0 - '@vitest/browser-webdriverio': 4.1.0 - '@vitest/ui': 4.1.0 + '@vitest/browser-playwright': 4.1.2 + '@vitest/browser-preview': 4.1.2 + '@vitest/browser-webdriverio': 4.1.2 + '@vitest/ui': 4.1.2 happy-dom: '*' jsdom: '*' - vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -3429,11 +3422,6 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - yaml@2.8.2: - resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} - engines: {node: '>= 14.6'} - hasBin: true - yaml@2.8.3: resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} engines: {node: '>= 14.6'} @@ -3505,50 +3493,50 @@ snapshots: '@babel/runtime@7.29.2': {} - '@biomejs/biome@2.4.8': + '@biomejs/biome@2.4.9': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.4.8 - '@biomejs/cli-darwin-x64': 2.4.8 - '@biomejs/cli-linux-arm64': 2.4.8 - '@biomejs/cli-linux-arm64-musl': 2.4.8 - '@biomejs/cli-linux-x64': 2.4.8 - '@biomejs/cli-linux-x64-musl': 2.4.8 - '@biomejs/cli-win32-arm64': 2.4.8 - '@biomejs/cli-win32-x64': 2.4.8 + '@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.8': + '@biomejs/cli-darwin-arm64@2.4.9': optional: true - '@biomejs/cli-darwin-x64@2.4.8': + '@biomejs/cli-darwin-x64@2.4.9': optional: true - '@biomejs/cli-linux-arm64-musl@2.4.8': + '@biomejs/cli-linux-arm64-musl@2.4.9': optional: true - '@biomejs/cli-linux-arm64@2.4.8': + '@biomejs/cli-linux-arm64@2.4.9': optional: true - '@biomejs/cli-linux-x64-musl@2.4.8': + '@biomejs/cli-linux-x64-musl@2.4.9': optional: true - '@biomejs/cli-linux-x64@2.4.8': + '@biomejs/cli-linux-x64@2.4.9': optional: true - '@biomejs/cli-win32-arm64@2.4.8': + '@biomejs/cli-win32-arm64@2.4.9': optional: true - '@biomejs/cli-win32-x64@2.4.8': + '@biomejs/cli-win32-x64@2.4.9': 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@5.9.3)': + '@commitlint/cli@20.5.0(@types/node@25.5.0)(conventional-commits-parser@6.3.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@5.9.3) + '@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/types': 20.5.0 tinyexec: 1.0.4 @@ -3597,14 +3585,14 @@ snapshots: '@commitlint/rules': 20.5.0 '@commitlint/types': 20.5.0 - '@commitlint/load@20.5.0(@types/node@25.5.0)(typescript@5.9.3)': + '@commitlint/load@20.5.0(@types/node@25.5.0)(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@5.9.3) - cosmiconfig-typescript-loader: 6.2.0(@types/node@25.5.0)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3) + 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) is-plain-obj: 4.1.0 lodash.mergewith: 4.6.2 picocolors: 1.1.1 @@ -3684,7 +3672,7 @@ snapshots: dependencies: '@csstools/css-tokenizer': 4.0.0 - '@csstools/css-syntax-patches-for-csstree@1.1.1(css-tree@3.2.1)': + '@csstools/css-syntax-patches-for-csstree@1.1.2(css-tree@3.2.1)': optionalDependencies: css-tree: 3.2.1 @@ -3954,30 +3942,30 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@next/env@16.2.0': {} + '@next/env@16.2.1': {} - '@next/swc-darwin-arm64@16.2.0': + '@next/swc-darwin-arm64@16.2.1': optional: true - '@next/swc-darwin-x64@16.2.0': + '@next/swc-darwin-x64@16.2.1': optional: true - '@next/swc-linux-arm64-gnu@16.2.0': + '@next/swc-linux-arm64-gnu@16.2.1': optional: true - '@next/swc-linux-arm64-musl@16.2.0': + '@next/swc-linux-arm64-musl@16.2.1': optional: true - '@next/swc-linux-x64-gnu@16.2.0': + '@next/swc-linux-x64-gnu@16.2.1': optional: true - '@next/swc-linux-x64-musl@16.2.0': + '@next/swc-linux-x64-musl@16.2.1': optional: true - '@next/swc-win32-arm64-msvc@16.2.0': + '@next/swc-win32-arm64-msvc@16.2.1': optional: true - '@next/swc-win32-x64-msvc@16.2.0': + '@next/swc-win32-x64-msvc@16.2.1': optional: true '@noble/hashes@2.0.1': {} @@ -4285,11 +4273,11 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.2.2 - '@tanstack/query-core@5.91.2': {} + '@tanstack/query-core@5.95.2': {} - '@tanstack/react-query@5.91.2(react@19.2.4)': + '@tanstack/react-query@5.95.2(react@19.2.4)': dependencies: - '@tanstack/query-core': 5.91.2 + '@tanstack/query-core': 5.95.2 react: 19.2.4 '@testing-library/dom@10.4.1': @@ -4338,7 +4326,7 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 - '@types/debug@4.1.12': + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -4384,44 +4372,44 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitest/expect@4.1.0': + '@vitest/expect@4.1.2': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.0 - '@vitest/utils': 4.1.0 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.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))': + '@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))': dependencies: - '@vitest/spy': 4.1.0 + '@vitest/spy': 4.1.2 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) - '@vitest/pretty-format@4.1.0': + '@vitest/pretty-format@4.1.2': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.0': + '@vitest/runner@4.1.2': dependencies: - '@vitest/utils': 4.1.0 + '@vitest/utils': 4.1.2 pathe: 2.0.3 - '@vitest/snapshot@4.1.0': + '@vitest/snapshot@4.1.2': dependencies: - '@vitest/pretty-format': 4.1.0 - '@vitest/utils': 4.1.0 + '@vitest/pretty-format': 4.1.2 + '@vitest/utils': 4.1.2 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.0': {} + '@vitest/spy@4.1.2': {} - '@vitest/utils@4.1.0': + '@vitest/utils@4.1.2': dependencies: - '@vitest/pretty-format': 4.1.0 + '@vitest/pretty-format': 4.1.2 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -4457,7 +4445,7 @@ snapshots: dependencies: '@phc/format': 1.0.0 cross-env: 10.1.0 - node-addon-api: 8.6.0 + node-addon-api: 8.7.0 node-gyp-build: 4.8.4 argparse@2.0.1: {} @@ -4480,7 +4468,7 @@ snapshots: balanced-match@1.0.2: {} - baseline-browser-mapping@2.10.9: {} + baseline-browser-mapping@2.10.11: {} bidi-js@1.0.3: dependencies: @@ -4507,7 +4495,7 @@ snapshots: camelcase@5.3.1: {} - caniuse-lite@1.0.30001780: {} + caniuse-lite@1.0.30001781: {} ccount@2.0.1: {} @@ -4575,12 +4563,12 @@ snapshots: detect-indent: 6.1.0 detect-newline: 3.1.0 dotgitignore: 2.1.0 - fast-xml-parser: 5.5.7 + fast-xml-parser: 5.5.9 figures: 3.2.0 find-up: 5.0.0 git-semver-tags: 5.0.1 semver: 7.7.4 - yaml: 2.8.2 + yaml: 2.8.3 yargs: 17.7.2 compare-func@2.0.0: @@ -4651,7 +4639,7 @@ snapshots: dependencies: conventional-commits-filter: 3.0.0 dateformat: 3.0.3 - handlebars: 4.7.8 + handlebars: 4.7.9 json-stringify-safe: 5.0.1 meow: 8.1.2 semver: 7.7.4 @@ -4702,21 +4690,21 @@ snapshots: core-util-is@1.0.3: {} - cosmiconfig-typescript-loader@6.2.0(@types/node@25.5.0)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3): + cosmiconfig-typescript-loader@6.2.0(@types/node@25.5.0)(cosmiconfig@9.0.1(typescript@6.0.2))(typescript@6.0.2): dependencies: '@types/node': 25.5.0 - cosmiconfig: 9.0.1(typescript@5.9.3) + cosmiconfig: 9.0.1(typescript@6.0.2) jiti: 2.6.1 - typescript: 5.9.3 + typescript: 6.0.2 - cosmiconfig@9.0.1(typescript@5.9.3): + cosmiconfig@9.0.1(typescript@6.0.2): dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 js-yaml: 4.1.1 parse-json: 5.2.0 optionalDependencies: - typescript: 5.9.3 + typescript: 6.0.2 cross-env@10.1.0: dependencies: @@ -4840,7 +4828,7 @@ snapshots: enhanced-resolve@5.20.1: dependencies: graceful-fs: 4.2.11 - tapable: 2.3.0 + tapable: 2.3.2 entities@6.0.1: {} @@ -4915,13 +4903,13 @@ snapshots: fast-xml-builder@1.1.4: dependencies: - path-expression-matcher: 1.1.3 + path-expression-matcher: 1.2.0 - fast-xml-parser@5.5.7: + fast-xml-parser@5.5.9: dependencies: fast-xml-builder: 1.1.4 - path-expression-matcher: 1.1.3 - strnum: 2.2.0 + path-expression-matcher: 1.2.0 + strnum: 2.2.2 fastq@1.20.1: dependencies: @@ -4931,10 +4919,6 @@ snapshots: dependencies: walk-up-path: 4.0.0 - fdir@6.5.0(picomatch@4.0.3): - optionalDependencies: - picomatch: 4.0.3 - fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -4985,10 +4969,6 @@ snapshots: through2: 2.0.5 yargs: 16.2.0 - get-tsconfig@4.13.6: - dependencies: - resolve-pkg-maps: 1.0.0 - get-tsconfig@4.13.7: dependencies: resolve-pkg-maps: 1.0.0 @@ -5031,7 +5011,7 @@ snapshots: graceful-fs@4.2.11: {} - handlebars@4.7.8: + handlebars@4.7.9: dependencies: minimist: 1.2.8 neo-async: 2.6.2 @@ -5161,7 +5141,7 @@ snapshots: jiti@2.6.1: {} - jose@6.2.1: {} + jose@6.2.2: {} joycon@3.1.1: {} @@ -5176,7 +5156,7 @@ snapshots: '@asamuzakjp/css-color': 5.0.1 '@asamuzakjp/dom-selector': 7.0.4 '@bramus/specificity': 2.4.2 - '@csstools/css-syntax-patches-for-csstree': 1.1.1(css-tree@3.2.1) + '@csstools/css-syntax-patches-for-csstree': 1.1.2(css-tree@3.2.1) '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) css-tree: 3.2.1 data-urls: 7.0.0(@noble/hashes@2.0.1) @@ -5188,7 +5168,7 @@ snapshots: saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.1 - undici: 7.24.5 + undici: 7.24.6 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 @@ -5209,7 +5189,7 @@ snapshots: kind-of@6.0.3: {} - knip@6.0.2: + knip@6.0.6: dependencies: '@nodelib/fs.walk': 1.2.8 fast-glob: 3.3.3 @@ -5220,7 +5200,7 @@ snapshots: oxc-parser: 0.120.0 oxc-resolver: 11.19.1 picocolors: 1.1.1 - picomatch: 4.0.3 + picomatch: 4.0.4 smol-toml: 1.6.1 strip-json-comments: 5.0.3 unbash: 2.2.0 @@ -5681,7 +5661,7 @@ snapshots: micromark@4.0.2: dependencies: - '@types/debug': 4.1.12 + '@types/debug': 4.1.13 debug: 4.4.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 @@ -5704,7 +5684,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 2.3.2 min-indent@1.0.1: {} @@ -5728,31 +5708,31 @@ snapshots: neo-async@2.6.2: {} - next@16.2.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - '@next/env': 16.2.0 + '@next/env': 16.2.1 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.9 - caniuse-lite: 1.0.30001780 + baseline-browser-mapping: 2.10.11 + caniuse-lite: 1.0.30001781 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.0 - '@next/swc-darwin-x64': 16.2.0 - '@next/swc-linux-arm64-gnu': 16.2.0 - '@next/swc-linux-arm64-musl': 16.2.0 - '@next/swc-linux-x64-gnu': 16.2.0 - '@next/swc-linux-x64-musl': 16.2.0 - '@next/swc-win32-arm64-msvc': 16.2.0 - '@next/swc-win32-x64-msvc': 16.2.0 + '@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 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - node-addon-api@8.6.0: {} + node-addon-api@8.7.0: {} node-cron@4.2.1: {} @@ -5898,7 +5878,7 @@ snapshots: path-exists@4.0.0: {} - path-expression-matcher@1.1.3: {} + path-expression-matcher@1.2.0: {} path-key@3.1.1: {} @@ -5912,9 +5892,7 @@ snapshots: picocolors@1.1.1: {} - picomatch@2.3.1: {} - - picomatch@4.0.3: {} + picomatch@2.3.2: {} picomatch@4.0.4: {} @@ -6325,7 +6303,7 @@ snapshots: strip-json-comments@5.0.3: {} - strnum@2.2.0: {} + strnum@2.2.2: {} style-to-js@1.1.21: dependencies: @@ -6350,7 +6328,7 @@ snapshots: tailwindcss@4.2.2: {} - tapable@2.3.0: {} + tapable@2.3.2: {} text-extensions@1.9.0: {} @@ -6371,8 +6349,8 @@ snapshots: tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 tinyrainbow@3.1.0: {} @@ -6407,7 +6385,7 @@ snapshots: tsx@4.21.0: dependencies: esbuild: 0.27.4 - get-tsconfig: 4.13.6 + get-tsconfig: 4.13.7 optionalDependencies: fsevents: 2.3.3 @@ -6419,7 +6397,7 @@ snapshots: typedarray@0.0.6: {} - typescript@5.9.3: {} + typescript@6.0.2: {} uglify-js@3.19.3: optional: true @@ -6428,7 +6406,7 @@ snapshots: undici-types@7.18.2: {} - undici@7.24.5: {} + undici@7.24.6: {} unified@11.0.5: dependencies: @@ -6496,21 +6474,21 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vitest@4.1.0(@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.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)): dependencies: - '@vitest/expect': 4.1.0 - '@vitest/mocker': 4.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)) - '@vitest/pretty-format': 4.1.0 - '@vitest/runner': 4.1.0 - '@vitest/snapshot': 4.1.0 - '@vitest/spy': 4.1.0 - '@vitest/utils': 4.1.0 + '@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 es-module-lexer: 2.0.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 std-env: 4.0.0 tinybench: 2.9.0 tinyexec: 1.0.4 @@ -6571,8 +6549,6 @@ snapshots: yallist@4.0.0: {} - yaml@2.8.2: {} - yaml@2.8.3: {} yargs-parser@20.2.9: {} diff --git a/public/tracker-logos/mousehole_logo.svg b/public/tracker-logos/mousehole_logo.svg new file mode 100644 index 00000000..e7d4b6d7 --- /dev/null +++ b/public/tracker-logos/mousehole_logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/tracker-logos/myanonamouse_logo.png b/public/tracker-logos/myanonamouse_logo.png new file mode 100644 index 00000000..a6bd09f0 Binary files /dev/null and b/public/tracker-logos/myanonamouse_logo.png differ diff --git a/scripts/security-audit.ts b/scripts/security-audit.ts index c28f84f2..34c13549 100644 --- a/scripts/security-audit.ts +++ b/scripts/security-audit.ts @@ -3,21 +3,26 @@ // Static security analysis for CI. // Outputs JSON to stdout: { results: CheckResult[], summary: { ... } } // -// Functions: walkFiles, routePathFromFile, getCachedLines, filterIgnoredFindings, +// Functions: walkFiles, relativePath, findAllLineNumbers, routePathFromFile, isTestFile, +// getCachedLines, filterIgnoredFindings, extractHandlerBodies, // checkAuthEnforcement, checkDangerousFunctions, checkHardcodedSecrets, // checkSecurityHeaders, checkCookieSecurity, checkSensitiveFieldExposure, // checkEnvFiles, checkConsoleLogInRoutes, checkTodoInSecurityFiles, // checkRawSqlInRoutes, checkUnvalidatedJsonParse, checkBareCatchBlocks, // checkUnsafeRedirectFetch, checkRequestBodySize, checkTimingSafeComparison, // checkNoRawMigrations, checkFetchTimeout, checkDockerfileNonRoot, -// checkProxyAllowlistSync, checkBigIntSafety, checkPathTraversalDefense, +// normalizeRoute, checkProxyAllowlistSync, checkBigIntSafety, checkPathTraversalDefense, // checkArgon2Hashing, checkEncryptedColumnWrites, checkTotpFlowIntegrity, // checkLockdownFlowIntegrity, checkNukeFlowIntegrity, -// checkBackupRestoreIntegrity, checkLoginFlowIntegrity, runAudit +// checkBackupRestoreIntegrity, checkLoginFlowIntegrity, checkAuthResultGating, +// checkBackupPasswordBounds, checkWebhookRedirectPolicy, +// checkSessionSecretLengthGuard, checkNotificationSsrfValidation, +// checkErrorMessageDisclosure, checkDockerCopySensitiveFiles, +// checkClientEnvLeak, runAudit // // Usage: npx tsx scripts/security-audit.ts [--changed-only file1 file2 ...] // If --changed-only is provided, only those files are scanned for -// file-level checks (2/3/8/9/11/12/14/20). Auth enforcement and headers always +// file-level checks (2/3/8/9/11/12/14/20/34). Auth enforcement and headers always // run a full scan regardless. // // Exit code: 1 if any critical check fails, 0 otherwise. @@ -261,29 +266,90 @@ function filterIgnoredFindings(results: CheckResult[]): CheckResult[] { return filtered } -// ── Check 1: Auth enforcement ─────────────────────────────────────────── +// ── Check 1: Auth enforcement (per-handler) ───────────────────────────── + +/** + * Extract the body of each exported handler function. + * Returns an array of { method, startLine, body } for each handler. + * + * Handles typed parameters like `(request: Request, props: { params: Promise<{ id: string }> })` + * by first skipping past the parameter list via paren-counting, then finding the + * function body's opening brace. + */ +function extractHandlerBodies( + content: string +): Array<{ method: string; startLine: number; body: string }> { + const HANDLER_RE = /export\s+(?:async\s+)?function\s+(GET|POST|PATCH|PUT|DELETE)\b/g + const results: Array<{ method: string; startLine: number; body: string }> = [] + let handlerMatch: RegExpExecArray | null = HANDLER_RE.exec(content) + + while (handlerMatch !== null) { + const method = handlerMatch[1] + const startLine = content.slice(0, handlerMatch.index).split("\n").length + + // Step 1: find opening paren of parameter list + const openParen = content.indexOf("(", handlerMatch.index + handlerMatch[0].length) + if (openParen === -1) continue + + // Step 2: count parens to find the closing paren of the parameter list + let parenDepth = 0 + let closeParen = openParen + for (let i = openParen; i < content.length; i++) { + if (content[i] === "(") parenDepth++ + if (content[i] === ")") parenDepth-- + if (parenDepth === 0) { + closeParen = i + break + } + } + + // Step 3: find the function body's opening brace AFTER the parameter list + const openBrace = content.indexOf("{", closeParen + 1) + if (openBrace === -1) continue + + // Step 4: count braces to find the matching close + let braceDepth = 0 + let end = openBrace + for (let i = openBrace; i < content.length; i++) { + if (content[i] === "{") braceDepth++ + if (content[i] === "}") braceDepth-- + if (braceDepth === 0) { + end = i + break + } + } + + results.push({ + method, + startLine, + body: content.slice(openBrace, end + 1), + }) + handlerMatch = HANDLER_RE.exec(content) + } + + return results +} function checkAuthEnforcement(): CheckResult { const findings: Finding[] = [] const routeFiles = walkFiles(API_DIR, "route.ts") - const EXPORTED_HANDLERS = /export\s+(?:async\s+)?function\s+(GET|POST|PATCH|PUT|DELETE)\b/g for (const file of routeFiles) { const routePath = routePathFromFile(file) if (NO_AUTH_ROUTES.has(routePath)) continue const content = fs.readFileSync(file, "utf8") - const handlers = [...content.matchAll(EXPORTED_HANDLERS)].map((m) => m[1]) + const handlers = extractHandlerBodies(content) if (handlers.length === 0) continue - // Accept any of the valid auth patterns - const hasAuth = AUTH_PATTERNS.some((re) => re.test(content)) + for (const { method, startLine, body } of handlers) { + const hasAuth = AUTH_PATTERNS.some((re) => re.test(body)) - if (!hasAuth) { - for (const method of handlers) { + if (!hasAuth) { findings.push({ file: relativePath(file), - detail: `${method} /${routePath} — no auth check (missing authenticate/getSession/requireAuth)`, + line: startLine, + detail: `${method} /${routePath} — no auth check in handler body (missing authenticate/getSession/requireAuth)`, }) } } @@ -291,7 +357,7 @@ function checkAuthEnforcement(): CheckResult { return { id: "auth-enforcement", - name: "Auth enforcement on protected routes", + name: "Auth enforcement on protected routes (per-handler)", severity: "critical", status: findings.length === 0 ? "pass" : "fail", findings, @@ -1889,6 +1955,441 @@ function checkLoginFlowIntegrity(): CheckResult { } } +// ── Check 29: Auth result must be checked (critical) ────────────────────── + +function checkAuthResultGating(): CheckResult { + const findings: Finding[] = [] + const routeFiles = walkFiles(API_DIR, "route.ts") + + for (const file of routeFiles) { + const routePath = routePathFromFile(file) + if (NO_AUTH_ROUTES.has(routePath)) continue + + const content = fs.readFileSync(file, "utf8") + const handlers = extractHandlerBodies(content) + + for (const { method, startLine, body } of handlers) { + // Only check handlers that use any recognized auth pattern + const hasAuth = AUTH_PATTERNS.some((re) => re.test(body)) + if (!hasAuth) continue + + // Strip comment lines before checking for the gate, + // so a commented-out guard doesn't satisfy the check + const bodyNoComments = body + .split("\n") + .filter((l) => { + const t = l.trim() + return !t.startsWith("//") && !t.startsWith("*") + }) + .join("\n") + + // authenticate() returns NextResponse | { encryptionKey }, so gate with instanceof + // getSession() returns { encryptionKey } | null, so gate with !session or === null + const hasInstanceofGate = + /instanceof\s+NextResponse/.test(bodyNoComments) || + /instanceof\s+Response/.test(bodyNoComments) + const hasNullGate = /!\s*session\b|session\s*===?\s*null/.test(bodyNoComments) + + if (!hasInstanceofGate && !hasNullGate) { + findings.push({ + file: relativePath(file), + line: startLine, + detail: `${method} /${routePath} — calls auth function but never checks the result (missing instanceof NextResponse or null check)`, + }) + } + } + } + + return { + id: "auth-result-gating", + name: "Auth result checked before proceeding", + severity: "critical", + status: findings.length === 0 ? "pass" : "fail", + findings, + } +} + +// ── Check 30: Backup password inputs bounded before deriveKey (critical) ─ + +function checkBackupPasswordBounds(): CheckResult { + const findings: Finding[] = [] + + const exportPath = path.join(API_DIR, "settings/backup/export/route.ts") + const restorePath = path.join(API_DIR, "settings/backup/restore/route.ts") + + for (const absPath of [exportPath, restorePath]) { + if (!fs.existsSync(absPath)) continue + const content = fs.readFileSync(absPath, "utf8") + const rel = relativePath(absPath) + + // Check: any route that reads backupPassword from formData and passes it + // to deriveKey/encryptBackupPayload must enforce an upper-bound length check + const readsBackupPw = + /formData\.get\s*\(\s*["']backupPassword["']\s*\)/.test(content) || + /\bbackupPassword\b/.test(content) + const callsDeriveOrEncrypt = + /\bderiveKey\s*\(/.test(content) || /\bencryptBackupPayload\s*\(/.test(content) + + if (!readsBackupPw || !callsDeriveOrEncrypt) continue + + // Must have an upper-bound length check (e.g., .length > 128) + const hasUpperBound = + /backupPassword\.length\s*>\s*\d+/.test(content) || + /formPassword\.length\s*>\s*\d+/.test(content) + + if (!hasUpperBound) { + findings.push({ + file: rel, + detail: + "Backup password input lacks upper-bound length check before deriveKey() — scrypt DoS vector", + }) + } + } + + return { + id: "backup-password-bounds", + name: "Backup password inputs bounded before key derivation", + severity: "critical", + status: findings.length === 0 ? "pass" : "fail", + findings, + } +} + +// ── Check 31: Webhook delivery fetch uses redirect:"error" (critical) ──── + +function checkWebhookRedirectPolicy(): CheckResult { + const findings: Finding[] = [] + const deliverPath = path.join(SRC_DIR, "lib/notifications/deliver.ts") + + if (!fs.existsSync(deliverPath)) { + return { + id: "webhook-redirect-policy", + name: 'Webhook delivery fetch uses redirect: "error"', + severity: "critical", + status: "pass", + findings, + } + } + + const content = fs.readFileSync(deliverPath, "utf8") + const rel = relativePath(deliverPath) + const lines = content.split("\n") + + const FETCH_RE = /\bfetch\s*\(/ + + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim() + if (trimmed.startsWith("//") || trimmed.startsWith("*")) continue + if (!FETCH_RE.test(lines[i])) continue + + // Check within 8 lines for redirect: "error" + const windowEnd = Math.min(i + 8, lines.length - 1) + const fetchWindow = lines.slice(i, windowEnd + 1).join("\n") + + if (!/redirect\s*:\s*["']error["']/.test(fetchWindow)) { + findings.push({ + file: rel, + line: i + 1, + detail: + 'fetch() in webhook delivery missing redirect: "error" — SSRF via open redirect possible', + }) + } + } + + return { + id: "webhook-redirect-policy", + name: 'Webhook delivery fetch uses redirect: "error"', + severity: "critical", + status: findings.length === 0 ? "pass" : "fail", + findings, + } +} + +// ── Check 32: SESSION_SECRET minimum-length guard (critical) ────────────── + +function checkSessionSecretLengthGuard(): CheckResult { + const findings: Finding[] = [] + + const checks: Array<{ file: string; fnName: string }> = [ + { file: "src/lib/auth.ts", fnName: "getSessionKey" }, + { file: "src/lib/crypto.ts", fnName: "deriveWrappingKey" }, + ] + + for (const { file, fnName } of checks) { + const absPath = path.join(ROOT, file) + if (!fs.existsSync(absPath)) { + findings.push({ + file, + detail: `${file} not found — cannot verify SESSION_SECRET length guard`, + }) + continue + } + + const content = fs.readFileSync(absPath, "utf8") + + const hasFn = new RegExp(`function\\s+${fnName}\\b`).test(content) + if (!hasFn) { + findings.push({ + file, + detail: `Expected function ${fnName}() not found`, + }) + continue + } + + // Extract the function body (rough: from function declaration to next top-level function/export) + const fnStart = content.indexOf(`function ${fnName}`) + const fnSlice = content.slice(fnStart, fnStart + 500) + + const hasLengthCheck = + /secret\.length\s*<\s*32/.test(fnSlice) || /\.length\s*<\s*32/.test(fnSlice) + + if (!hasLengthCheck) { + findings.push({ + file, + detail: `${fnName}() missing SESSION_SECRET minimum-length guard (secret.length < 32)`, + }) + } + } + + return { + id: "session-secret-length-guard", + name: "SESSION_SECRET minimum-length guard in auth/crypto modules", + severity: "critical", + status: findings.length === 0 ? "pass" : "fail", + findings, + } +} + +// ── Check 33: Notification type validators must include SSRF check (critical) ── + +function checkNotificationSsrfValidation(): CheckResult { + const findings: Finding[] = [] + const validatePath = path.join(SRC_DIR, "lib/notifications/validate.ts") + + if (!fs.existsSync(validatePath)) { + return { + id: "notification-ssrf-validation", + name: "Notification URL validators include SSRF protection", + severity: "critical", + status: "pass", + findings, + } + } + + const content = fs.readFileSync(validatePath, "utf8") + const rel = relativePath(validatePath) + + // Find case blocks for notification types that are NOT "not yet supported" + const CASE_RE = /case\s+["'](\w+)["']\s*:\s*\{/g + let caseMatch: RegExpExecArray | null = CASE_RE.exec(content) + + while (caseMatch !== null) { + const typeName = caseMatch[1] + const caseStart = caseMatch.index + + // Find the closing brace by counting depth + let depth = 0 + let caseEnd = caseStart + for (let i = content.indexOf("{", caseStart); i < content.length; i++) { + if (content[i] === "{") depth++ + if (content[i] === "}") depth-- + if (depth === 0) { + caseEnd = i + break + } + } + + const caseBody = content.slice(caseStart, caseEnd + 1) + + // Skip types that are "not yet supported" — no URL handling to validate + if (/not yet supported/.test(caseBody)) continue + + // Active type — must call isUnsafeNetworkHost + if (!/isUnsafeNetworkHost/.test(caseBody)) { + const lineNum = content.slice(0, caseStart).split("\n").length + findings.push({ + file: rel, + line: lineNum, + detail: `Notification type "${typeName}" has active validation but does not call isUnsafeNetworkHost() — SSRF risk`, + }) + } + caseMatch = CASE_RE.exec(content) + } + + return { + id: "notification-ssrf-validation", + name: "Notification URL validators include SSRF protection", + severity: "critical", + status: findings.length === 0 ? "pass" : "fail", + findings, + } +} + +// ── Check 34: Error message information disclosure (warning) ────────────── + +function checkErrorMessageDisclosure(files?: string[]): CheckResult { + const findings: Finding[] = [] + const targetFiles = files + ? files.filter((f) => f.includes("app/api") && f.endsWith("route.ts")) + : walkFiles(API_DIR, "route.ts") + + // Detect: err.message captured into a variable, then that variable (or the + // expression directly) interpolated into a JSON response within a few lines. + const ERR_CAPTURE_RE = + /(?:const|let)\s+(\w+)\s*=\s*(?:err|error)\s+instanceof\s+Error\s*\?\s*(?:err|error)\.message/ + const DIRECT_ERR_MSG_RE = /(?:err|error)\.message/ + + for (const file of targetFiles) { + if (isTestFile(file)) continue + const content = fs.readFileSync(file, "utf8") + const lines = content.split("\n") + + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim() + if (trimmed.startsWith("//") || trimmed.startsWith("*")) continue + + // Check for err.message capture: `const message = err instanceof Error ? err.message : ...` + const captureMatch = ERR_CAPTURE_RE.exec(lines[i]) + if (!captureMatch && !DIRECT_ERR_MSG_RE.test(lines[i])) continue + + // Look within 5 lines for a JSON response that includes the error + const windowEnd = Math.min(i + 5, lines.length - 1) + const window = lines.slice(i, windowEnd + 1).join("\n") + + const hasJsonResponse = + /NextResponse\.json\s*\(/.test(window) || /return\s+new\s+Response/.test(window) + + if (!hasJsonResponse) continue + + // Check: is the captured variable or err.message in the response? + const varName = captureMatch?.[1] + const varInResponse = varName + ? new RegExp(`\\b${varName}\\b`).test(window) && /(?:error|message)\s*:/.test(window) + : /(?:err|error)\.message/.test(window) + + if (varInResponse) { + findings.push({ + file: relativePath(file), + line: i + 1, + detail: + "Raw error message passed into API response — may leak internal paths or library details", + }) + } + } + } + + return { + id: "error-message-disclosure", + name: "No raw error messages in API responses", + severity: "warning", + status: findings.length === 0 ? "pass" : "fail", + findings, + } +} + +// ── Check 35: Docker COPY of sensitive files (critical) ─────────────────── + +function checkDockerCopySensitiveFiles(): CheckResult { + const findings: Finding[] = [] + const dockerfilePath = path.join(ROOT, "Dockerfile") + + if (!fs.existsSync(dockerfilePath)) { + return { + id: "docker-copy-sensitive", + name: "Dockerfile does not COPY sensitive files", + severity: "critical", + status: "pass", + findings, + } + } + + const content = fs.readFileSync(dockerfilePath, "utf8") + const lines = content.split("\n") + + const SENSITIVE_FILE_PATTERNS = [ + /\.env\b(?!\.example)/, + /\.env\.local\b/, + /\.env\.production\b/, + /credentials\.json/, + /\.pem\b/, + /id_rsa\b/, + /\.key\b/, + ] + + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim() + if (trimmed.startsWith("#")) continue + if (!trimmed.startsWith("COPY") && !trimmed.startsWith("ADD")) continue + + for (const pattern of SENSITIVE_FILE_PATTERNS) { + if (pattern.test(trimmed)) { + findings.push({ + file: "Dockerfile", + line: i + 1, + detail: `COPY/ADD of potentially sensitive file: ${trimmed.slice(0, 100)}`, + }) + } + } + } + + return { + id: "docker-copy-sensitive", + name: "Dockerfile does not COPY sensitive files", + severity: "critical", + status: findings.length === 0 ? "pass" : "fail", + findings, + } +} + +// ── Check 36: No secret env vars in client components (critical) ────────── + +const SECRET_ENV_VARS = [ + "SESSION_SECRET", + "DATABASE_URL", + "POSTGRES_URL", + "REDIS_URL", + "ENCRYPTION_KEY", +] + +function checkClientEnvLeak(): CheckResult { + const findings: Finding[] = [] + const clientFiles = [...walkFiles(SRC_DIR, ".tsx"), ...walkFiles(SRC_DIR, ".ts")].filter( + (f) => !isTestFile(f) + ) + + for (const file of clientFiles) { + const content = fs.readFileSync(file, "utf8") + + // Only check files marked as client components + if (!content.includes("'use client'") && !content.includes('"use client"')) continue + + const lines = content.split("\n") + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + const trimmed = line.trim() + if (trimmed.startsWith("//") || trimmed.startsWith("*")) continue + + for (const envVar of SECRET_ENV_VARS) { + if (line.includes(`process.env.${envVar}`)) { + findings.push({ + file: relativePath(file), + line: i + 1, + detail: `Client component references process.env.${envVar} — secret will be undefined but indicates confused boundary`, + }) + } + } + } + } + + return { + id: "client-env-leak", + name: "No secret env vars in client components", + severity: "critical", + status: findings.length === 0 ? "pass" : "fail", + findings, + } +} + // ── Run all checks ────────────────────────────────────────────────────── function runAudit(changedFiles?: string[]): AuditOutput { @@ -1918,6 +2419,13 @@ function runAudit(changedFiles?: string[]): AuditOutput { checkNukeFlowIntegrity(), checkBackupRestoreIntegrity(), checkLoginFlowIntegrity(), + checkAuthResultGating(), + checkBackupPasswordBounds(), + checkWebhookRedirectPolicy(), + checkSessionSecretLengthGuard(), + checkNotificationSsrfValidation(), + checkDockerCopySensitiveFiles(), + checkClientEnvLeak(), // Warning — flag but don't fail checkConsoleLogInRoutes(absChangedFiles), checkTodoInSecurityFiles(), @@ -1925,6 +2433,7 @@ function runAudit(changedFiles?: string[]): AuditOutput { checkBareCatchBlocks(absChangedFiles), checkRequestBodySize(absChangedFiles), checkBigIntSafety(absChangedFiles), + checkErrorMessageDisclosure(absChangedFiles), ] const finalResults = filterIgnoredFindings(results) diff --git a/scripts/validate-trackers.ts b/scripts/validate-trackers.ts index d83c44e3..ac4e7c28 100644 --- a/scripts/validate-trackers.ts +++ b/scripts/validate-trackers.ts @@ -12,9 +12,9 @@ import fs from "node:fs" import path from "node:path" import type { TrackerRegistryEntry } from "@/data/tracker-registry" import { ALL_TRACKERS } from "@/data/trackers" -import { DEFAULT_API_PATHS } from "@/lib/adapters/constants" +import { DEFAULT_API_PATHS, VALID_PLATFORM_TYPES } from "@/lib/adapters/constants" -const VALID_PLATFORMS = ["unit3d", "gazelle", "ggn", "nebulance", "custom"] as const +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)$/ diff --git a/src/app/(auth)/DashboardClient.tsx b/src/app/(auth)/DashboardClient.tsx index a3e70c16..06dafdd6 100644 --- a/src/app/(auth)/DashboardClient.tsx +++ b/src/app/(auth)/DashboardClient.tsx @@ -15,6 +15,8 @@ import { EcosystemStatsSection } from "@/components/dashboard/EcosystemStatsSect import { FleetDashboard } from "@/components/dashboard/FleetDashboard" import { LoginTimers } from "@/components/dashboard/LoginTimers" import { PollAllButton } from "@/components/dashboard/PollAllButton" +import { TodayAtAGlance } from "@/components/dashboard/TodayAtAGlance" +import { TodayAtAGlanceSkeleton } from "@/components/dashboard/TodayAtAGlanceSkeleton" import { TrackerLeaderboard } from "@/components/dashboard/TrackerLeaderboard" import { TrackerOverviewGrid } from "@/components/dashboard/TrackerOverviewGrid" import { useDashboardSettings } from "@/components/dashboard/useDashboardSettings" @@ -96,7 +98,19 @@ export function DashboardClient({ initialTrackers }: DashboardClientProps) { - {/* Section 1: Tracker Overview */} + {/* Today At A Glance */} + {dashSettings.settings.showTodayAtAGlance && ( +
+

Today At A Glance

+ {data.todayData ? ( + + ) : data.todayLoading ? ( + + ) : null} +
+ )} + + {/* Tracker Overview */}

Trackers

- {/* Section 2: Alerts */} + {/* Alerts */} {data.alerts.length > 0 && ( ("general") return ( @@ -102,6 +107,7 @@ export function SettingsClient({ initialSettings, initialProxyTrackers }: Settin }} initialSnapshotRetentionDays={initialSettings.snapshotRetentionDays} initialSessionTimeoutMinutes={initialSettings.sessionTimeoutMinutes} + databaseSize={databaseSize} /> + return ( + + ) } diff --git a/src/app/(auth)/trackers/[id]/TrackerDetailClient.tsx b/src/app/(auth)/trackers/[id]/TrackerDetailClient.tsx index 07667437..a111b512 100644 --- a/src/app/(auth)/trackers/[id]/TrackerDetailClient.tsx +++ b/src/app/(auth)/trackers/[id]/TrackerDetailClient.tsx @@ -19,7 +19,6 @@ import { resolveSlots } from "@/components/tracker-detail/resolve-slots" import { TrackerDetailHeader } from "@/components/tracker-detail/TrackerDetailHeader" import { TrackerInfoTab } from "@/components/tracker-detail/TrackerInfoTab" import { TrackerStatusBanner } from "@/components/tracker-detail/TrackerStatusBanner" -import type { TrackerRegistryEntry } from "@/data/tracker-registry" import { findRegistryEntry } from "@/data/tracker-registry" import { useTrackerTorrents } from "@/hooks/useTrackerTorrents" import { computeDelta, hexToRgba } from "@/lib/formatters" @@ -207,9 +206,8 @@ export function TrackerDetailClient({ const delta = useMemo(() => computeDelta(snapshots), [snapshots]) const tc = tracker?.color || CHART_THEME.accent - const registryEntry: TrackerRegistryEntry | undefined = tracker - ? findRegistryEntry(tracker.baseUrl) - : undefined + const baseUrl = tracker?.baseUrl + const registryEntry = useMemo(() => (baseUrl ? findRegistryEntry(baseUrl) : undefined), [baseUrl]) const { statCardSlots, badgeSlots, progressSlots } = useMemo(() => { if (!tracker) return { statCardSlots: [], badgeSlots: [], progressSlots: [] } diff --git a/src/app/api/auth/change-password/route.ts b/src/app/api/auth/change-password/route.ts index 245ed3a7..d255e372 100644 --- a/src/app/api/auth/change-password/route.ts +++ b/src/app/api/auth/change-password/route.ts @@ -190,7 +190,7 @@ export async function POST(request: Request) { log.error( { route: "POST /api/auth/change-password", - error: err instanceof Error ? err.message : "unknown", + error: String(err), }, "transaction failed during password change" ) diff --git a/src/app/api/clients/[id]/test/route.ts b/src/app/api/clients/[id]/test/route.ts index 45d05939..46de3ee4 100644 --- a/src/app/api/clients/[id]/test/route.ts +++ b/src/app/api/clients/[id]/test/route.ts @@ -8,6 +8,7 @@ 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 { log } from "@/lib/logger" import { buildBaseUrl, getTransferInfo, invalidateSession, login } from "@/lib/qbt" @@ -34,7 +35,14 @@ export async function POST(_request: Request, props: { params: Promise<{ id: str let password: string try { ;({ username, password } = decryptClientCredentials(client, key)) - } catch { + } 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" diff --git a/src/app/api/clients/[id]/torrents/route.ts b/src/app/api/clients/[id]/torrents/route.ts index 647245f7..fcb1f7bd 100644 --- a/src/app/api/clients/[id]/torrents/route.ts +++ b/src/app/api/clients/[id]/torrents/route.ts @@ -8,6 +8,7 @@ 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 { log } from "@/lib/logger" import { getTorrents, withSessionRetry } from "@/lib/qbt" @@ -20,7 +21,7 @@ export async function GET(request: Request, props: { params: Promise<{ id: strin const url = new URL(request.url) const tag = url.searchParams.get("tag") - if (!tag || !tag.trim()) { + if (!tag?.trim()) { return NextResponse.json({ error: "tag query parameter is required" }, { status: 400 }) } @@ -40,7 +41,14 @@ export async function GET(request: Request, props: { params: Promise<{ id: strin let password: string try { ;({ username, password } = decryptClientCredentials(client, key)) - } catch { + } catch (err) { + if (isDecryptionError(err)) { + log.warn( + { route: "GET /api/clients/[id]/torrents", clientId }, + "torrent fetch 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" diff --git a/src/app/api/clients/client-routes.test.ts b/src/app/api/clients/client-routes.test.ts index 431963e1..a6af0ad7 100644 --- a/src/app/api/clients/client-routes.test.ts +++ b/src/app/api/clients/client-routes.test.ts @@ -108,7 +108,7 @@ const MOCK_TORRENTS = [ progress: 1, content_path: "/downloads/Show.S01.BluRay", save_path: "/downloads", - isPrivate: true, + is_private: true, }, ] @@ -238,7 +238,7 @@ describe("GET /api/clients/[id]/torrents", () => { // Credential handling // ------------------------------------------------------------------------- - it("returns 422 when credential decryption fails", async () => { + it("returns 401 when credential decryption fails", async () => { mockDbSelectClient(MOCK_CLIENT) ;(decrypt as ReturnType).mockImplementation(() => { throw new Error("decryption error") @@ -249,7 +249,8 @@ describe("GET /api/clients/[id]/torrents", () => { const response = await GET(request, { params }) const data = await response.json() - expect(response.status).toBe(422) + 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) diff --git a/src/app/api/dashboard/today/route.ts b/src/app/api/dashboard/today/route.ts new file mode 100644 index 00000000..58948fb1 --- /dev/null +++ b/src/app/api/dashboard/today/route.ts @@ -0,0 +1,32 @@ +// src/app/api/dashboard/today/route.ts + +import { NextResponse } from "next/server" +import { authenticate } from "@/lib/api-helpers" +import { log } from "@/lib/logger" +import { backfillTrackerCheckpoints, computeTodayAtAGlance } from "@/lib/today" + +const g = globalThis as typeof globalThis & { __backfillDone?: boolean } + +export async function GET() { + const auth = await authenticate() + if (auth instanceof NextResponse) return auth + + try { + // One-time backfill on first request that populates checkpoint table from existing snapshots + if (!g.__backfillDone) { + try { + const filled = await backfillTrackerCheckpoints() + g.__backfillDone = true + if (filled > 0) log.info(`Backfilled ${filled} tracker daily checkpoints`) + } catch (err) { + log.error(err, "Checkpoint backfill failed") + } + } + + const data = await computeTodayAtAGlance() + return NextResponse.json(data) + } catch (error) { + log.error(error, "Failed to compute today at a glance") + return NextResponse.json({ error: "Failed to compute daily stats" }, { status: 500 }) + } +} diff --git a/src/app/api/fleet/torrents/route.ts b/src/app/api/fleet/torrents/route.ts index 167d0611..68d0eb8b 100644 --- a/src/app/api/fleet/torrents/route.ts +++ b/src/app/api/fleet/torrents/route.ts @@ -10,6 +10,7 @@ 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 { log } from "@/lib/logger" import { fetchAndMergeTorrents } from "@/lib/qbt/fetch-merged" export async function GET() { @@ -43,5 +44,11 @@ export async function GET() { ] 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 }) + } + return NextResponse.json(result) } diff --git a/src/app/api/notifications/[id]/route.ts b/src/app/api/notifications/[id]/route.ts index 9766adab..4e53f153 100644 --- a/src/app/api/notifications/[id]/route.ts +++ b/src/app/api/notifications/[id]/route.ts @@ -34,7 +34,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st const updates: Record = { updatedAt: new Date() } - // Key is decoded lazily — only when config update is present + // Key is decoded lazily and only when config update is present let cachedKey: Buffer | null = null const getKey = () => { if (!cachedKey) cachedKey = decodeKey(auth as Exclude) @@ -82,6 +82,10 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st "notifyZeroSeeding", "notifyRankChange", "notifyAnniversary", + "notifyBonusCap", + "notifyVipExpiring", + "notifyUnsatisfiedLimit", + "notifyActiveHnrs", "includeTrackerName", ] as const) { if (key in fields) { @@ -99,7 +103,13 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st return NextResponse.json({ error: "thresholds must be an object or null" }, { status: 400 }) } else { const t = fields.thresholds as Record - const allowed = new Set(["ratioDropDelta", "bufferMilestoneBytes"]) + const allowed = new Set([ + "ratioDropDelta", + "bufferMilestoneBytes", + "bonusCapLimit", + "vipExpiringDays", + "unsatisfiedLimitPercent", + ]) for (const key of Object.keys(t)) { if (!allowed.has(key)) { return NextResponse.json({ error: `thresholds: unknown key "${key}"` }, { status: 400 }) @@ -127,6 +137,40 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st { status: 400 } ) } + if ( + "bonusCapLimit" in t && + (typeof t.bonusCapLimit !== "number" || + !Number.isInteger(t.bonusCapLimit) || + t.bonusCapLimit <= 0) + ) { + return NextResponse.json( + { error: "thresholds.bonusCapLimit must be a positive integer" }, + { status: 400 } + ) + } + if ( + "vipExpiringDays" in t && + (typeof t.vipExpiringDays !== "number" || + !Number.isInteger(t.vipExpiringDays) || + t.vipExpiringDays <= 0) + ) { + return NextResponse.json( + { error: "thresholds.vipExpiringDays must be a positive integer" }, + { status: 400 } + ) + } + if ( + "unsatisfiedLimitPercent" in t && + (typeof t.unsatisfiedLimitPercent !== "number" || + !Number.isFinite(t.unsatisfiedLimitPercent) || + t.unsatisfiedLimitPercent <= 0 || + t.unsatisfiedLimitPercent > 100) + ) { + return NextResponse.json( + { error: "thresholds.unsatisfiedLimitPercent must be a number between 1 and 100" }, + { status: 400 } + ) + } updates.thresholds = fields.thresholds } } @@ -159,7 +203,7 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id: if (id instanceof NextResponse) return id // notificationDeliveryState rows are cleaned up automatically via FK cascade - // (onDelete: "cascade" on targetId) — no manual delete needed here. + // (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") diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts index 7a243cbe..2f46fc9a 100644 --- a/src/app/api/notifications/route.ts +++ b/src/app/api/notifications/route.ts @@ -32,6 +32,10 @@ export async function GET() { 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, diff --git a/src/app/api/settings/backup/export/route.ts b/src/app/api/settings/backup/export/route.ts index 67248150..4b49505c 100644 --- a/src/app/api/settings/backup/export/route.ts +++ b/src/app/api/settings/backup/export/route.ts @@ -26,6 +26,12 @@ 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) { + return NextResponse.json( + { error: "Backup password must be 128 characters or fewer" }, + { status: 400 } + ) + } backupPassword = formPassword } else if (settings?.backupEncryptionEnabled && settings.encryptedBackupPassword) { try { @@ -95,14 +101,13 @@ export async function POST(request: Request) { }, }) } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error" log.error( { route: "POST /api/settings/backup/export", - error: err instanceof Error ? err.message : "unknown", + error: String(err), }, "backup export failed" ) - return NextResponse.json({ error: `Backup export failed: ${message}` }, { status: 500 }) + return NextResponse.json({ error: "Backup export failed" }, { status: 500 }) } } diff --git a/src/app/api/settings/backup/restore/route.ts b/src/app/api/settings/backup/restore/route.ts index 60fc75e4..262cbc1e 100644 --- a/src/app/api/settings/backup/restore/route.ts +++ b/src/app/api/settings/backup/restore/route.ts @@ -134,6 +134,12 @@ export async function POST(request: Request) { { status: 400 } ) } + if (backupPassword.length > 128) { + return NextResponse.json( + { error: "Backup password must be 128 characters or fewer" }, + { status: 400 } + ) + } const backupEnvelope = envelope as unknown as EncryptedBackupEnvelope if (!backupEnvelope.encryptionSalt || typeof backupEnvelope.encryptionSalt !== "string") { @@ -147,11 +153,11 @@ export async function POST(request: Request) { payload = raw as BackupPayload } } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error" - return NextResponse.json( - { error: `Invalid or corrupted backup file: ${message}` }, - { status: 400 } + log.error( + { route: "POST /api/settings/backup/restore", error: String(err) }, + "backup file validation or decryption failed" ) + return NextResponse.json({ error: "Invalid or corrupted backup file" }, { status: 400 }) } // Step 5: Get current settings for ID @@ -666,21 +672,16 @@ export async function POST(request: Request) { .where(eq(appSettings.id, currentSettingsId)) }) } catch (err) { - const message = err instanceof Error ? err.message : "Unknown error" - log.error( { event: "restore_failed", - error: message, + error: err instanceof Error ? err.message : String(err), fileNameHash: hashFileName(fileName), }, "Restore operation failed" ) - return NextResponse.json( - { error: "Restore failed. Check server logs for details." }, - { status: 409 } - ) + return NextResponse.json({ error: "Backup restore failed" }, { status: 409 }) } finally { if (backupKey.length > 0) backupKey.fill(0) if (currentKey !== backupKey && currentKey.length > 0) currentKey.fill(0) diff --git a/src/app/api/trackers/[id]/mousehole/route.ts b/src/app/api/trackers/[id]/mousehole/route.ts new file mode 100644 index 00000000..41fdd3f5 --- /dev/null +++ b/src/app/api/trackers/[id]/mousehole/route.ts @@ -0,0 +1,182 @@ +// src/app/api/trackers/[id]/mousehole/route.ts +// +// Functions: GET, POST + +import { eq } from "drizzle-orm" +import { NextResponse } from "next/server" +import { authenticate, parseTrackerId } from "@/lib/api-helpers" +import { db } from "@/lib/db" +import { trackers } from "@/lib/db/schema" +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 }> +): Promise { + const trackerId = await parseTrackerId(params) + if (trackerId instanceof NextResponse) return trackerId + + const [tracker] = await db + .select({ platformType: trackers.platformType, mouseholeUrl: trackers.mouseholeUrl }) + .from(trackers) + .where(eq(trackers.id, trackerId)) + .limit(1) + + if (!tracker) { + return NextResponse.json({ error: "Tracker not found" }, { status: 404 }) + } + + if (tracker.platformType !== "mam") { + return NextResponse.json( + { error: "Mousehole is only available for MAM trackers" }, + { status: 400 } + ) + } + + if (!tracker.mouseholeUrl) { + return NextResponse.json({ error: "Mousehole URL not configured" }, { status: 400 }) + } + + try { + const parsed = new URL(tracker.mouseholeUrl) + 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 in database" }, { status: 400 }) + } + + const mouseholeBase = tracker.mouseholeUrl.replace(/\/+$/, "") + return { mouseholeBase } +} + +// --------------------------------------------------------------------------- +// GET /api/trackers/[id]/mousehole +// Combines GET /ok + GET /state from the Mousehole instance +// --------------------------------------------------------------------------- + +export async function GET(_request: Request, { params }: RouteContext) { + const auth = await authenticate() + if (auth instanceof NextResponse) return auth + + const resolved = await resolveMouseholeBase(params) + if (resolved instanceof NextResponse) return resolved + + const { mouseholeBase } = resolved + + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), GET_TIMEOUT_MS) + + try { + const [okRes, stateRes] = await Promise.all([ + fetch(`${mouseholeBase}/ok`, { signal: controller.signal }), + fetch(`${mouseholeBase}/state`, { signal: controller.signal }), + ]) + + clearTimeout(timer) + + const [okJson, stateJson] = await Promise.all([ + okRes.json() as Promise<{ ok: boolean; reason: string }>, + stateRes.json() as Promise<{ + host?: { ip?: string | null; asn?: number | null; as?: string | null } + nextUpdateAt?: string | null + lastUpdate?: { at?: string | null; mamUpdated?: boolean | null } + lastMam?: { response?: { body?: { msg?: string | null } } } + }>, + ]) + + return NextResponse.json({ + ok: okJson.ok ?? false, + reason: okJson.reason ?? null, + ip: stateJson.host?.ip ?? null, + asn: stateJson.host?.asn ?? null, + asOrg: stateJson.host?.as ?? null, + nextUpdateAt: stateJson.nextUpdateAt ?? null, + lastUpdateAt: stateJson.lastUpdate?.at ?? null, + lastUpdateResult: stateJson.lastMam?.response?.body?.msg ?? null, + mamUpdated: stateJson.lastUpdate?.mamUpdated ?? null, + }) + } catch (error) { + clearTimeout(timer) + + if (error instanceof Error && error.name === "AbortError") { + log.warn({ route: "GET /api/trackers/[id]/mousehole" }, "Mousehole request timed out") + return NextResponse.json({ error: "Mousehole request timed out" }, { status: 504 }) + } + + log.warn( + { route: "GET /api/trackers/[id]/mousehole", error: String(error) }, + "Mousehole unreachable" + ) + return NextResponse.json({ error: "Mousehole unreachable" }, { status: 502 }) + } +} + +// --------------------------------------------------------------------------- +// POST /api/trackers/[id]/mousehole +// Proxies POST /update to the Mousehole instance +// --------------------------------------------------------------------------- + +export async function POST(request: Request, { params }: RouteContext) { + const auth = await authenticate() + if (auth instanceof NextResponse) return auth + + const resolved = await resolveMouseholeBase(params) + if (resolved instanceof NextResponse) return resolved + + const { mouseholeBase } = resolved + + const MAX_BODY_SIZE = 256 + const contentLength = Number(request.headers.get("content-length") ?? 0) + if (contentLength > MAX_BODY_SIZE) { + return NextResponse.json({ error: "Request body too large" }, { status: 413 }) + } + + let body: { force?: boolean } = {} + try { + const raw = await request.json() + if (raw && typeof raw === "object" && "force" in raw) { + body = { force: Boolean(raw.force) } + } + } catch (_err) { + log.warn({ route: "POST /api/trackers/[id]/mousehole" }, "no request body (optional)") + } + + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), POST_TIMEOUT_MS) + + try { + const res = await fetch(`${mouseholeBase}/update`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: controller.signal, + }) + + clearTimeout(timer) + + const data: unknown = await res.json() + return NextResponse.json(data, { status: res.status }) + } catch (error) { + clearTimeout(timer) + + if (error instanceof Error && error.name === "AbortError") { + log.warn({ route: "POST /api/trackers/[id]/mousehole" }, "Mousehole request timed out") + return NextResponse.json({ error: "Mousehole request timed out" }, { status: 504 }) + } + + log.warn( + { route: "POST /api/trackers/[id]/mousehole", error: String(error) }, + "Mousehole unreachable" + ) + return NextResponse.json({ error: "Mousehole unreachable" }, { status: 502 }) + } +} diff --git a/src/app/api/trackers/[id]/poll/route.ts b/src/app/api/trackers/[id]/poll/route.ts index 4097a81c..dcaed335 100644 --- a/src/app/api/trackers/[id]/poll/route.ts +++ b/src/app/api/trackers/[id]/poll/route.ts @@ -4,6 +4,7 @@ import { NextResponse } from "next/server" import { authenticate, decodeKey, parseTrackerId } from "@/lib/api-helpers" import { db } from "@/lib/db" import { appSettings, trackers } from "@/lib/db/schema" +import { isDecryptionError } from "@/lib/error-utils" import { log } from "@/lib/logger" import { buildProxyAgentFromSettings } from "@/lib/proxy" import { pollTracker } from "@/lib/scheduler" @@ -57,11 +58,17 @@ export async function POST(_request: Request, props: { params: Promise<{ id: str await pollTracker(trackerId, key, privacyMode, proxyAgent) return NextResponse.json({ success: true }) } catch (error) { - const message = error instanceof Error ? error.message : "Poll failed" + if (isDecryptionError(error)) { + log.warn( + { 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 }) + } log.error( - { route: "POST /api/trackers/[id]/poll", trackerId, error: message }, + { route: "POST /api/trackers/[id]/poll", trackerId, error: String(error) }, "manual poll failed" ) - return NextResponse.json({ error: message }, { status: 500 }) + return NextResponse.json({ error: "Poll failed" }, { status: 500 }) } } diff --git a/src/app/api/trackers/[id]/route.ts b/src/app/api/trackers/[id]/route.ts index 5068c3b1..bfd57ace 100644 --- a/src/app/api/trackers/[id]/route.ts +++ b/src/app/api/trackers/[id]/route.ts @@ -75,9 +75,25 @@ export async function PATCH(request: Request, props: { params: Promise<{ id: str 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 }) + } + } + updates.mouseholeUrl = trimmed || null + } + if (typeof body.useProxy === "boolean") updates.useProxy = body.useProxy if (typeof body.countCrossSeedUnsatisfied === "boolean") updates.countCrossSeedUnsatisfied = body.countCrossSeedUnsatisfied + if (typeof body.hideUnreadBadges === "boolean") updates.hideUnreadBadges = body.hideUnreadBadges if (typeof body.isFavorite === "boolean") updates.isFavorite = body.isFavorite if (typeof body.pollingPaused === "boolean") { updates.userPausedAt = body.pollingPaused ? new Date() : null diff --git a/src/app/api/trackers/[id]/torrents/route.ts b/src/app/api/trackers/[id]/torrents/route.ts index 8b398e56..b3e19935 100644 --- a/src/app/api/trackers/[id]/torrents/route.ts +++ b/src/app/api/trackers/[id]/torrents/route.ts @@ -63,9 +63,8 @@ export async function GET(request: Request, props: { params: Promise<{ id: strin const result = await fetchAndMergeTorrents(clients, [tag], key, qbtFilter) return NextResponse.json(result) } catch (error) { - const message = error instanceof Error ? error.message : "unknown" log.error( - { route: "GET /api/trackers/[id]/torrents", trackerId, error: message }, + { route: "GET /api/trackers/[id]/torrents", trackerId, error: String(error) }, "torrent fetch failed" ) return NextResponse.json({ error: "Failed to fetch torrents" }, { status: 502 }) diff --git a/src/app/api/trackers/route.ts b/src/app/api/trackers/route.ts index a377447e..fc2eacef 100644 --- a/src/app/api/trackers/route.ts +++ b/src/app/api/trackers/route.ts @@ -34,13 +34,14 @@ export async function POST(request: Request) { const body = await parseJsonBody(request) if (body instanceof NextResponse) return body - const { name, baseUrl, apiToken, platformType, color, qbtTag, joinedAt } = body as { + const { name, baseUrl, apiToken, platformType, color, qbtTag, mouseholeUrl, joinedAt } = body as { name?: string baseUrl?: string apiToken?: string platformType?: string color?: string qbtTag?: string + mouseholeUrl?: string joinedAt?: string } @@ -92,6 +93,17 @@ export async function POST(request: Request) { ) } + 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 }) + } + } + if (typeof joinedAt === "string" && joinedAt) { const joinedAtErr = validateJoinedAt(joinedAt) if (joinedAtErr) return joinedAtErr @@ -115,6 +127,8 @@ export async function POST(request: Request) { 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() diff --git a/src/app/api/trackers/test/route.ts b/src/app/api/trackers/test/route.ts index 89e861a6..fec70b8f 100644 --- a/src/app/api/trackers/test/route.ts +++ b/src/app/api/trackers/test/route.ts @@ -61,8 +61,13 @@ export async function POST(request: Request) { group: stats.group, }) } catch (error) { - const message = error instanceof Error ? error.message : "Connection failed" - log.warn({ route: "POST /api/trackers/test", error: message }, "tracker connection test failed") - return NextResponse.json({ error: message }, { status: 422 }) + 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 5859c7bd..b933798d 100644 --- a/src/app/api/trackers/tracker-routes.test.ts +++ b/src/app/api/trackers/tracker-routes.test.ts @@ -24,6 +24,7 @@ vi.mock("@/lib/api-helpers", async (importOriginal) => { vi.mock("@/lib/db", () => ({ db: { select: vi.fn(), + selectDistinctOn: vi.fn(), insert: vi.fn(), update: vi.fn(), delete: vi.fn(), @@ -114,8 +115,12 @@ describe("GET /api/trackers", () => { ;(db.select as ReturnType) .mockReturnValueOnce({ from: mockFromTrackers }) .mockReturnValueOnce({ from: mockSettingsFrom }) - // DISTINCT ON query via db.execute - ;(db.execute as ReturnType).mockResolvedValueOnce([snapshot]) + // DISTINCT ON query via db.selectDistinctOn + ;(db.selectDistinctOn as ReturnType).mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockResolvedValue([snapshot]), + }), + }) const response = await GET() const data = await response.json() @@ -139,8 +144,12 @@ describe("GET /api/trackers", () => { ;(db.select as ReturnType) .mockReturnValueOnce({ from: mockFrom }) .mockReturnValueOnce({ from: mockSettingsFrom }) - // DISTINCT ON query via db.execute - ;(db.execute as ReturnType).mockResolvedValueOnce([]) + // DISTINCT ON query via db.selectDistinctOn + ;(db.selectDistinctOn as ReturnType).mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockResolvedValue([]), + }), + }) const response = await GET() const data = await response.json() @@ -181,8 +190,12 @@ describe("GET /api/trackers", () => { ;(db.select as ReturnType) .mockReturnValueOnce({ from: mockFromTrackers }) .mockReturnValueOnce({ from: mockSettingsFrom }) - // DISTINCT ON query via db.execute - ;(db.execute as ReturnType).mockResolvedValueOnce([]) + // DISTINCT ON query via db.selectDistinctOn + ;(db.selectDistinctOn as ReturnType).mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockResolvedValue([]), + }), + }) const response = await GET() const data = await response.json() @@ -763,7 +776,7 @@ describe("POST /api/trackers/[id]/poll", () => { expect(data.success).toBe(true) }) - it("returns 500 when poll fails with error message", async () => { + it("returns 500 with generic message when poll fails", async () => { ;(pollTracker as ReturnType).mockRejectedValue(new Error("Connection refused")) const request = makeRequest("http://localhost/api/trackers/1/poll", undefined, "POST") @@ -772,7 +785,7 @@ describe("POST /api/trackers/[id]/poll", () => { const data = await response.json() expect(response.status).toBe(500) - expect(data.error).toBe("Connection refused") + expect(data.error).toBe("Poll failed") }) it("returns 'Poll failed' when pollTracker rejects with non-Error", async () => { diff --git a/src/app/api/upload-image/route.ts b/src/app/api/upload-image/route.ts index 2cea57c2..86d84d02 100644 --- a/src/app/api/upload-image/route.ts +++ b/src/app/api/upload-image/route.ts @@ -160,14 +160,12 @@ export async function POST(request: Request) { return NextResponse.json(result) } catch (err) { const isTimeout = err instanceof DOMException && err.name === "TimeoutError" - const message = isTimeout - ? "Upload timed out after 30 seconds" - : err instanceof Error - ? err.message - : "Upload failed" - log.error({ route: "POST /api/upload-image", host: hostId, error: message }, "upload failed") + log.error( + { route: "POST /api/upload-image", host: hostId, error: String(err) }, + "upload failed" + ) return NextResponse.json( - { error: isTimeout ? message : "Upload failed" }, + { error: isTimeout ? "Upload timed out after 30 seconds" : "Image upload failed" }, { status: isTimeout ? 504 : 502 } ) } diff --git a/src/app/globals.css b/src/app/globals.css index bf6473cf..3cff1f8b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -174,6 +174,15 @@ /* #14151b */ inset -2px -2px 5px oklch(32.94% 0.0266 278.88); /* #323443 */ } +@utility slot-label { + font-family: var(--font-sans); + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-tertiary); +} + /* * Neumorphic interactive states — for clickable raised surfaces. * Three tiers matching elevation: sm (cards), base (panels), inset (dormant links). diff --git a/src/components/AddTrackerDialog.tsx b/src/components/AddTrackerDialog.tsx index 2365c22c..57bd114f 100644 --- a/src/components/AddTrackerDialog.tsx +++ b/src/components/AddTrackerDialog.tsx @@ -8,9 +8,9 @@ import { H2 } from "@typography" import clsx from "clsx" import Image from "next/image" import { - type FormEvent, type KeyboardEvent, type MouseEvent, + type SyntheticEvent, useCallback, useEffect, useMemo, @@ -247,6 +247,7 @@ function AddTrackerDialog({ const [baseUrl, setBaseUrl] = useState("") const [apiToken, setApiToken] = useState("") const [qbtTag, setQbtTag] = useState("") + const [mouseholeUrl, setMouseholeUrl] = useState("") const [color, setColor] = useState(CHART_THEME.accent) const [joinedAt, setJoinedAt] = useState("") const [errors, setErrors] = useState>({}) @@ -259,6 +260,7 @@ function AddTrackerDialog({ setBaseUrl("") setApiToken("") setQbtTag("") + setMouseholeUrl("") setColor(CHART_THEME.accent) setJoinedAt("") setErrors({}) @@ -348,7 +350,7 @@ function AddTrackerDialog({ return next } - async function handleSubmit(e: FormEvent) { + async function handleSubmit(e: SyntheticEvent) { e.preventDefault() const validationErrors = validate() @@ -394,6 +396,7 @@ function AddTrackerDialog({ color, qbtTag: qbtTag.trim() || undefined, joinedAt: joinedAt || undefined, + mouseholeUrl: mouseholeUrl.trim() || undefined, }), }) @@ -491,11 +494,13 @@ function AddTrackerDialog({ value={apiToken} onChange={(e) => setApiToken(e.target.value)} placeholder={ - selectedEntry?.platform === "ggn" - ? "Your GGn API key" - : selectedEntry?.platform === "gazelle" - ? "Your Gazelle API key" - : "Your UNIT3D API token" + selectedEntry?.platform === "mam" + ? "Your mam_id session cookie" + : selectedEntry?.platform === "ggn" + ? "Your GGn API key" + : selectedEntry?.platform === "gazelle" + ? "Your Gazelle API key" + : "Your UNIT3D API token" } error={errors.apiToken} /> @@ -520,6 +525,33 @@ function AddTrackerDialog({ + {selectedEntry?.platform === "mam" && ( +
+
+ setMouseholeUrl(e.target.value)} + placeholder="http://localhost:7001" + /> + + + ⓘ + + +
+
+ )} + {!(selectedEntry?.gazelleEnrich || selectedEntry?.platform === "ggn") && ( diff --git a/src/components/NotificationTargets.tsx b/src/components/NotificationTargets.tsx index 70de2148..a4a5ae19 100644 --- a/src/components/NotificationTargets.tsx +++ b/src/components/NotificationTargets.tsx @@ -35,6 +35,10 @@ interface NotificationTarget { 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 @@ -83,6 +87,10 @@ const DRAFT_KEYS: (keyof NotificationTarget)[] = [ "notifyZeroSeeding", "notifyRankChange", "notifyAnniversary", + "notifyBonusCap", + "notifyVipExpiring", + "notifyUnsatisfiedLimit", + "notifyActiveHnrs", "thresholds", "includeTrackerName", "scope", @@ -408,6 +416,34 @@ function NotificationCard({ target, onSaved, onRemove }: NotificationCardProps) checked={draft.notifyAnniversary} onChange={(v) => updateDraft({ notifyAnniversary: v })} /> + + updateDraft({ notifyBonusCap: v })} + description="Alert when seedbonus hits the cap (99,999 on MAM)" + /> + + updateDraft({ notifyVipExpiring: v })} + description="Alert when VIP status is about to expire" + /> + + updateDraft({ notifyUnsatisfiedLimit: v })} + description="Alert when approaching the unsatisfied torrent limit" + /> + + updateDraft({ notifyActiveHnrs: v })} + description="Alert when inactive Hit & Runs are detected" + />
diff --git a/src/components/TrackerHubStatus.tsx b/src/components/TrackerHubStatus.tsx index ef513da0..2da7cec1 100644 --- a/src/components/TrackerHubStatus.tsx +++ b/src/components/TrackerHubStatus.tsx @@ -109,7 +109,7 @@ function TrackerHubStatus({ const trackerHubUrl = `${TRACKERHUB_HISTORY}/${trackerHubSlug}` return ( -
+
{/* Header row — always visible */}
+ {tracker.platformType === "mam" && ( +
+ updateField("mouseholeUrl", e.target.value)} + placeholder="http://localhost:7001" + /> + + + +
+ )} + updateField("color", v)} /> {!(registryEntry?.gazelleEnrich || tracker.platformType === "ggn") && ( @@ -333,6 +360,15 @@ function TrackerSettingsDialog({ open, tracker, onClose, onUpdated }: TrackerSet description="Include cross-seeded torrents when calculating unsatisfied download requirements." /> + {(tracker.platformType === "mam" || tracker.platformType === "gazelle") && ( + updateField("hideUnreadBadges", v)} + label="Hide unread badges" + description="Don't show inbox/notification counts on this tracker's detail page" + /> + )} + {errors.form && (

{errors.form} diff --git a/src/components/charts/MetricChart.tsx b/src/components/charts/MetricChart.tsx index db5853ce..75b8057b 100644 --- a/src/components/charts/MetricChart.tsx +++ b/src/components/charts/MetricChart.tsx @@ -40,7 +40,14 @@ import { useLogScale } from "./lib/useLogScale" // ── Types ── -type Metric = "ratio" | "buffer" | "seedbonus" | "seedingCount" | "dailyDelta" | "shareScore" +type Metric = + | "ratio" + | "buffer" + | "seedbonus" + | "seedingCount" + | "dailyDelta" + | "shareScore" + | "freeleechTokens" interface MetricConfig { label: string @@ -89,6 +96,12 @@ const METRIC_CONFIGS: Record, MetricConfig> = { getValue: (s) => s.seedingCount, isInteger: true, }, + freeleechTokens: { + label: "Freeleech Tokens", + unit: "tokens", + getValue: (s) => s.freeleechTokens, + isInteger: true, + }, } const BORDER_SOFT = CHART_THEME.gridLine diff --git a/src/components/charts/TorrentActivityHeatmap.tsx b/src/components/charts/TorrentActivityHeatmap.tsx index 7d270121..e079a1a4 100644 --- a/src/components/charts/TorrentActivityHeatmap.tsx +++ b/src/components/charts/TorrentActivityHeatmap.tsx @@ -110,4 +110,4 @@ function TorrentActivityHeatmap({ } export type { TorrentActivityHeatmapProps } -export { TorrentActivityHeatmap, TorrentActivityHeatmap as ActivityHeatmap } +export { TorrentActivityHeatmap } diff --git a/src/components/charts/TorrentRatioDistribution.tsx b/src/components/charts/TorrentRatioDistribution.tsx index 50034e03..40d7f9f8 100644 --- a/src/components/charts/TorrentRatioDistribution.tsx +++ b/src/components/charts/TorrentRatioDistribution.tsx @@ -45,4 +45,4 @@ function TorrentRatioDistribution({ } export type { TorrentRatioDistributionProps } -export { TorrentRatioDistribution, TorrentRatioDistribution as RatioDistribution } +export { TorrentRatioDistribution } diff --git a/src/components/charts/TorrentSeedTimeDistribution.tsx b/src/components/charts/TorrentSeedTimeDistribution.tsx index e2b68ec5..5dc3f907 100644 --- a/src/components/charts/TorrentSeedTimeDistribution.tsx +++ b/src/components/charts/TorrentSeedTimeDistribution.tsx @@ -64,4 +64,4 @@ function TorrentSeedTimeDistribution({ } export type { TorrentSeedTimeDistributionProps } -export { TorrentSeedTimeDistribution, TorrentSeedTimeDistribution as SeedTimeDistribution } +export { TorrentSeedTimeDistribution } diff --git a/src/components/charts/lib/index.ts b/src/components/charts/lib/index.ts deleted file mode 100644 index bc2a9093..00000000 --- a/src/components/charts/lib/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -// src/components/charts/lib/index.ts -// -// Barrel re-export for all chart support files. - -export type { ChartEChartsProps } from "./ChartECharts" -export { ChartECharts } from "./ChartECharts" -export type { ChartEmptyStateProps } from "./ChartEmptyState" -export { ChartEmptyState } from "./ChartEmptyState" -export * from "./chart-helpers" -export * from "./chart-transforms" -export type { LogScaleToggleProps } from "./LogScaleToggle" -export { LogScaleToggle } from "./LogScaleToggle" -export * from "./theme" -export { useLogScale } from "./useLogScale" diff --git a/src/components/dashboard/DashboardSettingsSheet.tsx b/src/components/dashboard/DashboardSettingsSheet.tsx index 88ce5d3a..f98da912 100644 --- a/src/components/dashboard/DashboardSettingsSheet.tsx +++ b/src/components/dashboard/DashboardSettingsSheet.tsx @@ -94,6 +94,12 @@ function DashboardSettingsSheet({ {/* Trackers */}

Trackers

+ dashSettings.update("showTodayAtAGlance", checked)} + /> No fleet activity today

+ } + + return ( +
+ + {addedToday} + {" added today"} + + · + + {completedToday} + {" completed today"} + +
+ ) +} diff --git a/src/components/dashboard/FleetHeadline.tsx b/src/components/dashboard/FleetHeadline.tsx new file mode 100644 index 00000000..3152741a --- /dev/null +++ b/src/components/dashboard/FleetHeadline.tsx @@ -0,0 +1,107 @@ +// src/components/dashboard/FleetHeadline.tsx + +"use client" + +import { CHART_THEME } from "@/components/charts/lib/theme" +import { + DownloadArrowIcon, + RatioIcon, + ShieldIcon, + StarIcon, + UploadArrowIcon, +} from "@/components/ui/Icons" +import { StatCard } from "@/components/ui/StatCard" +import { computePctChange, formatBytesFromString, splitValueUnit } from "@/lib/formatters" +import type { TodayAtAGlance } from "@/types/api" + +interface FleetHeadlineProps { + fleet: TodayAtAGlance["fleet"] +} + +function pctLabel(pct: number | null): string | undefined { + if (pct === null) return undefined + const rounded = Math.round(pct) + return rounded >= 0 ? `+${rounded}% vs yesterday` : `${rounded}% vs yesterday` +} + +export function FleetHeadline({ fleet }: FleetHeadlineProps) { + const uploadParts = splitValueUnit(formatBytesFromString(fleet.uploadDelta)) + const downloadParts = splitValueUnit(formatBytesFromString(fleet.downloadDelta)) + const bufferParts = splitValueUnit(formatBytesFromString(fleet.bufferDelta)) + + const ratioDisplay = + fleet.ratioChange !== null + ? fleet.ratioChange >= 0 + ? `+${fleet.ratioChange.toFixed(4)}` + : fleet.ratioChange.toFixed(4) + : "+0.0000" + + const seedbonusDisplay = (() => { + if (fleet.seedbonusChange === null) return "+0" + const floored = Math.floor(Math.abs(fleet.seedbonusChange)) + const sign = fleet.seedbonusChange >= 0 ? "+" : "-" + return sign + floored.toLocaleString() + })() + + const uploadPct = computePctChange(fleet.uploadDelta, fleet.uploadDeltaYesterday) + const downloadPct = computePctChange(fleet.downloadDelta, fleet.downloadDeltaYesterday) + const bufferPct = computePctChange(fleet.bufferDelta, fleet.bufferDeltaYesterday) + + const ratioTrend = + fleet.ratioChange === null || fleet.ratioChange === 0 + ? undefined + : fleet.ratioChange > 0 + ? ("up" as const) + : ("down" as const) + + return ( +
+ } + /> + + } + /> + + } + /> + + } + /> + + } + /> +
+ ) +} diff --git a/src/components/dashboard/MoversAndShakers.tsx b/src/components/dashboard/MoversAndShakers.tsx new file mode 100644 index 00000000..bba1a7f4 --- /dev/null +++ b/src/components/dashboard/MoversAndShakers.tsx @@ -0,0 +1,96 @@ +// src/components/dashboard/MoversAndShakers.tsx + +"use client" + +import type { ReactNode } from "react" +import { MarqueeText } from "@/components/ui/MarqueeText" +import { Tooltip } from "@/components/ui/Tooltip" +import { formatBytesFromString } from "@/lib/formatters" +import type { TodayAtAGlance } from "@/types/api" + +interface MoversAndShakersProps { + movers: TodayAtAGlance["movers"] +} + +interface TorrentRankListEntry { + hash: string + name: string + qbtTag: string | null + trackerColor: string | null + clientName: string | null + bytes: string +} + +function buildTooltip(entry: TorrentRankListEntry): ReactNode { + const lines: string[] = [] + if (entry.qbtTag) lines.push(`Tracker: ${entry.qbtTag}`) + if (entry.clientName) lines.push(`Client: ${entry.clientName}`) + if (entry.qbtTag?.toLowerCase().includes("cross-seed")) lines.push("Cross-seeded") + if (lines.length === 0) return "Unknown tracker" + return ( +
+ {lines.map((line) => ( + {line} + ))} +
+ ) +} + +function TorrentRankList({ label, entries }: { label: string; entries: TorrentRankListEntry[] }) { + return ( +
+

+ {label} +

+ {entries.length === 0 ? ( +

No activity

+ ) : ( +
    + {entries.map((entry, index) => ( + +
  • +
    + + {entry.name} +
    + + {formatBytesFromString(entry.bytes)} + +
  • +
    + ))} +
+ )} +
+ ) +} + +export function MoversAndShakers({ movers }: MoversAndShakersProps) { + const { topUploaders, topDownloaders } = movers + + if (topUploaders.length === 0 && topDownloaders.length === 0) { + return ( +

+ Connect a download client to see torrent activity +

+ ) + } + + return ( +
+ ({ ...e, bytes: e.uploadedToday }))} + /> + ({ ...e, bytes: e.downloadedToday }))} + /> +
+ ) +} diff --git a/src/components/dashboard/TodayAtAGlance.tsx b/src/components/dashboard/TodayAtAGlance.tsx new file mode 100644 index 00000000..92e83069 --- /dev/null +++ b/src/components/dashboard/TodayAtAGlance.tsx @@ -0,0 +1,87 @@ +// src/components/dashboard/TodayAtAGlance.tsx + +"use client" + +import { FleetActivity } from "@/components/dashboard/FleetActivity" +import { FleetHeadline } from "@/components/dashboard/FleetHeadline" +import { MoversAndShakers } from "@/components/dashboard/MoversAndShakers" +import { TrackerBreakdownBars } from "@/components/dashboard/TrackerBreakdownBars" +import { TrackerBreakdownTicker } from "@/components/dashboard/TrackerBreakdownTicker" +import { Card } from "@/components/ui/Card" +import { formatTimeAgo } from "@/lib/formatters" +import type { TodayAtAGlance as TodayAtAGlanceData } from "@/types/api" + +interface TodayAtAGlanceProps { + data: TodayAtAGlanceData + variant?: "bars" | "ticker" +} + +function UpdatedAt({ iso }: { iso: string | null }) { + if (!iso) return null + const ago = formatTimeAgo(new Date(iso)) + return ( + + Updated {ago} + + ) +} + +export function TodayAtAGlance({ data, variant = "bars" }: TodayAtAGlanceProps) { + const hasActivity = + data.activity.addedToday > 0 || + data.activity.completedToday > 0 || + data.movers.topUploaders.length > 0 || + data.movers.topDownloaders.length > 0 + + return ( + +
+ + +
+ + {/* Tracker breakdowns — upload and download side by side on lg, stacked on mobile */} +
+ + By Tracker + + +
+
+
+

+ Upload +

+ {variant === "bars" ? ( + + ) : ( + + )} +
+ +
+

+ Download +

+ {variant === "bars" ? ( + + ) : ( + + )} +
+
+ + {hasActivity && ( + <> +
+
+ + +
+ + + )} +
+ + ) +} diff --git a/src/components/dashboard/TodayAtAGlanceSkeleton.tsx b/src/components/dashboard/TodayAtAGlanceSkeleton.tsx new file mode 100644 index 00000000..be4b966a --- /dev/null +++ b/src/components/dashboard/TodayAtAGlanceSkeleton.tsx @@ -0,0 +1,43 @@ +// src/components/dashboard/TodayAtAGlanceSkeleton.tsx + +"use client" + +import { Card } from "@/components/ui/Card" + +function Shimmer({ className }: { className?: string }) { + return
+} + +const STAT_KEYS = ["upload", "download", "buffer", "ratio", "bonus"] as const +const PANEL_KEYS = ["upload-panel", "download-panel"] as const + +export function TodayAtAGlanceSkeleton() { + return ( + +
+ {/* Fleet headline */} +
+ {STAT_KEYS.map((key) => ( +
+ + +
+ ))} +
+ +
+ + {/* Tracker breakdowns */} +
+ {PANEL_KEYS.map((key) => ( +
+ + + +
+ ))} +
+
+ + ) +} diff --git a/src/components/dashboard/TrackerBreakdownBars.tsx b/src/components/dashboard/TrackerBreakdownBars.tsx new file mode 100644 index 00000000..db662e44 --- /dev/null +++ b/src/components/dashboard/TrackerBreakdownBars.tsx @@ -0,0 +1,89 @@ +// src/components/dashboard/TrackerBreakdownBars.tsx + +"use client" + +import { ProgressBar } from "@/components/ui/ProgressBar" +import { compareBigIntDesc, formatBytesFromString } from "@/lib/formatters" +import type { TodayAtAGlance } from "@/types/api" + +interface TrackerBreakdownProps { + trackers: TodayAtAGlance["trackers"] + metric?: "upload" | "download" +} + +const TOP_N = 5 + +export function TrackerBreakdownBars({ trackers, metric = "upload" }: TrackerBreakdownProps) { + if (trackers.length === 0) return null + + const getDelta = (t: TodayAtAGlance["trackers"][number]) => + metric === "upload" ? t.uploadDelta : t.downloadDelta + + const sorted = [...trackers].sort((a, b) => + compareBigIntDesc(BigInt(getDelta(a)), BigInt(getDelta(b))) + ) + + const top = sorted.slice(0, TOP_N) + const rest = sorted.slice(TOP_N) + + const othersDelta = rest.reduce((acc, t) => acc + BigInt(getDelta(t)), 0n) + const totalDelta = sorted.reduce((acc, t) => acc + BigInt(getDelta(t)), 0n) + + const allZero = totalDelta === 0n + + function getPercentage(delta: bigint): number { + if (totalDelta === 0n) return 0 + return Number((delta * 100n) / totalDelta) + } + + if (allZero) { + return ( +

+ No {metric} activity yet today +

+ ) + } + + const activeTop = top.filter((t) => BigInt(getDelta(t)) > 0n) + + return ( +
+ {activeTop.map((tracker) => { + const delta = BigInt(getDelta(tracker)) + const pct = getPercentage(delta) + const color = tracker.color ?? undefined + + return ( +
+ + {tracker.name} + + + + + + {formatBytesFromString(getDelta(tracker))} + +
+ ) + })} + + {othersDelta > 0n && ( +
+ Others + + + + + {formatBytesFromString(othersDelta.toString())} + +
+ )} +
+ ) +} diff --git a/src/components/dashboard/TrackerBreakdownTicker.tsx b/src/components/dashboard/TrackerBreakdownTicker.tsx new file mode 100644 index 00000000..94afe3e2 --- /dev/null +++ b/src/components/dashboard/TrackerBreakdownTicker.tsx @@ -0,0 +1,39 @@ +// src/components/dashboard/TrackerBreakdownTicker.tsx + +"use client" + +import { compareBigIntDesc, formatBytesFromString } from "@/lib/formatters" +import type { TodayAtAGlance } from "@/types/api" + +interface TrackerBreakdownProps { + trackers: TodayAtAGlance["trackers"] +} + +export function TrackerBreakdownTicker({ trackers }: TrackerBreakdownProps) { + const active = [...trackers] + .filter((t) => BigInt(t.uploadDelta) > 0n) + .sort((a, b) => compareBigIntDesc(BigInt(a.uploadDelta), BigInt(b.uploadDelta))) + + if (active.length === 0) return null + + return ( +
+ {active.map((tracker) => { + const dotColor = tracker.color ?? "var(--color-accent)" + + return ( +
+ + {tracker.name} + + {formatBytesFromString(tracker.uploadDelta)} + +
+ ) + })} +
+ ) +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index e78266e0..d25f0751 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -766,6 +766,8 @@ function Sidebar({ collapsed: collapsedProp, onToggle, isMobile = false }: Sideb width={140} height={40} className="shrink-0" + style={{ width: 140, height: "auto" }} + priority /> - +
+ + +
{/* Version + GitHub + Docs */}
diff --git a/src/components/settings/SecurityPoliciesSection.tsx b/src/components/settings/SecurityPoliciesSection.tsx index 94a3de5b..abbe573c 100644 --- a/src/components/settings/SecurityPoliciesSection.tsx +++ b/src/components/settings/SecurityPoliciesSection.tsx @@ -22,12 +22,14 @@ export interface SecurityPoliciesSectionProps { initialLockout: LockoutConfig initialSnapshotRetentionDays: number | null initialSessionTimeoutMinutes: number | null + databaseSize?: string } export function SecurityPoliciesSection({ initialLockout, initialSnapshotRetentionDays, initialSessionTimeoutMinutes, + databaseSize, }: SecurityPoliciesSectionProps) { // ── Auto-lockout ─────────────────────────────────────────────────── const [lockoutEnabled, setLockoutEnabled] = useState(initialLockout.enabled) @@ -258,6 +260,11 @@ export function SecurityPoliciesSection({ Automatically prunes historical snapshot data older than the configured period. Reduces what's stored on disk. + {databaseSize && ( +

+ Current database size: {databaseSize} +

+ )} {retentionError && (

{retentionError} diff --git a/src/components/tracker-detail/AnalyticsCharts.tsx b/src/components/tracker-detail/AnalyticsCharts.tsx index a3bf6959..a7b03c5f 100644 --- a/src/components/tracker-detail/AnalyticsCharts.tsx +++ b/src/components/tracker-detail/AnalyticsCharts.tsx @@ -94,6 +94,14 @@ export function AnalyticsCharts({ )} + {/* FL Wedges (MAM only) */} + {platformType === "mam" && ( + +

FL Wedges

+ + + )} + {/* Gazelle Percentile Radar */} {gazelleMeta?.ranks && ( diff --git a/src/components/tracker-detail/TrackerDetailHeader.tsx b/src/components/tracker-detail/TrackerDetailHeader.tsx index 8b8c79fa..948f1597 100644 --- a/src/components/tracker-detail/TrackerDetailHeader.tsx +++ b/src/components/tracker-detail/TrackerDetailHeader.tsx @@ -6,6 +6,7 @@ import { H1 } from "@typography" import clsx from "clsx" import Image from "next/image" import { TrackerHubStatus } from "@/components/TrackerHubStatus" +import { MamMouseholeCard } from "@/components/tracker-detail/platform/MamMouseholeCard" import { SlotRenderer } from "@/components/tracker-detail/SlotRenderer" import { UserProfileCard } from "@/components/tracker-detail/UserProfileCard" import { Badge } from "@/components/ui/Badge" @@ -148,12 +149,18 @@ export function TrackerDetailHeader({
- {registryEntry?.trackerHubSlug && ( -
- + {(registryEntry?.trackerHubSlug || + (tracker.platformType === "mam" && tracker.mouseholeUrl)) && ( +
+ {registryEntry?.trackerHubSlug && ( + + )} + {tracker.platformType === "mam" && tracker.mouseholeUrl && ( + + )}
)} diff --git a/src/components/tracker-detail/platform/GgnAchievementProgress.tsx b/src/components/tracker-detail/platform/GgnAchievementProgress.tsx index 7c3715f4..44357fe0 100644 --- a/src/components/tracker-detail/platform/GgnAchievementProgress.tsx +++ b/src/components/tracker-detail/platform/GgnAchievementProgress.tsx @@ -1,6 +1,6 @@ // src/components/tracker-detail/platform/GgnAchievementProgress.tsx -import { hexToRgba } from "@/lib/formatters" +import { ProgressBar } from "@/components/ui/ProgressBar" import type { GGnPlatformMeta } from "@/types/api" export interface GgnAchievementProgressProps { @@ -13,7 +13,7 @@ export function GgnAchievementProgress({ ggMeta, accentColor }: GgnAchievementPr const { userLevel, nextLevel, totalPoints, pointsToNextLvl } = ggMeta.achievements const earned = totalPoints - pointsToNextLvl - const pct = totalPoints > 0 ? Math.min(100, Math.max(0, (earned / totalPoints) * 100)) : 0 + const pct = totalPoints > 0 ? (earned / totalPoints) * 100 : 0 return (
@@ -24,16 +24,7 @@ export function GgnAchievementProgress({ ggMeta, accentColor }: GgnAchievementPr {nextLevel}
-
-
-
+

{pointsToNextLvl.toLocaleString()} pts to {nextLevel}

diff --git a/src/components/tracker-detail/platform/GgnShareScoreProgress.tsx b/src/components/tracker-detail/platform/GgnShareScoreProgress.tsx index f6dea2da..561e8a6a 100644 --- a/src/components/tracker-detail/platform/GgnShareScoreProgress.tsx +++ b/src/components/tracker-detail/platform/GgnShareScoreProgress.tsx @@ -1,6 +1,6 @@ // src/components/tracker-detail/platform/GgnShareScoreProgress.tsx -import { hexToRgba } from "@/lib/formatters" +import { ProgressBar } from "@/components/ui/ProgressBar" import type { Snapshot } from "@/types/api" export interface GgnShareScoreProgressProps { @@ -13,28 +13,17 @@ export function GgnShareScoreProgress({ latestSnapshot, accentColor }: GgnShareS const score = latestSnapshot.shareScore const maxScore = 15 - const pct = Math.min(100, Math.max(0, (score / maxScore) * 100)) + const pct = (score / maxScore) * 100 return (
- - Share Score - + Share Score {score.toFixed(2)} / {maxScore}
-
-
-
+
) } diff --git a/src/components/tracker-detail/platform/MamHealthOverview.tsx b/src/components/tracker-detail/platform/MamHealthOverview.tsx new file mode 100644 index 00000000..fdadaae0 --- /dev/null +++ b/src/components/tracker-detail/platform/MamHealthOverview.tsx @@ -0,0 +1,193 @@ +// src/components/tracker-detail/platform/MamHealthOverview.tsx +// +// Composite layout for all MAM progress visualizations. +// Replaces the 4 individual full-width progress slots with a single +// component that handles internal composition and visual hierarchy. + +import { ProgressBar } from "@/components/ui/ProgressBar" +import { Tooltip } from "@/components/ui/Tooltip" +import { MAM_BONUS_CAP } from "@/lib/adapters/constants" +import type { MamPlatformMeta } from "@/lib/adapters/types" + +export interface MamHealthOverviewProps { + meta: MamPlatformMeta + seedingCount: number + leechingCount: number + hitAndRuns: number + seedbonus: number | null + accentColor: string + vipUntil: string | null + unsatisfiedCount: number | null + unsatisfiedLimit: number | null +} + +// ── Bonus cap waste estimate ──────────────────────────────────────────────── +const ESTIMATED_POINTS_PER_SEED_PER_HOUR = 0.5 // Rough average — MAM formula varies by torrent + +// ── Torrent health segments ───────────────────────────────────────────────── +const SEGMENTS = [ + { key: "seeding", label: "Seeding", color: "var(--color-success)" }, + { key: "seedingHnr", label: "Seeding HnR", color: "var(--color-warn)" }, + { key: "leeching", label: "Leeching", color: "var(--color-accent)" }, + { key: "inactiveHnr", label: "Inactive HnR", color: "var(--color-danger)" }, + { key: "preHnr", label: "Pre-HnR", color: "var(--color-warn)" }, + { key: "completed", label: "Completed", color: "var(--color-text-tertiary)" }, +] as const + +export function MamHealthOverview({ + meta, + seedingCount, + leechingCount, + hitAndRuns, + seedbonus, + accentColor, + vipUntil, + unsatisfiedCount, + unsatisfiedLimit, +}: MamHealthOverviewProps) { + // ── VIP countdown data ────────────────────────────────────────────────── + let vipDays: number | null = null + let vipPct = 0 + let vipColor = accentColor + let vipDateStr = "" + if (vipUntil) { + const expiry = new Date(vipUntil) + if (!Number.isNaN(expiry.getTime())) { + const ms = expiry.getTime() - Date.now() + if (ms > 0) { + vipDays = Math.ceil(ms / (1000 * 60 * 60 * 24)) + vipPct = (vipDays / 90) * 100 + if (vipDays <= 3) vipColor = "var(--color-danger)" + else if (vipDays <= 7) vipColor = "var(--color-warn)" + vipDateStr = expiry.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }) + } + } + } + + // ── Satisfaction data ─────────────────────────────────────────────────── + const safeLimit = unsatisfiedLimit ?? 0 + const hasCapacity = safeLimit > 0 + const satUsed = hasCapacity ? Math.min(unsatisfiedCount ?? 0, safeLimit) : 0 + const satPct = hasCapacity ? (satUsed / safeLimit) * 100 : 0 + const satRemaining = hasCapacity ? safeLimit - satUsed : 0 + let satColor = accentColor + if (satPct >= 90) satColor = "var(--color-danger)" + else if (satPct >= 70) satColor = "var(--color-warn)" + + // ── Torrent health data ──────────────────────────────────────────────── + const healthValues: Record = { + seeding: Math.max(0, seedingCount - (meta.seedingHnrCount ?? 0)), + seedingHnr: meta.seedingHnrCount ?? 0, + leeching: leechingCount, + inactiveHnr: hitAndRuns, + preHnr: meta.inactiveUnsatisfiedCount ?? 0, + completed: meta.inactiveSatisfiedCount ?? 0, + } + const healthTotal = Object.values(healthValues).reduce((a, b) => a + b, 0) + + // ── Bonus cap data ───────────────────────────────────────────────────── + const atCap = seedbonus != null && seedbonus >= MAM_BONUS_CAP + const wastePerDay = atCap ? Math.round(seedingCount * ESTIMATED_POINTS_PER_SEED_PER_HOUR * 24) : 0 + + const hasVip = vipDays != null + const hasHealth = healthTotal > 0 + + // Nothing to show + if (!hasVip && !hasCapacity && !hasHealth && !atCap) return null + + return ( +
+ {/* ── Top-left: VIP Countdown ──────────────────────────────────────── */} + {hasVip && ( +
+
+ VIP Expires + {vipDays}d +
+ +

{vipDateStr}

+
+ )} + + {/* ── Top-right: Download Capacity ─────────────────────────────────── */} + {hasCapacity && ( +
+
+ Download Capacity + + {satRemaining} / {unsatisfiedLimit} slots + +
+ +

+ {satUsed} unsatisfied torrent{satUsed !== 1 ? "s" : ""} +

+
+ )} + + {/* ── Bottom-left: Torrent Health ──────────────────────────────────── */} + {hasHealth && ( +
+
+ Torrent Health + + + ⓘ + + +
+
+ {SEGMENTS.map(({ key, label, color }) => { + const pct = (healthValues[key] / healthTotal) * 100 + if (pct <= 0) return null + return ( +
+ ) + })} +
+
+ {SEGMENTS.map(({ key, label, color }) => { + if (healthValues[key] <= 0) return null + return ( + + + {label}: {healthValues[key].toLocaleString()} + + ) + })} +
+
+ )} + + {/* ── Bottom-right: Bonus Cap Warning ──────────────────────────────── */} + {atCap && ( +
+ Bonus Cap Reached + + {(seedbonus ?? 0).toLocaleString()} / {MAM_BONUS_CAP.toLocaleString()} + + {wastePerDay > 0 && ( +

+ ~{wastePerDay.toLocaleString()} pts/day wasted +

+ )} +
+ )} +
+ ) +} diff --git a/src/components/tracker-detail/platform/MamMouseholeCard.tsx b/src/components/tracker-detail/platform/MamMouseholeCard.tsx new file mode 100644 index 00000000..f5103380 --- /dev/null +++ b/src/components/tracker-detail/platform/MamMouseholeCard.tsx @@ -0,0 +1,328 @@ +// src/components/tracker-detail/platform/MamMouseholeCard.tsx +"use client" + +import clsx from "clsx" +import Image from "next/image" +import { useCallback, useEffect, useRef, useState } from "react" +import { Button } from "@/components/ui/Button" +import { ChevronToggle } from "@/components/ui/ChevronToggle" +import { Tooltip } from "@/components/ui/Tooltip" +import { useLocalStorage } from "@/hooks/useLocalStorage" + +interface MouseholeResponse { + ok: boolean + reason: string + ip: string | null + asn: number | null + asOrg: string | null + nextUpdateAt: string | null + lastUpdateAt: string | null + lastUpdateResult: string | null + mamUpdated: boolean | null +} + +export interface MamMouseholeCardProps { + trackerId: number + mouseholeUrl: string +} + +function parseRfc9557(dateStr: string): Date { + return new Date(dateStr.replace(/\[.*\]$/, "")) +} + +function formatCountdown(ms: number): string { + if (ms <= 0) return "00:00:00" + const totalSeconds = Math.floor(ms / 1000) + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = totalSeconds % 60 + return [hours, minutes, seconds].map((v) => String(v).padStart(2, "0")).join(":") +} + +function formatResult(msg: string): string { + switch (msg) { + case "Completed": + return "IP updated" + case "No change": + case "No Change": + return "No change needed" + default: + return msg + } +} + +export function MamMouseholeCard({ trackerId, mouseholeUrl }: MamMouseholeCardProps) { + const [data, setData] = useState(null) + const [fetchError, setFetchError] = useState(null) + const [loading, setLoading] = useState(true) + const [checking, setChecking] = useState(false) + const [countdown, setCountdown] = useState("--:--:--") + const [expanded, setExpanded] = useLocalStorage("tracker-tracker:mousehole-expanded", true) + + const nextUpdateAtRef = useRef(null) + const mountedRef = useRef(true) + const fetchStateRef = useRef<() => void>(() => {}) + + const fetchState = useCallback( + async (signal?: AbortSignal) => { + try { + const res = await fetch(`/api/trackers/${trackerId}/mousehole`, { signal }) + if (!mountedRef.current) return + if (!res.ok) { + const body = await res.json().catch(() => ({})) + setFetchError(body?.error ?? `HTTP ${res.status}`) + return + } + const json: MouseholeResponse = await res.json() + setData(json) + setFetchError(null) + nextUpdateAtRef.current = json.nextUpdateAt + } catch (err) { + if (err instanceof Error && err.name === "AbortError") return + if (!mountedRef.current) return + setFetchError(err instanceof Error ? err.message : "Failed to reach Mousehole") + } finally { + if (mountedRef.current) setLoading(false) + } + }, + [trackerId] + ) + + // Keep ref in sync so the countdown effect can call it without a dep change + fetchStateRef.current = () => { + fetchState() + } + + // Polling + useEffect(() => { + mountedRef.current = true + const controller = new AbortController() + fetchState(controller.signal) + const id = setInterval(() => fetchState(controller.signal), 60_000) + return () => { + mountedRef.current = false + controller.abort() + clearInterval(id) + } + }, [fetchState]) + + // Countdown + useEffect(() => { + let prev = "" + let refetched = false + function tick() { + const raw = nextUpdateAtRef.current + if (!raw) { + if (prev !== "--:--:--") { + setCountdown("--:--:--") + prev = "--:--:--" + } + return + } + const ms = parseRfc9557(raw).getTime() - Date.now() + const next = formatCountdown(ms) + if (next !== prev) { + setCountdown(next) + prev = next + } + if (ms <= 0 && !refetched) { + refetched = true + fetchStateRef.current() + } + if (ms > 0) { + refetched = false + } + } + tick() + const id = setInterval(tick, 1_000) + return () => clearInterval(id) + }, []) + + const handleCheckNow = useCallback(async () => { + setChecking(true) + try { + const res = await fetch(`/api/trackers/${trackerId}/mousehole`, { method: "POST" }) + if (!mountedRef.current) return + if (!res.ok) { + const body = await res.json().catch(() => ({})) + setFetchError(body?.error ?? `HTTP ${res.status}`) + return + } + await fetchState() + } catch (err) { + if (!mountedRef.current) return + setFetchError(err instanceof Error ? err.message : "Check failed") + } finally { + if (mountedRef.current) setChecking(false) + } + }, [trackerId, fetchState]) + + if (loading) { + return ( +
+
+ + + Connecting to Mousehole... + +
+
+ ) + } + + if (fetchError && !data) { + return ( +
+ +
+ Mousehole + {fetchError} +
+
+ ) + } + + if (!data) return null + + const isOk = data.ok + const isStale = !data.ok && data.reason === "needs_update" + const countdownExpired = countdown === "00:00:00" + + return ( +
+ {/* Left: Logo — clickable, links to user's Mousehole instance */} + + + + + {/* Right: Content — separated from logo by gap, not border */} +
+ {/* Header — always visible */} +
+ + +
+ {/* Countdown */} + + + {countdown} + + + + + ⓘ + + +
+
+ + {/* Expandable details */} +
+
+ {/* IP + ASN */} +
+ {data.ip ?? "No IP"} + {data.asn != null && ( + + AS{data.asn} + {data.asOrg ? `, ${data.asOrg}` : ""} + + )} +
+ + {/* Last result */} + {data.lastUpdateResult && ( +
+ Last check: {formatResult(data.lastUpdateResult)} +
+ )} + + {/* Action row */} +
+ + Next check{" "} + + {countdown} + + + +
+
+
+
+
+ ) +} diff --git a/src/components/tracker-detail/slot-registry.ts b/src/components/tracker-detail/slot-registry.ts index caf8ff1c..854e492d 100644 --- a/src/components/tracker-detail/slot-registry.ts +++ b/src/components/tracker-detail/slot-registry.ts @@ -4,10 +4,13 @@ // Slot IDs defined: // stat-card: login-deadline, gold, snatched-nebulance, seedbonus, ggn-share-score-card, // gazelle-tokens, perfect-flacs, snatched-gazelle, torrents-uploaded, -// requests-filled, groups-contributed, invited, gazelle-bounty, gazelle-comments +// requests-filled, groups-contributed, invited, gazelle-bounty, gazelle-comments, +// mam-wedges, mam-completed, mam-tracker-errors // badge: warned, donor, disabled, ggn-parked, ggn-invites, ggn-irc, -// gazelle-paranoia, gazelle-unread, gazelle-announcement -// progress: ggn-achievement-progress, ggn-share-score-progress, ggn-buffs +// gazelle-paranoia, gazelle-unread, gazelle-announcement, +// mam-vip, mam-connectable, mam-unread +// progress: ggn-achievement-progress, ggn-share-score-progress, ggn-buffs, +// mam-health-overview import type { ComponentType, ReactNode } from "react" import { createElement } from "react" @@ -26,7 +29,7 @@ import type { StatCardStackedProps, } from "@/components/ui/StatCard" import { StatCard } from "@/components/ui/StatCard" -import type { GazellePlatformMeta, GGnPlatformMeta } from "@/lib/adapters/types" +import type { GazellePlatformMeta, GGnPlatformMeta, MamPlatformMeta } from "@/lib/adapters/types" import { formatBytesNum } from "@/lib/formatters" import type { SlotCategory, SlotContext } from "@/lib/slot-types" import type { GgnAchievementProgressProps } from "./platform/GgnAchievementProgress" @@ -35,6 +38,8 @@ import type { GgnBuffsDisplayProps } from "./platform/GgnBuffsDisplay" import { GgnBuffsDisplay } from "./platform/GgnBuffsDisplay" import type { GgnShareScoreProgressProps } from "./platform/GgnShareScoreProgress" import { GgnShareScoreProgress } from "./platform/GgnShareScoreProgress" +import type { MamHealthOverviewProps } from "./platform/MamHealthOverview" +import { MamHealthOverview } from "./platform/MamHealthOverview" import type { SlotBadgeProps } from "./slots/SlotBadge" import { SlotBadge } from "./slots/SlotBadge" @@ -507,6 +512,7 @@ const gazelleUnreadBadgeSlot: SlotDefinition = { component: SlotBadge, priority: 31, resolve(ctx) { + if (ctx.tracker.hideUnreadBadges) return null const { meta } = ctx if (!meta || !("notifications" in meta)) return null const gazMeta = meta as GazellePlatformMeta @@ -522,6 +528,7 @@ const gazelleAnnouncementBadgeSlot: SlotDefinition = { component: SlotBadge, priority: 32, resolve(ctx) { + if (ctx.tracker.hideUnreadBadges) return null const { meta } = ctx if (!meta || !("notifications" in meta)) return null const gazMeta = meta as GazellePlatformMeta @@ -575,6 +582,140 @@ const ggnBuffsSlot: SlotDefinition = { }, } +// --------------------------------------------------------------------------- +// MAM slot definitions +// --------------------------------------------------------------------------- + +function isMamMeta(meta: unknown): meta is MamPlatformMeta { + return !!meta && typeof meta === "object" && "vipUntil" in meta +} + +function getDaysUntilVipExpiry(vipUntil: string): number | null { + const expiry = new Date(vipUntil) + if (Number.isNaN(expiry.getTime())) return null + const ms = expiry.getTime() - Date.now() + if (ms <= 0) return null + return Math.ceil(ms / (1000 * 60 * 60 * 24)) +} + +const mamWedgesSlot: SlotDefinition = { + id: "mam-wedges", + category: "stat-card", + component: StatCard as ComponentType, + priority: 14, + resolve(ctx) { + const { meta, latestSnapshot, accentColor } = ctx + if (!isMamMeta(meta)) return null + if (latestSnapshot?.freeleechTokens == null) return null + return { + label: "FL Wedges", + value: Math.floor(latestSnapshot.freeleechTokens).toLocaleString(), + accentColor, + icon: icon16(StarIcon), + } + }, +} + +const mamCompletedSlot: SlotDefinition = { + id: "mam-completed", + category: "stat-card", + component: StatCard as ComponentType, + priority: 22, + resolve(ctx) { + const { meta, accentColor } = ctx + if (!isMamMeta(meta)) return null + if (meta.inactiveSatisfiedCount == null) return null + return { + label: "Completed", + value: meta.inactiveSatisfiedCount, + accentColor, + icon: icon16(DownloadArrowIcon), + } + }, +} + +const mamTrackerErrorsSlot: SlotDefinition = { + id: "mam-tracker-errors", + category: "stat-card", + component: StatCard as ComponentType, + priority: 23, + resolve(ctx) { + const { meta, accentColor } = ctx + if (!isMamMeta(meta)) return null + if (meta.trackerErrorCount == null || meta.trackerErrorCount <= 0) return null + return { + label: "Tracker Errors", + value: meta.trackerErrorCount, + accentColor, + alert: "danger" as const, + } + }, +} + +const mamVipBadgeSlot: SlotDefinition = { + id: "mam-vip", + category: "badge", + component: SlotBadge, + priority: 10, + resolve(ctx) { + if (!isMamMeta(ctx.meta) || !ctx.meta.vipUntil) return null + const days = getDaysUntilVipExpiry(ctx.meta.vipUntil) + if (days == null) return null + return { variant: days <= 7 ? "warn" : "accent", label: `VIP (${days}d)` } + }, +} + +const mamConnectableBadgeSlot: SlotDefinition = { + id: "mam-connectable", + category: "badge", + component: SlotBadge, + priority: 20, + resolve(ctx) { + if (!isMamMeta(ctx.meta) || ctx.meta.connectable == null) return null + const lower = ctx.meta.connectable.toLowerCase() + const isOnline = lower === "yes" || lower === "true" || lower === "online" + return { + variant: isOnline ? "default" : "danger", + label: isOnline ? "Connectable" : "Not Connectable", + } + }, +} + +const mamUnreadBadgeSlot: SlotDefinition = { + id: "mam-unread", + category: "badge", + component: SlotBadge, + priority: 30, + resolve(ctx) { + if (ctx.tracker.hideUnreadBadges) return null + if (!isMamMeta(ctx.meta)) return null + const count = (ctx.meta.unreadPMs ?? 0) + (ctx.meta.unreadTopics ?? 0) + if (count <= 0) return null + return { variant: "warn", label: `${count} Unread` } + }, +} + +const mamHealthOverviewSlot: SlotDefinition = { + id: "mam-health-overview", + category: "progress", + component: MamHealthOverview as ComponentType, + priority: 10, + resolve(ctx) { + if (!isMamMeta(ctx.meta)) return null + return { + meta: ctx.meta, + seedingCount: ctx.latestSnapshot?.seedingCount ?? 0, + leechingCount: ctx.latestSnapshot?.leechingCount ?? 0, + hitAndRuns: ctx.latestSnapshot?.hitAndRuns ?? 0, + seedbonus: ctx.latestSnapshot?.seedbonus ?? null, + accentColor: ctx.accentColor, + vipUntil: ctx.meta.vipUntil ?? null, + unsatisfiedCount: ctx.meta.unsatisfiedCount ?? null, + unsatisfiedLimit: ctx.meta.unsatisfiedLimit ?? null, + } + }, +} + // --------------------------------------------------------------------------- // Exported registry // --------------------------------------------------------------------------- @@ -595,6 +736,10 @@ export const SLOT_DEFINITIONS: AnySlotDefinition[] = [ invitedSlot, gazelleBountySlot, gazelleCommentsSlot, + // MAM slots (stat-card) + mamWedgesSlot, + mamCompletedSlot, + mamTrackerErrorsSlot, // badge slots warnedBadgeSlot, donorBadgeSlot, @@ -605,10 +750,16 @@ export const SLOT_DEFINITIONS: AnySlotDefinition[] = [ gazelleParanoiaBadgeSlot, gazelleUnreadBadgeSlot, gazelleAnnouncementBadgeSlot, + // MAM slots (badge) + mamVipBadgeSlot, + mamConnectableBadgeSlot, + mamUnreadBadgeSlot, // progress slots ggnAchievementProgressSlot, ggnShareScoreProgressSlot, ggnBuffsSlot, + // MAM slots (progress) + mamHealthOverviewSlot, ] // Shared component lookup — single source for rendering resolved slots diff --git a/src/components/ui/Icons.tsx b/src/components/ui/Icons.tsx index 2f694597..dff99bf3 100644 --- a/src/components/ui/Icons.tsx +++ b/src/components/ui/Icons.tsx @@ -259,15 +259,14 @@ function DownloadArrowIcon(props: IconProps) { viewBox="0 0 24 24" fill="none" stroke="currentColor" - strokeWidth={2} + strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props} > - - - + + ) } diff --git a/src/components/ui/ProgressBar.tsx b/src/components/ui/ProgressBar.tsx new file mode 100644 index 00000000..bc67cefe --- /dev/null +++ b/src/components/ui/ProgressBar.tsx @@ -0,0 +1,72 @@ +// src/components/ui/ProgressBar.tsx + +"use client" + +import clsx from "clsx" + +type ProgressBarSize = "sm" | "md" | "lg" + +interface ProgressBarProps { + /** Percentage filled (0-100). Clamped internally. */ + percent: number + /** Fill color — accepts any CSS color value or CSS variable. Defaults to accent. */ + color?: string + /** Track height. sm=4px, md=8px (default), lg=12px */ + size?: ProgressBarSize + /** Show the percentage as text inside the bar (only visible on lg size) */ + showLabel?: boolean + /** Animate width transitions */ + animated?: boolean + /** Additional classes on the outer track */ + className?: string +} + +const sizeMap: Record = { + sm: { track: "p-[1px]", fill: "h-1.5" }, + md: { track: "p-[3px]", fill: "h-3" }, + lg: { track: "p-[2px]", fill: "h-3" }, +} + +function ProgressBar({ + percent, + color, + size = "md", + showLabel = false, + animated = true, + className, +}: ProgressBarProps) { + const clamped = Math.max(0, Math.min(100, percent)) + const { track, fill } = sizeMap[size] + const resolvedColor = color ?? "var(--color-accent)" + + return ( +
+
0 ? `0 0 12px ${resolvedColor}60` : undefined, + }} + role="progressbar" + aria-valuenow={clamped} + aria-valuemin={0} + aria-valuemax={100} + > + {showLabel && size === "lg" && clamped > 10 && ( + + {Math.round(clamped)}% + + )} +
+
+ ) +} + +export type { ProgressBarProps, ProgressBarSize } +export { ProgressBar } diff --git a/src/components/ui/StatCard.tsx b/src/components/ui/StatCard.tsx index e1d7cae8..99ded844 100644 --- a/src/components/ui/StatCard.tsx +++ b/src/components/ui/StatCard.tsx @@ -1,8 +1,6 @@ // src/components/ui/StatCard.tsx -// -// Functions: StatCard -// -// Unified stat card with three variants: + +// three variants: // "basic" — single hero value (default) // "stacked" — multiple label/value rows with optional total // "ring" — countdown ring (login deadline) @@ -14,7 +12,7 @@ import { Tooltip } from "@/components/ui/Tooltip" import { hexToRgba } from "@/lib/formatters" // --------------------------------------------------------------------------- -// Shared types +// types // --------------------------------------------------------------------------- type TrendDirection = "up" | "down" | "flat" @@ -228,7 +226,7 @@ function BasicContent({ } // --------------------------------------------------------------------------- -// Stacked variant — multiple label/value rows +// Stacked variant // --------------------------------------------------------------------------- function StackedContent({ @@ -298,7 +296,7 @@ function StackedContent({ } // --------------------------------------------------------------------------- -// Ring variant — countdown progress +// Ring variant // --------------------------------------------------------------------------- function getDeadlineColor(progress: number, accent: string): string { @@ -404,7 +402,26 @@ function RingContent({ // --------------------------------------------------------------------------- function StatCard(props: StatCardProps) { - const { accentColor, icon, alert, alertReason, className, style, ...rest } = props + const { + accentColor, + icon, + alert, + alertReason, + className, + style, + // Destructure component-only props so they don't leak into Shell's DOM spread + ...rest + } = props + // Strip non-DOM props from rest before Shell spread + const { + label: _label, + value: _value, + unit: _unit, + subValue: _subValue, + subtitle: _subtitle, + trend: _trend, + ...shellRest + } = rest as Record if (props.type === "ring") { const la = new Date(props.lastAccessAt).getTime() @@ -456,7 +473,13 @@ function StatCard(props: StatCardProps) { // Default: basic return ( - +
{ + it("renders an element with role='progressbar'", () => { + const { getByRole } = render() + expect(getByRole("progressbar")).toBeDefined() + }) + + it("sets aria-valuenow to the clamped percent value", () => { + const { getByRole } = render() + const bar = getByRole("progressbar") + expect(bar.getAttribute("aria-valuenow")).toBe("75") + }) + + it("sets aria-valuemin to 0", () => { + const { getByRole } = render() + expect(getByRole("progressbar").getAttribute("aria-valuemin")).toBe("0") + }) + + it("sets aria-valuemax to 100", () => { + const { getByRole } = render() + expect(getByRole("progressbar").getAttribute("aria-valuemax")).toBe("100") + }) +}) + +// --------------------------------------------------------------------------- +// width computation +// --------------------------------------------------------------------------- + +describe("ProgressBar fill width", () => { + it("at 50% the fill has width 50%", () => { + const { getByRole } = render() + const bar = getByRole("progressbar") + expect(bar.style.width).toBe("50%") + }) + + it("at 100% the fill has width 100%", () => { + const { getByRole } = render() + const bar = getByRole("progressbar") + expect(bar.style.width).toBe("100%") + }) + + it("at 0% the fill has width 0% and aria-valuenow is 0", () => { + const { getByRole } = render() + const bar = getByRole("progressbar") + expect(bar.style.width).toBe("0%") + expect(bar.getAttribute("aria-valuenow")).toBe("0") + }) + + it("at 0% the fill element has the 'invisible' class applied", () => { + const { getByRole } = render() + const bar = getByRole("progressbar") + expect(bar.className).toContain("invisible") + }) + + it("at 50% the fill element does NOT have the 'invisible' class", () => { + const { getByRole } = render() + const bar = getByRole("progressbar") + expect(bar.className).not.toContain("invisible") + }) +}) + +// --------------------------------------------------------------------------- +// clamping +// --------------------------------------------------------------------------- + +describe("ProgressBar clamping", () => { + it("negative values are clamped to 0", () => { + const { getByRole } = render() + const bar = getByRole("progressbar") + expect(bar.getAttribute("aria-valuenow")).toBe("0") + expect(bar.style.width).toBe("0%") + }) + + it("values over 100 are clamped to 100", () => { + const { getByRole } = render() + const bar = getByRole("progressbar") + expect(bar.getAttribute("aria-valuenow")).toBe("100") + expect(bar.style.width).toBe("100%") + }) + + it("aria-valuenow reflects the clamped value, not the raw input", () => { + const { getByRole } = render() + expect(getByRole("progressbar").getAttribute("aria-valuenow")).toBe("0") + }) + + it("exactly 0 is not clamped further", () => { + const { getByRole } = render() + expect(getByRole("progressbar").getAttribute("aria-valuenow")).toBe("0") + }) + + it("exactly 100 is not clamped further", () => { + const { getByRole } = render() + expect(getByRole("progressbar").getAttribute("aria-valuenow")).toBe("100") + }) +}) + +// --------------------------------------------------------------------------- +// color prop +// --------------------------------------------------------------------------- + +describe("ProgressBar color prop", () => { + it("applies custom color to the fill's backgroundColor", () => { + const { getByRole } = render() + const bar = getByRole("progressbar") + expect(bar.style.backgroundColor).toBe("rgb(255, 0, 0)") + }) + + it("uses var(--color-accent) when no color prop is provided", () => { + const { getByRole } = render() + const bar = getByRole("progressbar") + // jsdom renders CSS variable references as-is in the style attribute + expect(bar.style.backgroundColor).toBe("var(--color-accent)") + }) + + it("applies a CSS variable as color", () => { + const { getByRole } = render() + const bar = getByRole("progressbar") + expect(bar.style.backgroundColor).toBe("var(--color-warn)") + }) +}) + +// --------------------------------------------------------------------------- +// glow / boxShadow +// --------------------------------------------------------------------------- + +describe("ProgressBar box shadow", () => { + it("applies glow boxShadow when percent > 0", () => { + const { getByRole } = render() + const bar = getByRole("progressbar") + expect(bar.style.boxShadow).toContain("12px") + }) + + it("does not apply boxShadow when percent is 0 (fill invisible)", () => { + const { getByRole } = render() + const bar = getByRole("progressbar") + expect(bar.style.boxShadow).toBe("") + }) +}) + +// --------------------------------------------------------------------------- +// showLabel +// --------------------------------------------------------------------------- + +describe("ProgressBar showLabel prop", () => { + it("does not render label by default", () => { + const { queryByText } = render() + expect(queryByText("50%")).toBeNull() + }) + + it("does not render label on lg size when percent <= 10", () => { + const { queryByText } = render() + expect(queryByText("10%")).toBeNull() + }) + + it("renders label text on lg size when percent > 10 and showLabel is true", () => { + const { getByText } = render() + expect(getByText("50%")).toBeDefined() + }) + + it("does not render label on md size even when showLabel is true", () => { + const { queryByText } = render() + expect(queryByText("50%")).toBeNull() + }) + + it("rounds the percent label to the nearest integer", () => { + const { getByText } = render() + expect(getByText("51%")).toBeDefined() + }) +}) + +// --------------------------------------------------------------------------- +// animated prop +// --------------------------------------------------------------------------- + +describe("ProgressBar animated prop", () => { + it("includes transition class by default", () => { + const { getByRole } = render() + const bar = getByRole("progressbar") + expect(bar.className).toContain("transition-all") + }) + + it("omits transition class when animated=false", () => { + const { getByRole } = render() + const bar = getByRole("progressbar") + expect(bar.className).not.toContain("transition-all") + }) +}) diff --git a/src/data/__tests__/tracker-registry.test.ts b/src/data/__tests__/tracker-registry.test.ts index 3793af12..704e2573 100644 --- a/src/data/__tests__/tracker-registry.test.ts +++ b/src/data/__tests__/tracker-registry.test.ts @@ -15,7 +15,7 @@ import type { TrackerRegistryEntry } from "@/data/tracker-registry" import { ALL_TRACKERS } from "@/data/trackers" import { DEFAULT_API_PATHS } from "@/lib/adapters" -const VALID_PLATFORMS = ["unit3d", "gazelle", "ggn", "nebulance", "custom"] as const +const VALID_PLATFORMS = ["unit3d", "gazelle", "ggn", "nebulance", "mam", "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)$/ diff --git a/src/data/tracker-registry.test.ts b/src/data/tracker-registry.test.ts index a0e05faa..8c94ad80 100644 --- a/src/data/tracker-registry.test.ts +++ b/src/data/tracker-registry.test.ts @@ -12,13 +12,15 @@ describe("tracker registry", () => { expect(tracker.slug).toBeTruthy() expect(tracker.name).toBeTruthy() expect(tracker.url).toMatch(/^https:\/\//) - expect(["unit3d", "gazelle", "ggn", "nebulance"]).toContain(tracker.platform) + expect(["unit3d", "gazelle", "ggn", "nebulance", "mam"]).toContain(tracker.platform) if (tracker.platform === "unit3d") { expect(tracker.apiPath).toBe("/api/user") } else if (tracker.platform === "gazelle") { expect(tracker.apiPath).toBe("/ajax.php") } else if (tracker.platform === "ggn" || tracker.platform === "nebulance") { expect(tracker.apiPath).toBe("/api.php") + } else if (tracker.platform === "mam") { + expect(tracker.apiPath).toBe("/jsonLoad.php") } expect(tracker.color).toMatch(/^#[0-9a-f]{6}$/i) } diff --git a/src/data/tracker-registry.ts b/src/data/tracker-registry.ts index cfe07515..dd7739bf 100644 --- a/src/data/tracker-registry.ts +++ b/src/data/tracker-registry.ts @@ -50,7 +50,7 @@ export interface TrackerRegistryEntry { abbreviation?: string url: string description: string - platform: "unit3d" | "gazelle" | "ggn" | "nebulance" | "custom" + platform: "unit3d" | "gazelle" | "ggn" | "nebulance" | "mam" | "custom" apiPath: string specialty: string contentCategories: string[] diff --git a/src/data/trackers/myanonamouse.ts b/src/data/trackers/myanonamouse.ts index 3a633940..949097a4 100644 --- a/src/data/trackers/myanonamouse.ts +++ b/src/data/trackers/myanonamouse.ts @@ -7,13 +7,13 @@ export const myanonamouse: TrackerRegistryEntry = { slug: "myanonamouse", name: "MyAnonaMouse", abbreviation: "MAM", - url: "https://myanonamouse.net", + url: "https://www.myanonamouse.net", description: "Book, audiobook, and comics tracker with an open interview for anyone who wants to join and an extremely friendly community.", // ── Platform & API ────────────────────────────────────────────────── - platform: "custom", - apiPath: "/api/user", + platform: "mam", + apiPath: "/jsonLoad.php", // ── Content ───────────────────────────────────────────────────────── specialty: "Books / Audiobooks", @@ -22,23 +22,40 @@ export const myanonamouse: TrackerRegistryEntry = { // ── Visual ────────────────────────────────────────────────────────── color: "#ec407a", - logo: "", + logo: "/tracker-logos/myanonamouse_logo.png", // ── External Links ────────────────────────────────────────────────── trackerHubSlug: "my-anona-mouse", statusPageUrl: "https://status.myanonamouse.net/", // ── Community ─────────────────────────────────────────────────────── - userClasses: [], + userClasses: [ + { name: "Mouse", requirements: "Ratio below 1.0 (auto-demotion)" }, + { name: "User", requirements: "Ratio above 1.0" }, + { name: "Power User", requirements: "4 weeks membership, 25 GB uploaded, 2.0 ratio" }, + { name: "Star", requirements: "Donor" }, + { name: "VIP", requirements: "Bonus points or donation (requires Power User first)" }, + { name: "Elite VIP", requirements: "Staff-selected for community contribution" }, + { name: "Elite", requirements: "Staff-selected, immune to auto-demotion" }, + { name: "Supporter", requirements: "Continuous supporter" }, + { name: "Mouseketeer", requirements: "Retired staff" }, + { name: "Uploader", requirements: "Staff-selected for consistent monthly uploads" }, + ], + stats: { + userCount: 114812, + torrentCount: 1128625, + statsUpdatedAt: "2026-03-26", + }, releaseGroups: [], bannedGroups: [], notableMembers: [], // ── Rules ─────────────────────────────────────────────────────────── rules: { - minimumRatio: 0, - seedTimeHours: 0, + minimumRatio: 1.0, + seedTimeHours: 72, loginIntervalDays: 0, + fulfillmentPeriodHours: 720, }, // ── Status ────────────────────────────────────────────────────────── @@ -46,7 +63,7 @@ export const myanonamouse: TrackerRegistryEntry = { warningNote: "", // ── Flags ─────────────────────────────────────────────────────────── - draft: true, + draft: false, supportsTransitPapers: false, profileUrlPattern: "", } diff --git a/src/hooks/__tests__/useDashboardData.test.tsx b/src/hooks/__tests__/useDashboardData.test.tsx index 03f166d3..a9a24928 100644 --- a/src/hooks/__tests__/useDashboardData.test.tsx +++ b/src/hooks/__tests__/useDashboardData.test.tsx @@ -47,6 +47,8 @@ const mockTracker: TrackerSummary = { userPausedAt: null, color: "#00d4ff", qbtTag: null, + mouseholeUrl: null, + hideUnreadBadges: false, useProxy: false, countCrossSeedUnsatisfied: false, isFavorite: false, diff --git a/src/hooks/useDashboardData.ts b/src/hooks/useDashboardData.ts index 724b5c26..9c3bc4d0 100644 --- a/src/hooks/useDashboardData.ts +++ b/src/hooks/useDashboardData.ts @@ -1,6 +1,4 @@ // src/hooks/useDashboardData.ts -// -// Functions: useDashboardData "use client" @@ -16,7 +14,7 @@ import { fetchDismissedKeys, postDismissAlert as persistDismiss, } from "@/lib/dashboard" -import type { Snapshot, TrackerSummary } from "@/types/api" +import type { Snapshot, TodayAtAGlance, TrackerSummary } from "@/types/api" interface DashboardData { trackers: TrackerSummary[] @@ -25,6 +23,8 @@ interface DashboardData { alerts: DashboardAlert[] dayRange: DayRange setDayRange: (range: DayRange) => void + todayData: TodayAtAGlance | null + todayLoading: boolean dismissAlert: (key: string, type: string) => void dismissAllAlerts: () => void refresh: () => Promise @@ -88,6 +88,17 @@ function useDashboardData(options?: UseDashboardDataOptions): DashboardData { })), }) + const todayQuery = useQuery({ + queryKey: ["dashboard-today"], + queryFn: async ({ signal }): Promise => { + const res = await fetch("/api/dashboard/today", { signal }) + if (!res.ok) throw new Error("Failed to fetch today data") + return res.json() + }, + staleTime: 60_000, + refetchInterval: 60_000, + }) + // Secondary queries for alert computation (less frequent) const clientsQuery = useQuery({ queryKey: ["clients-for-alerts"], @@ -169,6 +180,8 @@ function useDashboardData(options?: UseDashboardDataOptions): DashboardData { return { trackers, snapshotMap, + todayData: todayQuery.data ?? null, + todayLoading: todayQuery.isLoading, loading: trackersQuery.isLoading, alerts: visibleAlerts, dayRange, @@ -178,6 +191,7 @@ function useDashboardData(options?: UseDashboardDataOptions): DashboardData { refresh: async () => { await trackersQuery.refetch() await queryClient.invalidateQueries({ queryKey: ["snapshots"] }) + queryClient.invalidateQueries({ queryKey: ["dashboard-today"] }) }, } } diff --git a/src/instrumentation.ts b/src/instrumentation.ts index e5f85563..b46c9cf9 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -11,6 +11,14 @@ export async function register() { const { startScheduler } = await import("@/lib/scheduler") const { log } = await import("@/lib/logger") + const { shouldSecureCookies } = await import("@/lib/cookie-security") + if (!shouldSecureCookies()) { + log.warn( + "Session cookies are not marked Secure. If this instance is served over HTTPS, " + + "set BASE_URL=https://... or SECURE_COOKIES=true in your environment." + ) + } + try { const key = await loadSchedulerKey() if (!key) { diff --git a/src/lib/__tests__/client-decrypt.test.ts b/src/lib/__tests__/client-decrypt.test.ts index 594f6f8d..3b0c6928 100644 --- a/src/lib/__tests__/client-decrypt.test.ts +++ b/src/lib/__tests__/client-decrypt.test.ts @@ -7,6 +7,7 @@ vi.mock("@/lib/crypto", () => ({ })) import { decrypt } from "@/lib/crypto" +import { isDecryptionError } from "@/lib/error-utils" const { decryptClientCredentials } = await import("@/lib/client-decrypt") @@ -18,13 +19,39 @@ describe("decryptClientCredentials", () => { expect(result).toEqual({ username: "decrypted:enc-user", password: "decrypted:enc-pass" }) }) - it("throws descriptive error when decrypt fails", () => { + it("throws an error that isDecryptionError() recognises when decrypt throws a crypto error", () => { + // "bad decrypt" matches the /bad\s*decrypt/i pattern in isDecryptionError + ;(decrypt as ReturnType).mockImplementation(() => { + throw new Error("bad decrypt") + }) + const client = { name: "MyClient", encryptedUsername: "x", encryptedPassword: "y" } + let thrown: unknown + expect(() => { + try { + decryptClientCredentials(client, Buffer.alloc(32)) + } catch (err) { + thrown = err + throw err + } + }).toThrow() + expect(isDecryptionError(thrown)).toBe(true) + }) + + it("throws an error that isDecryptionError() does NOT recognise for non-crypto failures", () => { + // "bad key" does not match any pattern in isDecryptionError ;(decrypt as ReturnType).mockImplementation(() => { throw new Error("bad key") }) const client = { name: "MyClient", encryptedUsername: "x", encryptedPassword: "y" } - expect(() => decryptClientCredentials(client, Buffer.alloc(32))).toThrow( - 'Credentials are missing or invalid for client "MyClient"' - ) + let thrown: unknown + expect(() => { + try { + decryptClientCredentials(client, Buffer.alloc(32)) + } catch (err) { + thrown = err + throw err + } + }).toThrow(/Failed to read credentials for client "MyClient"/) + expect(isDecryptionError(thrown)).toBe(false) }) }) diff --git a/src/lib/__tests__/client-scheduler.test.ts b/src/lib/__tests__/client-scheduler.test.ts index 4eef762b..991a7fd7 100644 --- a/src/lib/__tests__/client-scheduler.test.ts +++ b/src/lib/__tests__/client-scheduler.test.ts @@ -259,35 +259,15 @@ describe("deepPollClient per-tag optimization", () => { // ------------------------------------------------------------------------- it("passes deduped per-tag results to aggregateByTag", async () => { + // isPrivate omitted — real qBT API returns is_private (snake_case), not isPrivate const aitherTorrents = [ - { hash: "a1", state: "uploading", tags: "aither", upspeed: 100, dlspeed: 0, isPrivate: true }, - { hash: "a2", state: "uploading", tags: "aither", upspeed: 100, dlspeed: 0, isPrivate: true }, + { hash: "a1", state: "uploading", tags: "aither", upspeed: 100, dlspeed: 0 }, + { hash: "a2", state: "uploading", tags: "aither", upspeed: 100, dlspeed: 0 }, ] const crossTorrents = [ - { - hash: "c1", - state: "uploading", - tags: "cross-seed", - upspeed: 100, - dlspeed: 0, - isPrivate: true, - }, - { - hash: "c2", - state: "uploading", - tags: "cross-seed", - upspeed: 100, - dlspeed: 0, - isPrivate: true, - }, - { - hash: "c3", - state: "uploading", - tags: "cross-seed", - upspeed: 100, - dlspeed: 0, - isPrivate: true, - }, + { hash: "c1", state: "uploading", tags: "cross-seed", upspeed: 100, dlspeed: 0 }, + { hash: "c2", state: "uploading", tags: "cross-seed", upspeed: 100, dlspeed: 0 }, + { hash: "c3", state: "uploading", tags: "cross-seed", upspeed: 100, dlspeed: 0 }, ] mockDbSelectSequence(MOCK_CLIENT, ["aither"]) @@ -423,6 +403,7 @@ describe("deepPollClient per-tag optimization", () => { // ------------------------------------------------------------------------- it("caches filtered torrents to downloadClients on successful poll", async () => { + // isPrivate omitted — real qBT API returns is_private (snake_case), not isPrivate const filteredTorrents = [ { hash: "a1", @@ -431,7 +412,6 @@ describe("deepPollClient per-tag optimization", () => { tags: "aither", upspeed: 100, dlspeed: 0, - isPrivate: true, }, { hash: "a2", @@ -440,7 +420,6 @@ describe("deepPollClient per-tag optimization", () => { tags: "aither", upspeed: 200, dlspeed: 0, - isPrivate: true, }, ] @@ -473,6 +452,7 @@ describe("deepPollClient per-tag optimization", () => { }) it("strips tracker, content_path, and save_path from cached torrents", async () => { + // isPrivate omitted — real qBT API returns is_private (snake_case), not isPrivate const torrentsWithSensitiveFields = [ { hash: "a1", @@ -481,7 +461,6 @@ describe("deepPollClient per-tag optimization", () => { tags: "aither", upspeed: 100, dlspeed: 0, - isPrivate: true, tracker: "https://aither.cc/announce?passkey=SECRET123", content_path: "/data/torrents/Movie.mkv", save_path: "/data/torrents", @@ -550,3 +529,188 @@ describe("deepPollClient per-tag optimization", () => { expect(retryCalls[0][4]).toBe("decrypted-pass") }) }) + +// --------------------------------------------------------------------------- +// Regression: isPrivate field mismatch — dedup must not rely on t.isPrivate +// --------------------------------------------------------------------------- + +describe("deepPollClient dedup without isPrivate", () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + // Regression: prevents dedup from silently dropping all torrents when t.isPrivate + // is undefined. The old guard `if (!t.isPrivate || seen.has(t.hash)) continue` would + // skip every torrent because `t.isPrivate` is always undefined (the real qBT API + // returns `is_private` in snake_case, not camelCase). The fix removed the isPrivate + // guard entirely, keeping only the hash-based dedup. + it("dedup includes torrents that do not have an isPrivate field", async () => { + // Torrents constructed WITHOUT isPrivate — matching real qBT API response shape + const torrentsWithoutIsPrivate = [ + { hash: "h1", state: "uploading", tags: "aither", upspeed: 100, dlspeed: 0 }, + { hash: "h2", state: "uploading", tags: "aither", upspeed: 200, dlspeed: 0 }, + { hash: "h3", state: "uploading", tags: "aither", upspeed: 300, dlspeed: 0 }, + ] + + // Sanity-check the test data itself: none of these objects have isPrivate defined + for (const t of torrentsWithoutIsPrivate) { + expect(Object.hasOwn(t, "isPrivate")).toBe(false) + } + + mockDbSelectSequence(MOCK_CLIENT, ["aither"]) + ;(decrypt as ReturnType) + .mockReturnValueOnce("admin") + .mockReturnValueOnce("secret") + ;(getTorrents as ReturnType).mockResolvedValue(torrentsWithoutIsPrivate) + ;(getTransferInfo as ReturnType).mockResolvedValue(MOCK_TRANSFER_INFO) + ;(aggregateByTag as ReturnType).mockReturnValue(MOCK_STATS) + mockDbInsertSnapshot() + mockDbUpdateClient() + + await deepPollClient(1, makeEncryptionKey()) + + // aggregateByTag must have been called with all 3 torrents — none were dropped + const aggregateCalls = (aggregateByTag as ReturnType).mock.calls + expect(aggregateCalls).toHaveLength(1) + const passedTorrents = aggregateCalls[0][0] + expect(passedTorrents).toHaveLength(3) + }) + + // Regression: verify that dedup still correctly handles duplicate hashes across tag + // fetches even without isPrivate present. The only dedup criterion should be the hash. + it("dedup removes duplicate hashes across tag results when isPrivate is absent", async () => { + // Same hash "shared" appears in both aither and cross-seed tag responses + const aitherResult = [ + { hash: "shared", state: "uploading", tags: "aither, cross-seed", upspeed: 100, dlspeed: 0 }, + { hash: "aither-only", state: "uploading", tags: "aither", upspeed: 50, dlspeed: 0 }, + ] + const crossResult = [ + // "shared" seen again from the cross-seed tag query — should be deduped + { hash: "shared", state: "uploading", tags: "aither, cross-seed", upspeed: 100, dlspeed: 0 }, + { hash: "cross-only", state: "uploading", tags: "cross-seed", upspeed: 75, dlspeed: 0 }, + ] + + mockDbSelectSequence(MOCK_CLIENT, ["aither"]) + ;(decrypt as ReturnType) + .mockReturnValueOnce("admin") + .mockReturnValueOnce("secret") + // First call (aither tag) returns aitherResult; second (cross-seed tag) returns crossResult + ;(getTorrents as ReturnType) + .mockResolvedValueOnce(aitherResult) + .mockResolvedValueOnce(crossResult) + ;(getTransferInfo as ReturnType).mockResolvedValue(MOCK_TRANSFER_INFO) + ;(aggregateByTag as ReturnType).mockReturnValue(MOCK_STATS) + mockDbInsertSnapshot() + mockDbUpdateClient() + + await deepPollClient(1, makeEncryptionKey()) + + const aggregateCalls = (aggregateByTag as ReturnType).mock.calls + expect(aggregateCalls).toHaveLength(1) + const passedTorrents = aggregateCalls[0][0] as Array<{ hash: string }> + + // 3 unique hashes: "shared", "aither-only", "cross-only" + expect(passedTorrents).toHaveLength(3) + const hashes = passedTorrents.map((t) => t.hash) + expect(hashes).toContain("shared") + expect(hashes).toContain("aither-only") + expect(hashes).toContain("cross-only") + // "shared" must appear exactly once + expect(hashes.filter((h) => h === "shared")).toHaveLength(1) + }) +}) + +// --------------------------------------------------------------------------- +// Regression: heartbeat must not overwrite lastPolledAt +// --------------------------------------------------------------------------- + +describe("deepPollClient lastPolledAt written; heartbeat update does not include it", () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + // Regression: prevents heartbeat from starving the deep poll scheduler. + // deepPollAllClients decides whether to run by comparing now - lastPolledAt >= intervalMs. + // If heartbeat (running every 5s) also wrote lastPolledAt, the overdue threshold would + // never be reached after the first heartbeat, and deep polls would stop firing. + // The fix: heartbeat updates only lastError/errorSince/updatedAt, never lastPolledAt. + it("deep poll success writes lastPolledAt to the DB", async () => { + setupFullHappyPathMocks(["aither"]) + const { mockSet } = mockDbUpdateClient() + + await deepPollClient(1, makeEncryptionKey()) + + // At least one update call must include lastPolledAt — this is the status update + const allSetCalls = mockSet.mock.calls.map((c: unknown[]) => c[0] as Record) + const statusUpdate = allSetCalls.find((c) => "lastPolledAt" in c) + expect(statusUpdate).toBeDefined() + expect(statusUpdate?.lastPolledAt).toBeInstanceOf(Date) + }) + + // Regression: verifies the scheduling invariant — a client with a recent lastPolledAt + // (set during a prior deep poll) should be considered NOT overdue, while a client + // with lastPolledAt = null (never polled) should always be considered overdue. + // This mirrors the logic in deepPollAllClients without needing to call it directly. + it("overdue check: client with null lastPolledAt is always overdue", () => { + const pollIntervalSeconds = 30 + const now = Date.now() + + // Mirrors: const lastPoll = client.lastPolledAt?.getTime() ?? 0 + // Mirrors: return now - lastPoll >= intervalMs + const lastPolledAt = null + const intervalMs = pollIntervalSeconds * 1000 + const lastPoll = lastPolledAt ?? 0 + const isOverdue = now - lastPoll >= intervalMs + + expect(isOverdue).toBe(true) + }) + + it("overdue check: client deep-polled within interval is not overdue", () => { + const pollIntervalSeconds = 30 + const now = Date.now() + + // Simulate: heartbeat ran 5s ago but deep poll ran 10s ago (within 30s interval) + const lastPolledAt = new Date(now - 10_000) // 10 seconds ago + const intervalMs = pollIntervalSeconds * 1000 + const lastPoll = lastPolledAt.getTime() + const isOverdue = now - lastPoll >= intervalMs + + expect(isOverdue).toBe(false) + }) + + it("overdue check: client whose interval has elapsed is overdue", () => { + const pollIntervalSeconds = 30 + const now = Date.now() + + // Simulate: last deep poll was 35s ago (past the 30s interval) + const lastPolledAt = new Date(now - 35_000) + const intervalMs = pollIntervalSeconds * 1000 + const lastPoll = lastPolledAt.getTime() + const isOverdue = now - lastPoll >= intervalMs + + expect(isOverdue).toBe(true) + }) + + // Regression: if heartbeat wrote lastPolledAt (the bug), a client polled 5s ago + // via heartbeat would NOT be considered overdue even after the deep poll interval elapsed. + // This test documents that scenario: if lastPolledAt reflects a heartbeat timestamp + // (5s ago) and the poll interval is 30s, the client should still be overdue — + // but only if deep poll hadn't run. We verify the boundary is the deep poll timestamp, + // not the heartbeat timestamp. + it("overdue check: client whose only recent update was a heartbeat 5s ago is still overdue after 30s interval", () => { + const pollIntervalSeconds = 30 + const now = Date.now() + + // Scenario demonstrating the bug: if heartbeat wrote lastPolledAt 5s ago, + // the client would appear not-overdue even though deep poll hasn't run in 60s. + // With the fix, heartbeat does NOT write lastPolledAt, so the last deep poll + // timestamp (60s ago) is what drives the overdue check. + const lastDeepPollAt = new Date(now - 60_000) // deep poll ran 60s ago + const intervalMs = pollIntervalSeconds * 1000 + const lastPoll = lastDeepPollAt.getTime() + const isOverdue = now - lastPoll >= intervalMs + + // 60s ago > 30s interval → overdue + expect(isOverdue).toBe(true) + }) +}) diff --git a/src/lib/__tests__/dashboard.test.ts b/src/lib/__tests__/dashboard.test.ts index cab3e78e..61eb7ce9 100644 --- a/src/lib/__tests__/dashboard.test.ts +++ b/src/lib/__tests__/dashboard.test.ts @@ -42,6 +42,8 @@ function makeTracker(overrides: Partial = {}): TrackerSummary { userPausedAt: null, color: "#00d4ff", qbtTag: null, + mouseholeUrl: null, + hideUnreadBadges: false, sortOrder: 0, joinedAt: null, lastAccessAt: null, diff --git a/src/lib/__tests__/error-utils.test.ts b/src/lib/__tests__/error-utils.test.ts new file mode 100644 index 00000000..41299eed --- /dev/null +++ b/src/lib/__tests__/error-utils.test.ts @@ -0,0 +1,182 @@ +// src/lib/__tests__/error-utils.test.ts + +import { describe, expect, it } from "vitest" +import { isDecryptionError, sanitizeNetworkError } from "@/lib/error-utils" + +// --------------------------------------------------------------------------- +// isDecryptionError +// --------------------------------------------------------------------------- + +describe("isDecryptionError", () => { + // Positive cases — messages that indicate AES-GCM authentication failure + + it("returns true for 'Unsupported state or unable to authenticate data'", () => { + expect(isDecryptionError(new Error("Unsupported state or unable to authenticate data"))).toBe( + true + ) + }) + + it("returns true for 'bad decrypt'", () => { + expect(isDecryptionError(new Error("bad decrypt"))).toBe(true) + }) + + it("returns true for 'bad decrypt' with mixed casing", () => { + expect(isDecryptionError(new Error("Bad Decrypt"))).toBe(true) + }) + + it("returns true for 'Invalid key length'", () => { + expect(isDecryptionError(new Error("Invalid key length"))).toBe(true) + }) + + it("returns true for messages containing 'EVP_' (OpenSSL error codes)", () => { + expect( + isDecryptionError( + new Error("error:1e000065:Cipher functions:OPENSSL_internal:EVP_DecryptFinal_ex") + ) + ).toBe(true) + }) + + it("returns true for messages containing 'decrypt' anywhere", () => { + expect(isDecryptionError(new Error("Failed to decrypt cipher text"))).toBe(true) + }) + + it("returns true for 'authenticate data' partial match", () => { + expect(isDecryptionError(new Error("unable to authenticate data"))).toBe(true) + }) + + it("is case-insensitive for 'DECRYPT'", () => { + expect(isDecryptionError(new Error("DECRYPT failed"))).toBe(true) + }) + + it("is case-insensitive for 'INVALID KEY'", () => { + expect(isDecryptionError(new Error("INVALID KEY supplied"))).toBe(true) + }) + + // Negative cases — errors unrelated to decryption + + it("returns false for 'Connection refused'", () => { + expect(isDecryptionError(new Error("Connection refused"))).toBe(false) + }) + + it("returns false for 'Timeout'", () => { + expect(isDecryptionError(new Error("Timeout"))).toBe(false) + }) + + it("returns false for 'ECONNREFUSED'", () => { + expect(isDecryptionError(new Error("ECONNREFUSED"))).toBe(false) + }) + + it("returns false for 'Not found'", () => { + expect(isDecryptionError(new Error("Not found"))).toBe(false) + }) + + it("returns false for a generic empty error message", () => { + expect(isDecryptionError(new Error(""))).toBe(false) + }) + + // Non-Error inputs + + it("returns false for a plain string (not an Error instance)", () => { + expect(isDecryptionError("bad decrypt")).toBe(false) + }) + + it("returns false for null", () => { + expect(isDecryptionError(null)).toBe(false) + }) + + it("returns false for undefined", () => { + expect(isDecryptionError(undefined)).toBe(false) + }) + + it("returns false for a number", () => { + expect(isDecryptionError(42)).toBe(false) + }) + + it("returns false for a plain object", () => { + expect(isDecryptionError({ message: "bad decrypt" })).toBe(false) + }) + + it("returns false for an array", () => { + expect(isDecryptionError(["bad decrypt"])).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// sanitizeNetworkError +// --------------------------------------------------------------------------- + +describe("sanitizeNetworkError", () => { + it("maps 'timed out' to 'Request timed out'", () => { + expect(sanitizeNetworkError("Request timed out after 15s")).toBe("Request timed out") + }) + + it("maps 'timeout' variant to 'Request timed out'", () => { + expect(sanitizeNetworkError("Connection timeout")).toBe("Request timed out") + }) + + it("maps 'ECONNREFUSED' to 'Connection refused'", () => { + expect(sanitizeNetworkError("ECONNREFUSED 127.0.0.1:8080")).toBe("Connection refused") + }) + + it("maps 'ENOTFOUND' to 'Host not found'", () => { + expect(sanitizeNetworkError("getaddrinfo ENOTFOUND example.invalid")).toBe("Host not found") + }) + + it("maps 'EHOSTUNREACH' to 'Host unreachable'", () => { + expect(sanitizeNetworkError("EHOSTUNREACH")).toBe("Host unreachable") + }) + + it("maps 'ECONNRESET' to 'Connection reset'", () => { + expect(sanitizeNetworkError("ECONNRESET")).toBe("Connection reset") + }) + + it("maps 'ip ban' to IP ban message", () => { + expect(sanitizeNetworkError("ip ban detected")).toBe("IP temporarily banned by tracker") + }) + + it("maps 'rate-limit' to IP ban message", () => { + expect(sanitizeNetworkError("rate-limit exceeded")).toBe("IP temporarily banned by tracker") + }) + + it("maps '401' to 'Authentication failed'", () => { + expect(sanitizeNetworkError("HTTP 401 Unauthorized")).toBe("Authentication failed") + }) + + it("maps 'Unauthorized' to 'Authentication failed'", () => { + expect(sanitizeNetworkError("Unauthorized")).toBe("Authentication failed") + }) + + it("maps 'Forbidden' to 'Authentication failed'", () => { + expect(sanitizeNetworkError("403 Forbidden")).toBe("Authentication failed") + }) + + it("maps 'Session expired' to 'Session expired'", () => { + expect(sanitizeNetworkError("Session expired")).toBe("Session expired") + }) + + it("maps 'proxy' to 'Proxy connection failed'", () => { + expect(sanitizeNetworkError("Could not connect via proxy")).toBe("Proxy connection failed") + }) + + it("extracts status code from 'Tracker API error: 404'", () => { + expect(sanitizeNetworkError("Tracker API error: 404")).toBe("API returned 404") + }) + + it("extracts status code from 'Tracker API error: 500'", () => { + expect(sanitizeNetworkError("Tracker API error: 500")).toBe("API returned 500") + }) + + it("returns the default fallback for an unrecognized message", () => { + expect(sanitizeNetworkError("Something completely unexpected happened")).toBe( + "Connection failed" + ) + }) + + it("uses a custom fallback when provided", () => { + expect(sanitizeNetworkError("Unknown error", "Polling failed")).toBe("Polling failed") + }) + + it("returns default fallback for empty string", () => { + expect(sanitizeNetworkError("")).toBe("Connection failed") + }) +}) diff --git a/src/lib/__tests__/formatters-today.test.ts b/src/lib/__tests__/formatters-today.test.ts new file mode 100644 index 00000000..075a10ba --- /dev/null +++ b/src/lib/__tests__/formatters-today.test.ts @@ -0,0 +1,163 @@ +// src/lib/__tests__/formatters-today.test.ts + +import { describe, expect, it } from "vitest" +import { formatBytesFromString, localDateStr } from "@/lib/formatters" + +// --------------------------------------------------------------------------- +// localDateStr +// --------------------------------------------------------------------------- + +describe("localDateStr", () => { + it("returns a YYYY-MM-DD formatted string", () => { + const result = localDateStr(new Date("2024-06-15")) + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/) + }) + + it("returns the correct date for a given Date object", () => { + // Construct a date at local midnight to avoid any timezone edge case confusion + const d = new Date(2024, 5, 15) // June 15 2024 in local time + const result = localDateStr(d) + expect(result).toBe("2024-06-15") + }) + + it("returns the correct date for a unix milliseconds number", () => { + // 2024-01-20 at noon UTC — choosing noon avoids UTC±local edge + const ms = new Date("2024-01-20T12:00:00").getTime() + const result = localDateStr(ms) + // The year, month, and day must match a local interpretation of that timestamp + expect(result).toMatch(/^2024-01-\d{2}$/) + }) + + it("with no args, returns today's date in YYYY-MM-DD format", () => { + const today = new Date().toLocaleDateString("en-CA") + const result = localDateStr() + expect(result).toBe(today) + }) + + it("uses en-CA locale formatting (YYYY-MM-DD, not MM/DD/YYYY)", () => { + // en-CA produces ISO-style dates; en-US would produce "6/15/2024" + const d = new Date(2024, 5, 15) + const result = localDateStr(d) + // Must not contain slashes + expect(result).not.toContain("/") + // Must match the ISO date pattern + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/) + }) + + it("handles the first day of the month without off-by-one", () => { + const d = new Date(2024, 0, 1) // January 1 local + expect(localDateStr(d)).toBe("2024-01-01") + }) + + it("handles the last day of the month correctly", () => { + const d = new Date(2024, 0, 31) // January 31 local + expect(localDateStr(d)).toBe("2024-01-31") + }) + + it("handles leap day", () => { + const d = new Date(2024, 1, 29) // Feb 29 2024 (leap year) local + expect(localDateStr(d)).toBe("2024-02-29") + }) + + it("is consistent between Date object and equivalent unix ms input", () => { + const d = new Date(2025, 2, 26) // March 26 2025 local + const ms = d.getTime() + expect(localDateStr(d)).toBe(localDateStr(ms)) + }) +}) + +// --------------------------------------------------------------------------- +// formatBytesFromString — MiB threshold +// --------------------------------------------------------------------------- + +describe("formatBytesFromString MiB/GiB threshold", () => { + const MiB = 1024 * 1024 + const GiB = 1024 * 1024 * 1024 + const TiB = 1024 * 1024 * 1024 * 1024 + + it("value of exactly 0 bytes returns MiB scale", () => { + // "0" bigint → 0 bytes → 0 MiB + const result = formatBytesFromString("0") + expect(result).toContain("MiB") + }) + + it("returns '0 MiB' (zero padded rounds to 0) for 0 bytes", () => { + expect(formatBytesFromString("0")).toBe("0 MiB") + }) + + it("500 MiB (under 1 GiB) displays as MiB, not GiB", () => { + const bytes = BigInt(500 * MiB) + const result = formatBytesFromString(bytes.toString()) + expect(result).toContain("MiB") + expect(result).not.toContain("GiB") + expect(result).not.toContain("TiB") + }) + + it("value just under 1 GiB displays as MiB", () => { + const bytes = BigInt(GiB - 1) + const result = formatBytesFromString(bytes.toString()) + expect(result).toContain("MiB") + expect(result).not.toContain("GiB") + }) + + it("exactly 1 GiB displays as GiB", () => { + const bytes = BigInt(GiB) + const result = formatBytesFromString(bytes.toString()) + expect(result).toContain("GiB") + expect(result).not.toContain("MiB") + expect(result).toBe("1.00 GiB") + }) + + it("value over 1 GiB displays as GiB", () => { + const bytes = BigInt(1.5 * GiB) + const result = formatBytesFromString(bytes.toString()) + expect(result).toContain("GiB") + expect(result).not.toContain("MiB") + expect(result).toBe("1.50 GiB") + }) + + it("value over 1 TiB displays as TiB", () => { + const bytes = BigInt(2 * TiB) + const result = formatBytesFromString(bytes.toString()) + expect(result).toContain("TiB") + expect(result).not.toContain("GiB") + expect(result).toBe("2.00 TiB") + }) + + it("value just over 1 TiB displays as TiB not GiB", () => { + const bytes = BigInt(TiB + 1) + const result = formatBytesFromString(bytes.toString()) + expect(result).toContain("TiB") + }) + + it("null input returns the em dash fallback", () => { + expect(formatBytesFromString(null)).toBe("—") + }) + + it("undefined input returns the em dash fallback", () => { + expect(formatBytesFromString(undefined)).toBe("—") + }) + + it("empty string input returns the em dash fallback", () => { + expect(formatBytesFromString("")).toBe("—") + }) + + it("MiB value rounds to whole number (no decimal)", () => { + const bytes = BigInt(512 * MiB) + const result = formatBytesFromString(bytes.toString()) + // Math.round(512) → "512 MiB" + expect(result).toBe("512 MiB") + }) + + it("100 MiB rounds and displays correctly", () => { + const bytes = BigInt(100 * MiB) + const result = formatBytesFromString(bytes.toString()) + expect(result).toBe("100 MiB") + }) + + it("1 MiB displays as 1 MiB", () => { + const bytes = BigInt(MiB) + const result = formatBytesFromString(bytes.toString()) + expect(result).toBe("1 MiB") + }) +}) diff --git a/src/lib/__tests__/notifications.test.ts b/src/lib/__tests__/notifications.test.ts index 2e1ee30f..287f357b 100644 --- a/src/lib/__tests__/notifications.test.ts +++ b/src/lib/__tests__/notifications.test.ts @@ -77,6 +77,7 @@ function makeContext(overrides: Partial = {}): SnapshotContext trackerPausedAt: null, trackerJoinedAt: "2020-01-01", minimumRatio: 0.6, + mamContext: undefined, ...overrides, } } @@ -488,7 +489,7 @@ describe("buildEventData", () => { // Catches: the label argument being ignored due to parameter order confusion. const { buildEventData } = await import("@/lib/notifications/dispatch") const ctx = makeContext() - const data = buildEventData("anniversary", ctx, "3 Year Anniversary") + const data = buildEventData("anniversary", ctx, null, "3 Year Anniversary") expect(data.label).toBe("3 Year Anniversary") }) diff --git a/src/lib/__tests__/security.test.ts b/src/lib/__tests__/security.test.ts index f256544c..8bc6625e 100644 --- a/src/lib/__tests__/security.test.ts +++ b/src/lib/__tests__/security.test.ts @@ -24,6 +24,7 @@ vi.mock("@/lib/api-helpers", async (importOriginal) => { vi.mock("@/lib/db", () => ({ db: { select: vi.fn(), + selectDistinctOn: vi.fn(), insert: vi.fn(), update: vi.fn(), delete: vi.fn(), @@ -845,7 +846,12 @@ describe("Token leakage prevention", () => { ;(db.select as ReturnType) .mockReturnValueOnce({ from: mockFrom }) .mockReturnValueOnce({ from: mockSettingsFrom }) - // DISTINCT ON query via db.execute — default mock resolves [] + // DISTINCT ON query via db.selectDistinctOn + ;(db.selectDistinctOn as ReturnType).mockReturnValueOnce({ + from: vi.fn().mockReturnValue({ + orderBy: vi.fn().mockResolvedValue([]), + }), + }) const res = await GET() const body = await res.json() @@ -1633,7 +1639,6 @@ describe("Backup restore authenticated flows", () => { expect(res.status).toBe(400) const data = await res.json() expect(data.error).toContain("Invalid or corrupted backup file") - expect(data.error).toContain("Unsupported backup version") }) it("POST /api/settings/backup/restore with no password returns 400", async () => { diff --git a/src/lib/__tests__/server-data.test.ts b/src/lib/__tests__/server-data.test.ts index 640513fa..2d22faa0 100644 --- a/src/lib/__tests__/server-data.test.ts +++ b/src/lib/__tests__/server-data.test.ts @@ -306,6 +306,8 @@ describe("serializeTrackerResponse excludes encryptedApiToken", () => { userPausedAt: null, color: "#00d4ff", qbtTag: null, + mouseholeUrl: null, + hideUnreadBadges: false, remoteUserId: null, platformMeta: null, avatarData: null, @@ -361,6 +363,7 @@ describe("trackerColumns security invariant", () => { "pausedAt", "color", "qbtTag", + "mouseholeUrl", "useProxy", "countCrossSeedUnsatisfied", "isFavorite", diff --git a/src/lib/__tests__/slot-resolve.test.tsx b/src/lib/__tests__/slot-resolve.test.tsx index 8b85d1ae..8483abbe 100644 --- a/src/lib/__tests__/slot-resolve.test.tsx +++ b/src/lib/__tests__/slot-resolve.test.tsx @@ -28,6 +28,8 @@ function makeTracker(overrides: Partial = {}): TrackerSummary { userPausedAt: null, color: "#00d4ff", qbtTag: null, + mouseholeUrl: null, + hideUnreadBadges: false, useProxy: false, countCrossSeedUnsatisfied: false, isFavorite: false, diff --git a/src/lib/__tests__/today.test.ts b/src/lib/__tests__/today.test.ts new file mode 100644 index 00000000..d36cc27d --- /dev/null +++ b/src/lib/__tests__/today.test.ts @@ -0,0 +1,799 @@ +// src/lib/__tests__/today.test.ts +// +// Tests for computeTodayAtAGlance() in src/lib/today.ts. +// The DB layer is mocked via vi.mock. Because vi.mock factories are hoisted to +// the top of the file before any variable declarations, the mock factories use +// inline string sentinels that match the schema mock values exactly. + +import { beforeEach, describe, expect, it, vi } from "vitest" + +// --------------------------------------------------------------------------- +// Per-test data store — populated by seedStore() in each test +// --------------------------------------------------------------------------- + +// These string values must match the values returned by the schema mock below. +// "T_" prefix is just a local naming convention; the actual values are what matter. +const SENT_TRACKERS = "SENT_trackers" +const SENT_SNAPSHOTS = "SENT_snapshots" +const SENT_TRACKER_CP = "SENT_tracker_cp" // single merged query for both checkpoint dates +const SENT_TORRENT_CP = "SENT_torrent_cp" +const SENT_CLIENTS = "SENT_clients" + +// The store is read by the mock's .from() handler +const store: Record = { + [SENT_TRACKERS]: [], + [SENT_SNAPSHOTS]: [], + [SENT_TRACKER_CP]: [], + [SENT_TORRENT_CP]: [], + [SENT_CLIENTS]: [], +} + +function seedStore( + trackers: unknown[] = [], + snapshots: unknown[] = [], + yesterdayCps: unknown[] = [], + dayBeforeCps: unknown[] = [], + torrentCps: unknown[] = [], + clients: unknown[] = [] +) { + store[SENT_TRACKERS] = trackers + store[SENT_SNAPSHOTS] = snapshots + // Both date ranges are now fetched in one inArray query; combine them here + store[SENT_TRACKER_CP] = [...yesterdayCps, ...dayBeforeCps] + store[SENT_TORRENT_CP] = torrentCps + store[SENT_CLIENTS] = clients +} + +// --------------------------------------------------------------------------- +// DB mock — vi.mock factory is hoisted; use ONLY inline literals here +// --------------------------------------------------------------------------- + +vi.mock("@/lib/db", () => { + // NOTE: Cannot reference outer variables here — this factory is hoisted. + // The store/storeDayBefore variables are module-level so they ARE accessible + // after hoisting as long as we reference them by closure (not by value at + // declaration time). Vitest hoists vi.mock but the factory closure still + // has access to module-level mutable state. + return { + db: { + select: vi.fn(() => ({ + from: vi.fn((table: string) => ({ + where: vi.fn((_cond: unknown) => { + // todaySnapshots has .orderBy() after .where() + if (table === "SENT_snapshots") { + return { + orderBy: vi.fn(() => Promise.resolve(store.SENT_snapshots ?? [])), + } + } + // trackerDailyCheckpoints is fetched in a single inArray query + if (table === "SENT_tracker_cp") { + return Promise.resolve(store.SENT_tracker_cp ?? []) + } + if (table === "SENT_torrent_cp") { + return Promise.resolve(store.SENT_torrent_cp ?? []) + } + if (table === "SENT_clients") { + return Promise.resolve(store.SENT_clients ?? []) + } + if (table === "SENT_trackers") { + return Promise.resolve(store.SENT_trackers ?? []) + } + return Promise.resolve([]) + }), + })), + })), + }, + } +}) + +// Schema mock — each table is a string sentinel matching the from() dispatch above +vi.mock("@/lib/db/schema", () => ({ + trackers: "SENT_trackers", + trackerSnapshots: "SENT_snapshots", + trackerDailyCheckpoints: "SENT_tracker_cp", + torrentDailyCheckpoints: "SENT_torrent_cp", + downloadClients: "SENT_clients", +})) + +vi.mock("drizzle-orm", () => ({ + eq: vi.fn((_col: unknown, _val: unknown) => ({ type: "eq" })), + gte: vi.fn((_col: unknown, _val: unknown) => ({ type: "gte" })), + inArray: vi.fn((_col: unknown, _vals: unknown) => ({ type: "inArray" })), + sql: vi.fn(() => ({ type: "sql" })), +})) + +// --------------------------------------------------------------------------- +// Import under test (after mocks) +// --------------------------------------------------------------------------- + +import { localDateStr } from "@/lib/formatters" +import { computeTodayAtAGlance } from "@/lib/today" + +// --------------------------------------------------------------------------- +// Factories +// --------------------------------------------------------------------------- + +function makeTracker( + id: number, + overrides: { qbtTag?: string | null; color?: string | null } = {} +) { + return { + id, + name: `Tracker ${id}`, + color: overrides.color ?? null, + qbtTag: overrides.qbtTag ?? null, + } +} + +function makeSnapshot( + trackerId: number, + polledAt: Date, + uploadedBytes: string, + downloadedBytes: string, + bufferBytes: string | null = null, + ratio: number | null = null, + seedbonus: number | null = null +) { + return { + id: Math.random(), + trackerId, + polledAt, + uploadedBytes, + downloadedBytes, + bufferBytes, + ratio, + seedbonus, + seedingCount: null, + leechingCount: null, + hitAndRuns: null, + warned: null, + freeleechTokens: null, + shareScore: null, + username: null, + group: null, + } +} + +function makeTrackerCheckpoint( + trackerId: number, + checkpointDate: string, + uploadedBytesEnd: string, + downloadedBytesEnd: string, + bufferBytesEnd: string | null = null +) { + return { + id: Math.random(), + trackerId, + checkpointDate, + uploadedBytesEnd, + downloadedBytesEnd, + bufferBytesEnd, + ratioEnd: null, + seedbonusEnd: null, + snapshotCount: 1, + } +} + +function makeTorrent( + hash: string, + name: string, + tags: string, + uploaded: number, + downloaded: number, + addedOn = 0, + completionOn = -1 +) { + return { + hash, + name, + state: "uploading", + tags, + category: "", + upspeed: 0, + dlspeed: 0, + uploaded, + downloaded, + ratio: 1.0, + size: downloaded || 1, + num_seeds: 5, + num_leechs: 0, + num_complete: 5, + num_incomplete: 0, + tracker: "", + added_on: addedOn, + completion_on: completionOn, + last_activity: 0, + seeding_time: 0, + time_active: 0, + seen_complete: 0, + availability: 1, + amount_left: 0, + progress: 1, + content_path: "", + save_path: "", + } +} + +function makeTorrentCheckpoint( + clientId: number, + hash: string, + uploadedStart: string, + downloadedStart: string +) { + return { + id: Math.random(), + clientId, + hash, + checkpointDate: localDateStr(), + uploadedStart, + downloadedStart, + } +} + +// --------------------------------------------------------------------------- +// beforeEach resets store to empty state +// --------------------------------------------------------------------------- + +beforeEach(() => { + seedStore() +}) + +// --------------------------------------------------------------------------- +// Empty state +// --------------------------------------------------------------------------- + +describe("computeTodayAtAGlance — empty state", () => { + it("returns zero fleet upload delta when there are no trackers", async () => { + const result = await computeTodayAtAGlance() + expect(result.fleet.uploadDelta).toBe("0") + }) + + it("returns zero fleet download delta when there are no trackers", async () => { + const result = await computeTodayAtAGlance() + expect(result.fleet.downloadDelta).toBe("0") + }) + + it("returns zero fleet buffer delta when there are no trackers", async () => { + const result = await computeTodayAtAGlance() + expect(result.fleet.bufferDelta).toBe("0") + }) + + it("returns null ratioChange when there is no upload activity", async () => { + const result = await computeTodayAtAGlance() + expect(result.fleet.ratioChange).toBeNull() + }) + + it("returns null yesterday delta values when no checkpoints exist", async () => { + const result = await computeTodayAtAGlance() + expect(result.fleet.uploadDeltaYesterday).toBeNull() + expect(result.fleet.downloadDeltaYesterday).toBeNull() + expect(result.fleet.bufferDeltaYesterday).toBeNull() + }) + + it("returns empty trackers array", async () => { + const result = await computeTodayAtAGlance() + expect(result.trackers).toEqual([]) + }) + + it("returns zero activity counts", async () => { + const result = await computeTodayAtAGlance() + expect(result.activity.addedToday).toBe(0) + expect(result.activity.completedToday).toBe(0) + }) + + it("returns empty movers arrays", async () => { + const result = await computeTodayAtAGlance() + expect(result.movers.topUploaders).toHaveLength(0) + expect(result.movers.topDownloaders).toHaveLength(0) + }) +}) + +// --------------------------------------------------------------------------- +// Single tracker, two snapshots today +// --------------------------------------------------------------------------- + +describe("computeTodayAtAGlance — single tracker two snapshots today", () => { + const GiB = 1024 * 1024 * 1024 + + beforeEach(() => { + const now = new Date() + const tracker = makeTracker(1) + const earliest = makeSnapshot( + 1, + new Date(now.getTime() - 3600000), + String(10n * BigInt(GiB)), + String(5n * BigInt(GiB)) + ) + const latest = makeSnapshot( + 1, + now, + String(11n * BigInt(GiB)), + String(5n * BigInt(GiB) + 512n * 1024n * 1024n) + ) + seedStore([tracker], [earliest, latest]) + }) + + it("computes upload delta as last minus first snapshot value", async () => { + const result = await computeTodayAtAGlance() + expect(BigInt(result.fleet.uploadDelta)).toBe(BigInt(GiB)) + }) + + it("computes download delta correctly", async () => { + const result = await computeTodayAtAGlance() + expect(BigInt(result.fleet.downloadDelta)).toBe(512n * 1024n * 1024n) + }) + + it("exposes the tracker entry with the correct upload delta", async () => { + const result = await computeTodayAtAGlance() + expect(result.trackers).toHaveLength(1) + expect(result.trackers[0].id).toBe(1) + expect(BigInt(result.trackers[0].uploadDelta)).toBe(BigInt(GiB)) + }) +}) + +// --------------------------------------------------------------------------- +// Fewer than 2 snapshots +// --------------------------------------------------------------------------- + +describe("computeTodayAtAGlance — tracker with only one snapshot today", () => { + beforeEach(() => { + const tracker = makeTracker(1) + const snap = makeSnapshot(1, new Date(), "1000000000", "500000000") + seedStore([tracker], [snap]) + }) + + it("returns zero deltas when tracker has fewer than 2 snapshots today", async () => { + const result = await computeTodayAtAGlance() + expect(result.trackers[0].uploadDelta).toBe("0") + expect(result.trackers[0].downloadDelta).toBe("0") + }) +}) + +// --------------------------------------------------------------------------- +// Null bufferBytes handling +// --------------------------------------------------------------------------- + +describe("computeTodayAtAGlance — null bufferBytes on both snapshots", () => { + beforeEach(() => { + const tracker = makeTracker(1) + const earliest = makeSnapshot( + 1, + new Date(Date.now() - 3600000), + "1000000000", + "500000000", + null + ) + const latest = makeSnapshot(1, new Date(), "2000000000", "600000000", null) + seedStore([tracker], [earliest, latest]) + }) + + it("does not crash when bufferBytes is null and returns 0 buffer delta", async () => { + const result = await computeTodayAtAGlance() + expect(result.fleet.bufferDelta).toBe("0") + expect(result.trackers[0].bufferDelta).toBe("0") + }) +}) + +describe("computeTodayAtAGlance — null bufferBytes on earliest, value on latest", () => { + beforeEach(() => { + const tracker = makeTracker(1) + const earliest = makeSnapshot( + 1, + new Date(Date.now() - 3600000), + "1000000000", + "500000000", + null + ) + const latest = makeSnapshot(1, new Date(), "2000000000", "600000000", "1073741824") + seedStore([tracker], [earliest, latest]) + }) + + it("treats null bufferBytes as 0n so delta equals the latest buffer value", async () => { + const result = await computeTodayAtAGlance() + expect(BigInt(result.trackers[0].bufferDelta)).toBe(1073741824n) + }) +}) + +// --------------------------------------------------------------------------- +// Yesterday comparison via checkpoint pairs +// --------------------------------------------------------------------------- + +describe("computeTodayAtAGlance — yesterday comparison", () => { + const yesterdayStr = localDateStr(Date.now() - 86400000) + const dayBeforeStr = localDateStr(Date.now() - 172800000) + + it("computes fleet upload and download deltas for yesterday", async () => { + const tracker = makeTracker(1) + const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "2000000000", "1000000000") + const snap2 = makeSnapshot(1, new Date(), "3000000000", "1500000000") + // Yesterday ended at 2G upload, day-before ended at 1G → delta = 1G + const yestCp = makeTrackerCheckpoint(1, yesterdayStr, "2000000000", "1000000000") + const dayBeforeCp = makeTrackerCheckpoint(1, dayBeforeStr, "1000000000", "500000000") + seedStore([tracker], [snap1, snap2], [yestCp], [dayBeforeCp]) + + const result = await computeTodayAtAGlance() + expect(result.fleet.uploadDeltaYesterday).toBe("1000000000") + expect(result.fleet.downloadDeltaYesterday).toBe("500000000") + }) + + it("returns null when yesterday checkpoint exists but no day-before checkpoint", async () => { + const tracker = makeTracker(1) + const snap = makeSnapshot(1, new Date(), "2000000000", "1000000000") + const yestCp = makeTrackerCheckpoint(1, yesterdayStr, "2000000000", "1000000000") + seedStore([tracker], [snap], [yestCp], []) + + const result = await computeTodayAtAGlance() + expect(result.fleet.uploadDeltaYesterday).toBeNull() + expect(result.fleet.downloadDeltaYesterday).toBeNull() + }) + + it("returns null when checkpoints exist for a different tracker id", async () => { + const tracker = makeTracker(1) + const snap = makeSnapshot(1, new Date(), "2000000000", "1000000000") + // trackerId 99 does not match tracker 1 + const yestCp = makeTrackerCheckpoint(99, yesterdayStr, "2000000000", "1000000000") + const dayBeforeCp = makeTrackerCheckpoint(99, dayBeforeStr, "1000000000", "500000000") + seedStore([tracker], [snap], [yestCp], [dayBeforeCp]) + + const result = await computeTodayAtAGlance() + expect(result.fleet.uploadDeltaYesterday).toBeNull() + }) + + it("treats null bufferBytesEnd as 0n in yesterday buffer delta", async () => { + const tracker = makeTracker(1) + const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000") + const snap2 = makeSnapshot(1, new Date(), "2000000000", "1000000000") + const yestCp = makeTrackerCheckpoint(1, yesterdayStr, "2000000000", "1000000000", null) + const dayBeforeCp = makeTrackerCheckpoint(1, dayBeforeStr, "1000000000", "500000000", null) + seedStore([tracker], [snap1, snap2], [yestCp], [dayBeforeCp]) + + const result = await computeTodayAtAGlance() + expect(result.fleet.bufferDeltaYesterday).toBe("0") + }) +}) + +// --------------------------------------------------------------------------- +// Torrent movers tag filtering +// --------------------------------------------------------------------------- + +describe("computeTodayAtAGlance — torrent movers tag filtering", () => { + it("excludes torrents whose tags do not match any tracker qbtTag", async () => { + const tracker = makeTracker(1, { qbtTag: "aither" }) + const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000") + const snap2 = makeSnapshot(1, new Date(), "2000000000", "1000000000") + const torrent = makeTorrent("hash1", "Some Movie", "other-tracker", 5000000000, 3000000000) + const cp = makeTorrentCheckpoint(1, "hash1", "0", "0") + const client = { id: 1, name: "qBit", cachedTorrents: JSON.stringify([torrent]) } + seedStore([tracker], [snap1, snap2], [], [], [cp], [client]) + + const result = await computeTodayAtAGlance() + expect(result.movers.topUploaders).toHaveLength(0) + expect(result.movers.topDownloaders).toHaveLength(0) + }) + + it("includes torrents whose tags match a tracker's qbtTag", async () => { + const tracker = makeTracker(1, { qbtTag: "aither" }) + const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000") + const snap2 = makeSnapshot(1, new Date(), "2000000000", "1000000000") + const torrent = makeTorrent("hash1", "Some Movie", "aither", 5000000000, 3000000000) + const cp = makeTorrentCheckpoint(1, "hash1", "0", "0") + const client = { id: 1, name: "qBit", cachedTorrents: JSON.stringify([torrent]) } + seedStore([tracker], [snap1, snap2], [], [], [cp], [client]) + + const result = await computeTodayAtAGlance() + expect(result.movers.topUploaders).toHaveLength(1) + expect(result.movers.topUploaders[0].hash).toBe("hash1") + expect(result.movers.topUploaders[0].qbtTag).toBe("aither") + }) + + it("excludes torrents with zero upload delta from topUploaders", async () => { + const tracker = makeTracker(1, { qbtTag: "aither" }) + const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000") + const snap2 = makeSnapshot(1, new Date(), "2000000000", "1000000000") + // uploaded matches checkpoint start → upload delta = 0 + const torrent = makeTorrent("hash1", "Stalled Movie", "aither", 1000000000, 3000000000) + const cp = makeTorrentCheckpoint(1, "hash1", "1000000000", "0") + const client = { id: 1, name: "qBit", cachedTorrents: JSON.stringify([torrent]) } + seedStore([tracker], [snap1, snap2], [], [], [cp], [client]) + + const result = await computeTodayAtAGlance() + expect(result.movers.topUploaders).toHaveLength(0) + }) + + it("excludes torrents with zero download delta from topDownloaders", async () => { + const tracker = makeTracker(1, { qbtTag: "aither" }) + const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000") + const snap2 = makeSnapshot(1, new Date(), "2000000000", "1000000000") + // downloaded matches checkpoint start → download delta = 0 + const torrent = makeTorrent("hash1", "Completed Movie", "aither", 5000000000, 3000000000) + const cp = makeTorrentCheckpoint(1, "hash1", "0", "3000000000") + const client = { id: 1, name: "qBit", cachedTorrents: JSON.stringify([torrent]) } + seedStore([tracker], [snap1, snap2], [], [], [cp], [client]) + + const result = await computeTodayAtAGlance() + expect(result.movers.topDownloaders).toHaveLength(0) + }) + + it("caps topUploaders and topDownloaders at 5 entries each", async () => { + const tracker = makeTracker(1, { qbtTag: "aither" }) + const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000") + const snap2 = makeSnapshot(1, new Date(), "2000000000", "1000000000") + const torrents = Array.from({ length: 8 }, (_, i) => + makeTorrent(`hash${i}`, `Movie ${i}`, "aither", (i + 1) * 1000000000, (i + 1) * 500000000) + ) + const cps = torrents.map((t) => makeTorrentCheckpoint(1, t.hash, "0", "0")) + const client = { id: 1, name: "qBit", cachedTorrents: JSON.stringify(torrents) } + seedStore([tracker], [snap1, snap2], [], [], cps, [client]) + + const result = await computeTodayAtAGlance() + expect(result.movers.topUploaders.length).toBeLessThanOrEqual(5) + expect(result.movers.topDownloaders.length).toBeLessThanOrEqual(5) + }) + + it("excludes torrents that have no matching torrent checkpoint entry", async () => { + const tracker = makeTracker(1, { qbtTag: "aither" }) + const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000") + const snap2 = makeSnapshot(1, new Date(), "2000000000", "1000000000") + const torrent = makeTorrent( + "hash-no-cp", + "No Checkpoint Movie", + "aither", + 5000000000, + 3000000000 + ) + const client = { id: 1, name: "qBit", cachedTorrents: JSON.stringify([torrent]) } + // No checkpoint in torrent CP list + seedStore([tracker], [snap1, snap2], [], [], [], [client]) + + const result = await computeTodayAtAGlance() + expect(result.movers.topUploaders).toHaveLength(0) + }) + + it("attaches tracker color to matched mover entries", async () => { + const tracker = makeTracker(1, { qbtTag: "aither", color: "#00d4ff" }) + const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000") + const snap2 = makeSnapshot(1, new Date(), "2000000000", "1000000000") + const torrent = makeTorrent("hash1", "Colored Movie", "aither", 5000000000, 3000000000) + const cp = makeTorrentCheckpoint(1, "hash1", "0", "0") + const client = { id: 1, name: "qBit", cachedTorrents: JSON.stringify([torrent]) } + seedStore([tracker], [snap1, snap2], [], [], [cp], [client]) + + const result = await computeTodayAtAGlance() + expect(result.movers.topUploaders[0].trackerColor).toBe("#00d4ff") + }) +}) + +// --------------------------------------------------------------------------- +// Fleet aggregation — multiple trackers +// --------------------------------------------------------------------------- + +describe("computeTodayAtAGlance — fleet aggregation with multiple trackers", () => { + it("sums upload deltas across all trackers", async () => { + const tracker1 = makeTracker(1) + const tracker2 = makeTracker(2) + const now = Date.now() + // tracker1: 3G - 1G = 2G; tracker2: 4G - 2G = 2G; total = 4G + const snaps = [ + makeSnapshot(1, new Date(now - 3600000), "1000000000", "500000000"), + makeSnapshot(2, new Date(now - 3600000), "2000000000", "1000000000"), + makeSnapshot(1, new Date(now), "3000000000", "600000000"), + makeSnapshot(2, new Date(now), "4000000000", "1200000000"), + ] + seedStore([tracker1, tracker2], snaps) + + const result = await computeTodayAtAGlance() + expect(BigInt(result.fleet.uploadDelta)).toBe(4000000000n) + }) + + it("sums download deltas across all trackers", async () => { + const tracker1 = makeTracker(1) + const tracker2 = makeTracker(2) + const now = Date.now() + // tracker1 dl: 700M - 500M = 200M; tracker2 dl: 500M - 200M = 300M; total = 500M + const snaps = [ + makeSnapshot(1, new Date(now - 3600000), "1000000000", "500000000"), + makeSnapshot(2, new Date(now - 3600000), "1000000000", "200000000"), + makeSnapshot(1, new Date(now), "2000000000", "700000000"), + makeSnapshot(2, new Date(now), "2000000000", "500000000"), + ] + seedStore([tracker1, tracker2], snaps) + + const result = await computeTodayAtAGlance() + expect(BigInt(result.fleet.downloadDelta)).toBe(500000000n) + }) + + it("tracker with no snapshots contributes zero and still appears in trackers array", async () => { + const tracker1 = makeTracker(1) + const tracker2 = makeTracker(2) // no snapshots + const now = Date.now() + const snaps = [ + makeSnapshot(1, new Date(now - 3600000), "1000000000", "500000000"), + makeSnapshot(1, new Date(now), "2000000000", "600000000"), + ] + seedStore([tracker1, tracker2], snaps) + + const result = await computeTodayAtAGlance() + expect(BigInt(result.fleet.uploadDelta)).toBe(1000000000n) + expect(result.trackers).toHaveLength(2) + expect(result.trackers.find((t) => t.id === 2)?.uploadDelta).toBe("0") + }) +}) + +// --------------------------------------------------------------------------- +// Ratio weighted average +// --------------------------------------------------------------------------- + +describe("computeTodayAtAGlance — ratio weighted average", () => { + it("weights ratio change by upload volume — more upload means more weight", async () => { + const tracker1 = makeTracker(1) + const tracker2 = makeTracker(2) + const now = Date.now() + // tracker1: +2G upload, ratio +0.1; tracker2: +0.5G upload, ratio +1.0 + // weighted avg = (0.1 * 2G + 1.0 * 0.5G) / (2G + 0.5G) + // = (0.2G + 0.5G) / 2.5G = 0.7 / 2.5 = 0.28 + const snaps = [ + makeSnapshot(1, new Date(now - 3600000), "2000000000", "2000000000", null, 1.0), + makeSnapshot(2, new Date(now - 3600000), "500000000", "500000000", null, 1.0), + makeSnapshot(1, new Date(now), "4000000000", "2000000000", null, 1.1), + makeSnapshot(2, new Date(now), "1000000000", "500000000", null, 2.0), + ] + seedStore([tracker1, tracker2], snaps) + + const result = await computeTodayAtAGlance() + expect(result.fleet.ratioChange).not.toBeNull() + expect(result.fleet.ratioChange as number).toBeCloseTo(0.28, 5) + }) + + it("returns null ratioChange when all upload deltas are zero (no progress this session)", async () => { + const tracker = makeTracker(1) + // Both snapshots have same uploaded bytes — delta = 0, weight = 0 + const snap1 = makeSnapshot( + 1, + new Date(Date.now() - 3600000), + "1000000000", + "500000000", + null, + 1.0 + ) + const snap2 = makeSnapshot(1, new Date(), "1000000000", "500000000", null, 1.5) + seedStore([tracker], [snap1, snap2]) + + const result = await computeTodayAtAGlance() + expect(result.fleet.ratioChange).toBeNull() + }) +}) + +// --------------------------------------------------------------------------- +// Activity counts +// --------------------------------------------------------------------------- + +describe("computeTodayAtAGlance — activity counts", () => { + it("counts torrents whose added_on unix timestamp falls on today", async () => { + const tracker = makeTracker(1, { qbtTag: "aither" }) + const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000") + const snap2 = makeSnapshot(1, new Date(), "2000000000", "1000000000") + + const todayNoon = new Date() + todayNoon.setHours(12, 0, 0, 0) + const torrent = makeTorrent( + "h1", + "New Movie", + "aither", + 0, + 0, + Math.floor(todayNoon.getTime() / 1000), + -1 + ) + const client = { id: 1, name: "qBit", cachedTorrents: JSON.stringify([torrent]) } + seedStore([tracker], [snap1, snap2], [], [], [], [client]) + + const result = await computeTodayAtAGlance() + expect(result.activity.addedToday).toBe(1) + }) + + it("does not count torrents with added_on of 0 (unix epoch, not today)", async () => { + const tracker = makeTracker(1, { qbtTag: "aither" }) + const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000") + const snap2 = makeSnapshot(1, new Date(), "2000000000", "1000000000") + const torrent = makeTorrent("h1", "Old Movie", "aither", 0, 0, 0, -1) + const client = { id: 1, name: "qBit", cachedTorrents: JSON.stringify([torrent]) } + seedStore([tracker], [snap1, snap2], [], [], [], [client]) + + const result = await computeTodayAtAGlance() + expect(result.activity.addedToday).toBe(0) + }) + + it("counts torrents whose completion_on unix timestamp falls on today", async () => { + const tracker = makeTracker(1, { qbtTag: "aither" }) + const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000") + const snap2 = makeSnapshot(1, new Date(), "2000000000", "1000000000") + + const todayNoon = new Date() + todayNoon.setHours(12, 0, 0, 0) + const torrent = makeTorrent( + "h1", + "Done Movie", + "aither", + 0, + 5000000000, + 0, + Math.floor(todayNoon.getTime() / 1000) + ) + const client = { id: 1, name: "qBit", cachedTorrents: JSON.stringify([torrent]) } + seedStore([tracker], [snap1, snap2], [], [], [], [client]) + + const result = await computeTodayAtAGlance() + expect(result.activity.completedToday).toBe(1) + }) + + it("ignores completion_on of -1 (torrent is still downloading)", async () => { + const tracker = makeTracker(1, { qbtTag: "aither" }) + const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000") + const snap2 = makeSnapshot(1, new Date(), "2000000000", "1000000000") + const torrent = makeTorrent("h1", "Seeding Movie", "aither", 5000000000, 5000000000, 0, -1) + const client = { id: 1, name: "qBit", cachedTorrents: JSON.stringify([torrent]) } + seedStore([tracker], [snap1, snap2], [], [], [], [client]) + + const result = await computeTodayAtAGlance() + expect(result.activity.completedToday).toBe(0) + }) + + it("ignores completion_on of 0 (qBittorrent sentinel for 'not completed')", async () => { + const tracker = makeTracker(1, { qbtTag: "aither" }) + const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000") + const snap2 = makeSnapshot(1, new Date(), "2000000000", "1000000000") + const torrent = makeTorrent("h1", "Seeding Movie", "aither", 5000000000, 5000000000, 0, 0) + const client = { id: 1, name: "qBit", cachedTorrents: JSON.stringify([torrent]) } + seedStore([tracker], [snap1, snap2], [], [], [], [client]) + + const result = await computeTodayAtAGlance() + expect(result.activity.completedToday).toBe(0) + }) +}) + +// --------------------------------------------------------------------------- +// Output shape invariants +// --------------------------------------------------------------------------- + +describe("computeTodayAtAGlance — output shape", () => { + it("fleet object has all required keys", async () => { + const result = await computeTodayAtAGlance() + expect(result.fleet).toHaveProperty("uploadDelta") + expect(result.fleet).toHaveProperty("downloadDelta") + expect(result.fleet).toHaveProperty("bufferDelta") + expect(result.fleet).toHaveProperty("ratioChange") + expect(result.fleet).toHaveProperty("seedbonusChange") + expect(result.fleet).toHaveProperty("uploadDeltaYesterday") + expect(result.fleet).toHaveProperty("downloadDeltaYesterday") + expect(result.fleet).toHaveProperty("bufferDeltaYesterday") + }) + + it("fleet delta values are strings (bigints serialized for JSON safety)", async () => { + const result = await computeTodayAtAGlance() + expect(typeof result.fleet.uploadDelta).toBe("string") + expect(typeof result.fleet.downloadDelta).toBe("string") + expect(typeof result.fleet.bufferDelta).toBe("string") + }) + + it("movers has topUploaders and topDownloaders as arrays", async () => { + const result = await computeTodayAtAGlance() + expect(Array.isArray(result.movers.topUploaders)).toBe(true) + expect(Array.isArray(result.movers.topDownloaders)).toBe(true) + }) + + it("each tracker entry has id, name, color, uploadDelta, downloadDelta, bufferDelta", async () => { + const tracker = makeTracker(1) + const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000") + const snap2 = makeSnapshot(1, new Date(), "2000000000", "600000000") + seedStore([tracker], [snap1, snap2]) + + const result = await computeTodayAtAGlance() + const t = result.trackers[0] + expect(t).toHaveProperty("id") + expect(t).toHaveProperty("name") + expect(t).toHaveProperty("color") + expect(t).toHaveProperty("uploadDelta") + expect(t).toHaveProperty("downloadDelta") + expect(t).toHaveProperty("bufferDelta") + }) +}) diff --git a/src/lib/__tests__/tracker-events.test.ts b/src/lib/__tests__/tracker-events.test.ts index 0eb92711..ad4f35dd 100644 --- a/src/lib/__tests__/tracker-events.test.ts +++ b/src/lib/__tests__/tracker-events.test.ts @@ -4,7 +4,9 @@ import { afterEach, describe, expect, it, vi } from "vitest" import type { NotificationEventType } from "@/lib/notifications/types" import { VALID_EVENT_TYPES } from "@/lib/notifications/types" import { + checkActiveHnrs, checkAnniversaryMilestone, + checkBonusCapReached, checkBufferMilestoneCrossed, checkHnrIncrease, checkRankChange, @@ -12,6 +14,8 @@ import { checkRatioBelowMinimumTransition, checkRatioDelta, checkTrackerError, + checkUnsatisfiedLimitApproaching, + checkVipExpiringSoon, checkWarned, checkWarnedTransition, checkZeroSeeding, @@ -325,3 +329,111 @@ describe("EVENT_SNOOZE_MS", () => { expect(EVENT_SNOOZE_MS.anniversary).toBe(sevenDays) }) }) + +describe("checkBonusCapReached", () => { + it("returns true when current >= capLimit and previous was below cap (transition)", () => { + expect(checkBonusCapReached(1000, 800, 1000)).toBe(true) + }) + it("returns true when current exceeds capLimit and previous was below cap", () => { + expect(checkBonusCapReached(1200, 800, 1000)).toBe(true) + }) + it("returns false when current is below capLimit", () => { + expect(checkBonusCapReached(900, 800, 1000)).toBe(false) + }) + it("returns false when previous was already at or above capLimit (already notified)", () => { + expect(checkBonusCapReached(1100, 1000, 1000)).toBe(false) + }) + it("returns false when currentBonus is null", () => { + expect(checkBonusCapReached(null, 800, 1000)).toBe(false) + }) + it("returns false when currentBonus is undefined", () => { + expect(checkBonusCapReached(undefined, 800, 1000)).toBe(false) + }) + it("returns true when previousBonus is null and current >= capLimit (first poll at cap fires)", () => { + expect(checkBonusCapReached(1000, null, 1000)).toBe(true) + }) +}) + +describe("checkVipExpiringSoon", () => { + afterEach(() => { + vi.useRealTimers() + }) + + it("returns true when expiry is within threshold days", () => { + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-03-26T00:00:00Z")) + // Expires in 3 days, threshold is 7 + expect(checkVipExpiringSoon("2026-03-29T00:00:00Z", 7)).toBe(true) + }) + it("returns false when expiry is beyond threshold days", () => { + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-03-26T00:00:00Z")) + // Expires in 30 days, threshold is 7 + expect(checkVipExpiringSoon("2026-04-25T00:00:00Z", 7)).toBe(false) + }) + it("returns false when vipUntil is null", () => { + expect(checkVipExpiringSoon(null, 7)).toBe(false) + }) + it("returns false when vipUntil is undefined", () => { + expect(checkVipExpiringSoon(undefined, 7)).toBe(false) + }) + it("returns false when vipUntil is an invalid date string", () => { + expect(checkVipExpiringSoon("not-a-date", 7)).toBe(false) + }) + it("returns false when VIP has already expired (date in the past)", () => { + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-03-26T00:00:00Z")) + expect(checkVipExpiringSoon("2026-03-20T00:00:00Z", 7)).toBe(false) + }) +}) + +describe("checkUnsatisfiedLimitApproaching", () => { + it("returns true when count/limit ratio meets the percent threshold", () => { + // 80/100 = 80% >= 80% + expect(checkUnsatisfiedLimitApproaching(80, 100, 80)).toBe(true) + }) + it("returns true when count/limit ratio exceeds the percent threshold", () => { + // 90/100 = 90% >= 80% + expect(checkUnsatisfiedLimitApproaching(90, 100, 80)).toBe(true) + }) + it("returns false when count/limit ratio is below threshold", () => { + // 50/100 = 50% < 80% + expect(checkUnsatisfiedLimitApproaching(50, 100, 80)).toBe(false) + }) + it("returns false when count is null", () => { + expect(checkUnsatisfiedLimitApproaching(null, 100, 80)).toBe(false) + }) + it("returns false when count is undefined", () => { + expect(checkUnsatisfiedLimitApproaching(undefined, 100, 80)).toBe(false) + }) + it("returns false when limit is null", () => { + expect(checkUnsatisfiedLimitApproaching(80, null, 80)).toBe(false) + }) + it("returns false when limit is 0 (division by zero guard)", () => { + expect(checkUnsatisfiedLimitApproaching(80, 0, 80)).toBe(false) + }) +}) + +describe("checkActiveHnrs", () => { + it("returns true when count increased from 0 to 2 (transition)", () => { + expect(checkActiveHnrs(2, 0)).toBe(true) + }) + it("returns true when count increased from a prior positive value", () => { + expect(checkActiveHnrs(5, 3)).toBe(true) + }) + it("returns false when count is 0", () => { + expect(checkActiveHnrs(0, 0)).toBe(false) + }) + it("returns false when count is null", () => { + expect(checkActiveHnrs(null, 0)).toBe(false) + }) + it("returns false when count is undefined", () => { + expect(checkActiveHnrs(undefined, 0)).toBe(false) + }) + it("returns false when count stayed the same (already notified)", () => { + expect(checkActiveHnrs(3, 3)).toBe(false) + }) + it("returns false when count decreased (resolving HnRs)", () => { + expect(checkActiveHnrs(2, 5)).toBe(false) + }) +}) diff --git a/src/lib/__tests__/tracker-serializer.test.ts b/src/lib/__tests__/tracker-serializer.test.ts index a074052b..5bb6191b 100644 --- a/src/lib/__tests__/tracker-serializer.test.ts +++ b/src/lib/__tests__/tracker-serializer.test.ts @@ -17,6 +17,8 @@ const mockTracker = { userPausedAt: null, color: "#00d4ff", qbtTag: null, + mouseholeUrl: null, + hideUnreadBadges: false, remoteUserId: null, platformMeta: null, avatarData: null, diff --git a/src/lib/adapters/constants.ts b/src/lib/adapters/constants.ts index bafb9238..634352d3 100644 --- a/src/lib/adapters/constants.ts +++ b/src/lib/adapters/constants.ts @@ -1,10 +1,13 @@ // src/lib/adapters/constants.ts -export const VALID_PLATFORM_TYPES = ["unit3d", "gazelle", "ggn", "nebulance"] as const +export const VALID_PLATFORM_TYPES = ["unit3d", "gazelle", "ggn", "nebulance", "mam"] as const + +export const MAM_BONUS_CAP = 99999 export const DEFAULT_API_PATHS: Record = { unit3d: "/api/user", gazelle: "/ajax.php", ggn: "/api.php", nebulance: "/api.php", + mam: "/jsonLoad.php", } diff --git a/src/lib/adapters/index.ts b/src/lib/adapters/index.ts index fe6a13f2..20f56f12 100644 --- a/src/lib/adapters/index.ts +++ b/src/lib/adapters/index.ts @@ -7,6 +7,7 @@ import type { Agent as HttpAgent } from "node:http" import { findRegistryEntry } from "@/data/tracker-registry" import { GazelleAdapter } from "./gazelle" import { GGnAdapter } from "./ggn" +import { MamAdapter } from "./mam" import { NebulanceAdapter } from "./nebulance" import type { FetchOptions, TrackerAdapter } from "./types" import { Unit3dAdapter } from "./unit3d" @@ -16,6 +17,7 @@ export { DEFAULT_API_PATHS, VALID_PLATFORM_TYPES } from "./constants" const adapters: Record = { gazelle: new GazelleAdapter(), ggn: new GGnAdapter(), + mam: new MamAdapter(), nebulance: new NebulanceAdapter(), unit3d: new Unit3dAdapter(), } diff --git a/src/lib/adapters/mam.test.ts b/src/lib/adapters/mam.test.ts new file mode 100644 index 00000000..0c90758d --- /dev/null +++ b/src/lib/adapters/mam.test.ts @@ -0,0 +1,325 @@ +// src/lib/adapters/mam.test.ts +// +// Functions: mockMamResponse, mockFetch, describe(MamAdapter - parsing), +// describe(MamAdapter - auth), describe(MamAdapter - error handling), +// describe(MamAdapter - fetchRaw) + +import { beforeEach, describe, expect, it, vi } from "vitest" +import { MamAdapter } from "./mam" + +function mockMamResponse(overrides?: Partial>) { + return { + username: "trackerfan", + uid: 12345, + classname: "VIP", + ratio: 2.47, + uploaded: "500.25 GiB", + downloaded: "202.57 GiB", + uploaded_bytes: 537062408601, + downloaded_bytes: 217524183040, + seedbonus: 98765, + wedges: 3, + vip_until: "2027-01-01", + connectable: "Yes", + recently_deleted: 0, + leeching: { name: "Leeching", count: 2, red: false, size: null }, + sSat: { name: "Seeding Satisfied", count: 10, red: false, size: null }, + seedHnr: { name: "Seeding HnR", count: 1, red: true, size: null }, + seedUnsat: { name: "Seeding Unsatisfied", count: 3, red: true, size: null }, + upAct: { name: "Upload Active", count: 4, red: false, size: null }, + upInact: { name: "Upload Inactive", count: 0, red: false, size: null }, + inactHnr: { name: "Inactive HnR", count: 5, red: true, size: null }, + inactSat: { name: "Inactive Satisfied", count: 8, red: false, size: null }, + inactUnsat: { name: "Inactive Unsatisfied", count: 2, red: true, size: null }, + unsat: { name: "Unsatisfied", count: 2, red: true, size: null, limit: 10 }, + duplicates: { name: "Duplicates", count: 0, red: false, size: null }, + reseed: { name: "Reseed", count: 0, inactive: 0, red: false }, + ite: { name: "Tracker Errors", count: 1, latest: 1711000000 }, + notifs: { + pms: 3, + aboutToDropClient: 0, + tickets: 1, + waiting_tickets: 0, + requests: 2, + topics: 5, + }, + ...overrides, + } +} + +function mockFetch(overrides?: Partial>) { + return vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + json: async () => mockMamResponse(overrides), + } as Response) +} + +describe("MamAdapter - parsing", () => { + const adapter = new MamAdapter() + + beforeEach(() => { + vi.restoreAllMocks() + }) + + it("parses a valid response into TrackerStats", async () => { + mockFetch() + + const stats = await adapter.fetchStats( + "https://www.myanonamouse.net", + "fake-session-id", + "/jsonLoad.php" + ) + + expect(stats.username).toBe("trackerfan") + expect(stats.group).toBe("VIP") + expect(stats.remoteUserId).toBe(12345) + expect(stats.uploadedBytes).toBe(BigInt(537062408601)) + expect(stats.downloadedBytes).toBe(BigInt(217524183040)) + expect(stats.ratio).toBeCloseTo(2.47) + expect(stats.bufferBytes).toBe(BigInt(537062408601) - BigInt(217524183040)) + expect(stats.seedbonus).toBe(98765) + expect(stats.freeleechTokens).toBe(3) + expect(stats.hitAndRuns).toBe(5) + expect(stats.requiredRatio).toBeNull() + expect(stats.warned).toBeNull() + }) + + it("aggregates seedingCount from sSat + seedHnr + seedUnsat + upAct", async () => { + mockFetch() + + const stats = await adapter.fetchStats( + "https://www.myanonamouse.net", + "fake-session-id", + "/jsonLoad.php" + ) + + // 10 + 1 + 3 + 4 = 18 + expect(stats.seedingCount).toBe(18) + }) + + it("reads leechingCount directly from leeching.count", async () => { + mockFetch() + + const stats = await adapter.fetchStats( + "https://www.myanonamouse.net", + "fake-session-id", + "/jsonLoad.php" + ) + + expect(stats.leechingCount).toBe(2) + }) + + it("returns zero bufferBytes when downloaded exceeds uploaded", async () => { + mockFetch({ + uploaded_bytes: 100_000_000, + downloaded_bytes: 500_000_000, + }) + + const stats = await adapter.fetchStats( + "https://www.myanonamouse.net", + "session-id", + "/jsonLoad.php" + ) + + expect(stats.bufferBytes).toBe(BigInt(0)) + }) + + it("handles missing snatch_summary fields and returns seedingCount of 0", async () => { + mockFetch({ + sSat: undefined, + seedHnr: undefined, + seedUnsat: undefined, + upAct: undefined, + leeching: undefined, + inactHnr: undefined, + }) + + const stats = await adapter.fetchStats( + "https://www.myanonamouse.net", + "session-id", + "/jsonLoad.php" + ) + + expect(stats.seedingCount).toBe(0) + expect(stats.leechingCount).toBe(0) + expect(stats.hitAndRuns).toBeNull() + }) + + it("maps wedges to freeleechTokens", async () => { + mockFetch({ wedges: 7 }) + + const stats = await adapter.fetchStats( + "https://www.myanonamouse.net", + "session-id", + "/jsonLoad.php" + ) + + expect(stats.freeleechTokens).toBe(7) + }) + + it("maps inactHnr.count to hitAndRuns", async () => { + mockFetch({ inactHnr: { name: "Inactive HnR", count: 12, red: true, size: null } }) + + const stats = await adapter.fetchStats( + "https://www.myanonamouse.net", + "session-id", + "/jsonLoad.php" + ) + + expect(stats.hitAndRuns).toBe(12) + }) + + it("populates MamPlatformMeta with all expected fields", async () => { + mockFetch() + + const stats = await adapter.fetchStats( + "https://www.myanonamouse.net", + "session-id", + "/jsonLoad.php" + ) + + const meta = stats.platformMeta as { + vipUntil?: string + connectable?: string + unsatisfiedCount?: number + unsatisfiedLimit?: number + inactiveSatisfiedCount?: number + seedingHnrCount?: number + trackerErrorCount?: number + recentlyDeleted?: number + unreadPMs?: number + openTickets?: number + pendingRequests?: number + unreadTopics?: number + } + + expect(meta).toBeDefined() + expect(meta.vipUntil).toBe("2027-01-01") + expect(meta.connectable).toBe("Yes") + expect(meta.unsatisfiedCount).toBe(2) + expect(meta.unsatisfiedLimit).toBe(10) + expect(meta.inactiveSatisfiedCount).toBe(8) + expect(meta.seedingHnrCount).toBe(1) + expect(meta.trackerErrorCount).toBe(1) + expect(meta.recentlyDeleted).toBe(0) + expect(meta.unreadPMs).toBe(3) + expect(meta.openTickets).toBe(1) + expect(meta.pendingRequests).toBe(2) + expect(meta.unreadTopics).toBe(5) + }) +}) + +describe("MamAdapter - auth", () => { + const adapter = new MamAdapter() + + beforeEach(() => { + vi.restoreAllMocks() + }) + + it("sends mam_id as Cookie header", async () => { + const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + json: async () => mockMamResponse(), + } as Response) + + await adapter.fetchStats("https://www.myanonamouse.net", "my-session-cookie", "/jsonLoad.php") + + const callOpts = fetchSpy.mock.calls[0][1] as RequestInit + const headers = callOpts.headers as Record + expect(headers.Cookie).toBe("mam_id=my-session-cookie") + }) + + it("includes snatch_summary in the request URL", async () => { + const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + json: async () => mockMamResponse(), + } as Response) + + await adapter.fetchStats("https://www.myanonamouse.net", "session-id", "/jsonLoad.php") + + const calledUrl = fetchSpy.mock.calls[0][0] as string + expect(calledUrl).toContain("snatch_summary") + expect(calledUrl).toContain("notif") + expect(calledUrl).toContain("jsonLoad.php") + }) +}) + +describe("MamAdapter - error handling", () => { + const adapter = new MamAdapter() + + beforeEach(() => { + vi.restoreAllMocks() + }) + + it("throws when username is missing from response", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + json: async () => mockMamResponse({ username: "" }), + } as Response) + + await expect( + adapter.fetchStats("https://www.myanonamouse.net", "session-id", "/jsonLoad.php") + ).rejects.toThrow("missing username") + }) + + it("does not leak the session cookie in error messages", async () => { + const secretToken = "ultra-secret-mam-session-abc123" + + vi.spyOn(global, "fetch").mockRejectedValueOnce(new Error("fetch failed")) + + await expect( + adapter.fetchStats("https://www.myanonamouse.net", secretToken, "/jsonLoad.php") + ).rejects.toSatisfy((err: Error) => { + expect(err.message).not.toContain(secretToken) + expect(err.message).toContain("www.myanonamouse.net") + return true + }) + }) + + it("throws a timeout-specific message on TimeoutError", async () => { + const timeoutError = new DOMException("signal timed out", "TimeoutError") + vi.spyOn(global, "fetch").mockRejectedValueOnce(timeoutError) + + await expect( + adapter.fetchStats("https://www.myanonamouse.net", "session-id", "/jsonLoad.php") + ).rejects.toThrow("Request to www.myanonamouse.net timed out") + }) + + it("passes an AbortSignal to fetch for timeout protection", async () => { + const fetchSpy = vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + json: async () => mockMamResponse(), + } as Response) + + await adapter.fetchStats("https://www.myanonamouse.net", "session-id", "/jsonLoad.php") + + const callOpts = fetchSpy.mock.calls[0][1] as RequestInit + expect(callOpts.signal).toBeDefined() + }) +}) + +describe("MamAdapter - fetchRaw", () => { + const adapter = new MamAdapter() + + beforeEach(() => { + vi.restoreAllMocks() + }) + + it("returns a single debug call with label 'User Stats'", async () => { + vi.spyOn(global, "fetch").mockResolvedValueOnce({ + ok: true, + json: async () => mockMamResponse(), + } as Response) + + const calls = await adapter.fetchRaw( + "https://www.myanonamouse.net", + "session-id", + "/jsonLoad.php" + ) + + expect(calls).toHaveLength(1) + expect(calls[0].label).toBe("User Stats") + expect(calls[0].error).toBeNull() + expect(calls[0].data).toBeDefined() + }) +}) diff --git a/src/lib/adapters/mam.ts b/src/lib/adapters/mam.ts new file mode 100644 index 00000000..57ef4de3 --- /dev/null +++ b/src/lib/adapters/mam.ts @@ -0,0 +1,166 @@ +// src/lib/adapters/mam.ts +// +// Functions: MamAdapter, MamAdapter.fetchStats, MamAdapter.fetchRaw + +import { adapterFetch } from "./adapter-fetch" +import type { + DebugApiCall, + FetchOptions, + MamPlatformMeta, + TrackerAdapter, + TrackerStats, +} from "./types" + +interface MamSnatchCategory { + name: string + count: number + red: boolean + size: number | null +} + +interface MamJsonLoadResponse { + username: string + uid: number + classname: string + ratio: number + uploaded: string + downloaded: string + uploaded_bytes: number + downloaded_bytes: number + seedbonus: number + wedges: number + vip_until?: string | null + connectable?: string + country_code?: string + country_name?: string + created?: number + update?: number + ipv6_mac?: boolean + v6_connectable?: string | null + partial?: boolean + recently_deleted?: number + + // snatch_summary categories (present when ?snatch_summary is set) + leeching?: MamSnatchCategory + sSat?: MamSnatchCategory + seedHnr?: MamSnatchCategory + seedUnsat?: MamSnatchCategory + upAct?: MamSnatchCategory + upInact?: MamSnatchCategory + inactHnr?: MamSnatchCategory + inactSat?: MamSnatchCategory + inactUnsat?: MamSnatchCategory + unsat?: MamSnatchCategory & { limit: number } + duplicates?: MamSnatchCategory + reseed?: { name: string; count: number; inactive: number; red: boolean } + ite?: { name: string; count: number; latest: number } + + // notif (present when ?notif is set) + notifs?: { + pms: number + aboutToDropClient: number + tickets: number + waiting_tickets: number + requests: number + topics: number + } + + // clientStats (present when ?clientStats is set) + clientStats?: unknown[] +} + +export class MamAdapter implements TrackerAdapter { + async fetchStats( + baseUrl: string, + apiToken: string, + apiPath: string, + options?: FetchOptions + ): Promise { + const hostname = new URL(baseUrl).hostname + const url = new URL(apiPath, baseUrl) + url.searchParams.set("snatch_summary", "") + url.searchParams.set("notif", "") + + const data = await adapterFetch(url.toString(), hostname, options, { + Cookie: `mam_id=${apiToken}`, + }) + + if (!data.username) { + throw new Error(`Unexpected response from ${hostname}: missing username`) + } + + const uploaded = BigInt(Math.floor(data.uploaded_bytes ?? 0)) + const downloaded = BigInt(Math.floor(data.downloaded_bytes ?? 0)) + + const seedingCount = + (data.sSat?.count ?? 0) + + (data.seedHnr?.count ?? 0) + + (data.seedUnsat?.count ?? 0) + + (data.upAct?.count ?? 0) + + const platformMeta: MamPlatformMeta = { + vipUntil: data.vip_until ?? undefined, + connectable: data.connectable ?? undefined, + unsatisfiedCount: data.unsat?.count ?? undefined, + unsatisfiedLimit: data.unsat?.limit ?? undefined, + inactiveSatisfiedCount: data.inactSat?.count ?? undefined, + seedingHnrCount: data.seedHnr?.count ?? undefined, + inactiveUnsatisfiedCount: data.inactUnsat?.count ?? undefined, + trackerErrorCount: data.ite?.count ?? undefined, + recentlyDeleted: data.recently_deleted ?? undefined, + unreadPMs: data.notifs?.pms ?? undefined, + openTickets: data.notifs?.tickets ?? undefined, + pendingRequests: data.notifs?.requests ?? undefined, + unreadTopics: data.notifs?.topics ?? undefined, + } + + return { + username: data.username, + group: data.classname ?? "Unknown", + remoteUserId: data.uid, + uploadedBytes: uploaded, + downloadedBytes: downloaded, + ratio: typeof data.ratio === "number" ? data.ratio : parseFloat(String(data.ratio)) || 0, + bufferBytes: uploaded > downloaded ? uploaded - downloaded : BigInt(0), + seedingCount, + leechingCount: data.leeching?.count ?? 0, + seedbonus: data.seedbonus ?? null, + hitAndRuns: data.inactHnr?.count ?? null, + requiredRatio: null, + warned: null, + freeleechTokens: data.wedges ?? null, + platformMeta, + } + } + + async fetchRaw( + baseUrl: string, + apiToken: string, + apiPath: string, + options?: FetchOptions + ): Promise { + const hostname = new URL(baseUrl).hostname + const calls: DebugApiCall[] = [] + + const url = new URL(apiPath, baseUrl) + url.searchParams.set("snatch_summary", "") + url.searchParams.set("notif", "") + const endpoint = `${apiPath}?snatch_summary¬if` + + try { + const data = await adapterFetch>(url.toString(), hostname, options, { + Cookie: `mam_id=${apiToken}`, + }) + calls.push({ label: "User Stats", endpoint, data, error: null }) + } catch (err) { + calls.push({ + label: "User Stats", + endpoint, + data: null, + error: err instanceof Error ? err.message : "Request failed", + }) + } + + return calls + } +} diff --git a/src/lib/adapters/types.ts b/src/lib/adapters/types.ts index f51a6936..f9ef0e4f 100644 --- a/src/lib/adapters/types.ts +++ b/src/lib/adapters/types.ts @@ -21,7 +21,7 @@ export interface TrackerStats { lastAccessDate?: string shareScore?: number avatarUrl?: string - platformMeta?: GGnPlatformMeta | GazellePlatformMeta | NebulancePlatformMeta + platformMeta?: GGnPlatformMeta | GazellePlatformMeta | NebulancePlatformMeta | MamPlatformMeta } export interface GGnPlatformMeta { @@ -96,6 +96,22 @@ export interface NebulancePlatformMeta { invites?: number } +export interface MamPlatformMeta { + vipUntil?: string + connectable?: string + unsatisfiedCount?: number + unsatisfiedLimit?: number + inactiveSatisfiedCount?: number + seedingHnrCount?: number + inactiveUnsatisfiedCount?: number + trackerErrorCount?: number + recentlyDeleted?: number + unreadPMs?: number + openTickets?: number + pendingRequests?: number + unreadTopics?: number +} + export interface FetchOptions { proxyAgent?: HttpAgent remoteUserId?: number diff --git a/src/lib/auth-session.test.ts b/src/lib/auth-session.test.ts index af566a9a..99f4dcde 100644 --- a/src/lib/auth-session.test.ts +++ b/src/lib/auth-session.test.ts @@ -76,10 +76,21 @@ vi.mock("jose", () => ({ jwtDecrypt: vi.fn(async (token: string) => ({ payload: JSON.parse(token).payload })), })) +vi.mock("@/lib/cookie-security", async () => { + return { + shouldSecureCookies: () => { + if (process.env.SECURE_COOKIES === "true") return true + if (process.env.BASE_URL?.toLowerCase().startsWith("https://")) return true + return false + }, + } +}) + describe("auth session cookies", () => { beforeEach(() => { resetCookieState() tokenCounter = 0 + vi.unstubAllEnvs() vi.stubEnv("SESSION_SECRET", "x".repeat(32)) vi.stubEnv("NODE_ENV", "test") }) @@ -113,8 +124,8 @@ describe("auth session cookies", () => { await expect(getSession()).resolves.toEqual({ encryptionKey: "a".repeat(64) }) }) - it("uses secure cookies in production and different tokens per session", async () => { - vi.stubEnv("NODE_ENV", "production") + it("uses secure cookies when SECURE_COOKIES=true and different tokens per session", async () => { + vi.stubEnv("SECURE_COOKIES", "true") const { createSession } = await loadAuthModule() const first = await createSession("b".repeat(64), null) @@ -127,6 +138,64 @@ describe("auth session cookies", () => { }) }) + it("enables secure cookies when BASE_URL uses https", async () => { + vi.stubEnv("BASE_URL", "https://trackertracker.example.com") + const { createSession } = await loadAuthModule() + + await createSession("f".repeat(64), null) + + expect(getCookieOptions("tt_session")).toMatchObject({ + secure: true, + }) + }) + + it("does not set secure cookies when NODE_ENV is production but no SECURE_COOKIES or HTTPS BASE_URL", async () => { + vi.stubEnv("NODE_ENV", "production") + const { createSession } = await loadAuthModule() + + await createSession("g".repeat(64), null) + + expect(getCookieOptions("tt_session")).toMatchObject({ + secure: false, + }) + }) + + it("does not set secure cookies when BASE_URL uses http://", async () => { + vi.stubEnv("BASE_URL", "http://trackertracker.local") + const { createSession } = await loadAuthModule() + + await createSession("h".repeat(64), null) + + expect(getCookieOptions("tt_session")).toMatchObject({ + secure: false, + }) + expect(getCookieOptions("tt_max_age")).toMatchObject({ + secure: false, + }) + }) + + it("does not set secure cookies when SECURE_COOKIES is the string 'false'", async () => { + vi.stubEnv("SECURE_COOKIES", "false") + const { createSession } = await loadAuthModule() + + await createSession("i".repeat(64), null) + + expect(getCookieOptions("tt_session")).toMatchObject({ + secure: false, + }) + }) + + it("sets secure cookies when BASE_URL uses HTTPS:// with uppercase scheme", async () => { + vi.stubEnv("BASE_URL", "HTTPS://trackertracker.example.com") + const { createSession } = await loadAuthModule() + + await createSession("j".repeat(64), null) + + expect(getCookieOptions("tt_session")).toMatchObject({ + secure: true, + }) + }) + it("clears both cookies on logout", async () => { const { clearSession, createSession } = await loadAuthModule() diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 7f8d2d8c..b35146c7 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -8,6 +8,7 @@ import { hkdfSync } from "node:crypto" import argon2 from "argon2" import { EncryptJWT, jwtDecrypt } from "jose" import { cookies } from "next/headers" +import { shouldSecureCookies } from "@/lib/cookie-security" const SESSION_COOKIE = "tt_session" const MAX_AGE_COOKIE = "tt_max_age" @@ -47,12 +48,12 @@ export async function createSession( .setExpirationTime(`${jweMaxAge}s`) .encrypt(getSessionKey()) - const isProduction = process.env.NODE_ENV === "production" + const secureCookies = shouldSecureCookies() const cookieStore = await cookies() cookieStore.set(SESSION_COOKIE, token, { httpOnly: true, - secure: isProduction, + secure: secureCookies, sameSite: "strict", maxAge: cookieMaxAge, path: "/", @@ -61,7 +62,7 @@ export async function createSession( // Companion cookie so middleware can refresh maxAge without decrypting the JWE cookieStore.set(MAX_AGE_COOKIE, String(cookieMaxAge), { httpOnly: true, - secure: isProduction, + secure: secureCookies, sameSite: "strict", maxAge: cookieMaxAge, path: "/", diff --git a/src/lib/client-decrypt.ts b/src/lib/client-decrypt.ts index 30d94af6..6af2d763 100644 --- a/src/lib/client-decrypt.ts +++ b/src/lib/client-decrypt.ts @@ -5,6 +5,7 @@ import "server-only" import { decrypt } from "@/lib/crypto" +import { isDecryptionError } from "@/lib/error-utils" export function decryptClientCredentials( client: { name: string; encryptedUsername: string; encryptedPassword: string }, @@ -15,7 +16,17 @@ export function decryptClientCredentials( username: decrypt(client.encryptedUsername, key), password: decrypt(client.encryptedPassword, key), } - } catch { - throw new Error(`Credentials are missing or invalid for client "${client.name}"`) + } catch (err) { + // Use "decrypt" prefix only for genuine AES-GCM auth failures so + // isDecryptionError() in callers correctly classifies key mismatches. + const cause = err instanceof Error ? err.message : String(err) + if (isDecryptionError(err)) { + throw new Error(`decrypt credentials failed for client "${client.name}": ${cause}`, { + cause: err, + }) + } + throw new Error(`Failed to read credentials for client "${client.name}": ${cause}`, { + cause: err, + }) } } diff --git a/src/lib/client-scheduler.ts b/src/lib/client-scheduler.ts index 43074256..349c9823 100644 --- a/src/lib/client-scheduler.ts +++ b/src/lib/client-scheduler.ts @@ -10,6 +10,10 @@ // // Functions: heartbeatClient, heartbeatAllClients, deepPollClient, deepPollAllClients, // startClientScheduler, stopClientScheduler, ensureClientSchedulerRunning +// +// Side effects of deepPollClient: +// - Writes torrent daily checkpoints (torrentDailyCheckpoints) for "Movers & Shakers". +// Uses onConflictDoNothing so the first poll of the day wins; subsequent polls skip. import { eq, isNotNull, lt, sql } from "drizzle-orm" import cron, { type ScheduledTask } from "node-cron" @@ -20,9 +24,11 @@ import { clientSnapshots, clientUptimeBuckets, downloadClients, + torrentDailyCheckpoints, trackers, } from "@/lib/db/schema" import { sanitizeNetworkError } from "@/lib/error-utils" +import { localDateStr } from "@/lib/formatters" import { log } from "@/lib/logger" import type { QbtTorrent } from "@/lib/qbt" import { @@ -38,7 +44,7 @@ import { } from "@/lib/qbt" import { clearUptimeAccumulator, flushCompletedBuckets, recordHeartbeat } from "@/lib/uptime" -/** Columns needed by heartbeatClient — excludes large blobs like cachedTorrents */ +/** Columns needed by heartbeatClient. Excludes large blobs like cachedTorrents */ export const HEARTBEAT_COLUMNS = { id: downloadClients.id, enabled: downloadClients.enabled, @@ -50,7 +56,7 @@ export const HEARTBEAT_COLUMNS = { encryptedPassword: downloadClients.encryptedPassword, } as const -/** Columns needed by deepPollClient — heartbeat fields + poll config + tags */ +/** Columns needed by deepPollClient. Heartbeat fields + poll config + tags */ export const DEEP_POLL_COLUMNS = { ...HEARTBEAT_COLUMNS, crossSeedTags: downloadClients.crossSeedTags, @@ -82,7 +88,7 @@ function setDeepPollTask(task: ScheduledTask | null) { } // --------------------------------------------------------------------------- -// Heartbeat — lightweight speed + connection check +// Heartbeat // --------------------------------------------------------------------------- async function heartbeatClient(clientId: number, encryptionKey: Buffer): Promise { @@ -92,7 +98,7 @@ async function heartbeatClient(clientId: number, encryptionKey: Buffer): Promise .where(eq(downloadClients.id, clientId)) .limit(1) - if (!client || !client.enabled) return + if (!client?.enabled) return if (!client.encryptedUsername || !client.encryptedPassword) return try { @@ -112,7 +118,7 @@ async function heartbeatClient(clientId: number, encryptionKey: Buffer): Promise await db .update(downloadClients) - .set({ lastPolledAt: new Date(), lastError: null, errorSince: null, updatedAt: new Date() }) + .set({ lastError: null, errorSince: null, updatedAt: new Date() }) .where(eq(downloadClients.id, clientId)) } catch (error) { recordHeartbeat(clientId, false) @@ -142,7 +148,7 @@ async function heartbeatAllClients(encryptionKey: Buffer): Promise { } // --------------------------------------------------------------------------- -// Deep poll — full torrent list + per-tag aggregation +// Deep poll // --------------------------------------------------------------------------- export async function deepPollClient(clientId: number, encryptionKey: Buffer): Promise { @@ -152,7 +158,7 @@ export async function deepPollClient(clientId: number, encryptionKey: Buffer): P .where(eq(downloadClients.id, clientId)) .limit(1) - if (!client || !client.enabled) return + if (!client?.enabled) return if (!client.encryptedUsername || !client.encryptedPassword) return try { @@ -187,7 +193,7 @@ export async function deepPollClient(clientId: number, encryptionKey: Buffer): P for (const result of tagSettled) { if (result.status !== "fulfilled") continue for (const t of result.value) { - if (!t.isPrivate || seen.has(t.hash)) continue + if (seen.has(t.hash)) continue seen.add(t.hash) deduped.push(t) } @@ -200,6 +206,36 @@ export async function deepPollClient(clientId: number, encryptionKey: Buffer): P `[deep-poll] client=${clientId} → ${torrents.length} relevant torrents (${allTags.length} tags)` ) + // Write daily torrent checkpoints for "Movers & Shakers" — first-seen-today wins + const checkpointDate = localDateStr() + const checkpointable = torrents.filter( + (t) => t.uploaded != null && t.downloaded != null && t.hash && t.name + ) + if (checkpointable.length > 0) { + const CHUNK = 500 + try { + for (let i = 0; i < checkpointable.length; i += CHUNK) { + await db + .insert(torrentDailyCheckpoints) + .values( + checkpointable.slice(i, i + CHUNK).map((t) => ({ + clientId, + hash: t.hash, + name: t.name, + checkpointDate, + uploadedStart: BigInt(t.uploaded), + downloadedStart: BigInt(t.downloaded), + })) + ) + .onConflictDoNothing() + } + } catch (err) { + log.warn( + `Torrent checkpoint insert failed for client ${clientId}: ${err instanceof Error ? err.message : "Unknown"}` + ) + } + } + const stats = aggregateByTag(torrents, trackerTags, crossSeedTags) // Cache the filtered torrent list for fallback when client is offline. @@ -280,7 +316,7 @@ export function startClientScheduler(encryptionKey: Buffer): void { log.error(error, "Initial deep poll error") }) - // Heartbeat: every 5 seconds — lightweight speed + connection check + // Heartbeat: every 5 seconds const hbTask = cron.schedule("*/5 * * * * *", async () => { if (heartbeatInFlight) return heartbeatInFlight = true @@ -295,7 +331,7 @@ export function startClientScheduler(encryptionKey: Buffer): void { }) setHeartbeatTask(hbTask) - // Deep poll: every 5 minutes — full torrent list + tag aggregation + // Deep poll const dpTask = cron.schedule("*/5 * * * *", async () => { if (deepPollInFlight) return deepPollInFlight = true @@ -334,7 +370,7 @@ export function stopClientScheduler(): void { } clearAllSessions() clearSpeedCache() - // Best-effort flush of any completed uptime buckets before clearing + flushCompletedBuckets().catch(() => {}) clearUptimeAccumulator() } diff --git a/src/lib/cookie-security.test.ts b/src/lib/cookie-security.test.ts new file mode 100644 index 00000000..6775cbcc --- /dev/null +++ b/src/lib/cookie-security.test.ts @@ -0,0 +1,84 @@ +// src/lib/cookie-security.test.ts + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { shouldSecureCookies } from "./cookie-security" + +describe("shouldSecureCookies", () => { + beforeEach(() => { + vi.unstubAllEnvs() + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it("returns true when SECURE_COOKIES is the string 'true'", () => { + vi.stubEnv("SECURE_COOKIES", "true") + expect(shouldSecureCookies()).toBe(true) + }) + + it("returns false when SECURE_COOKIES is the string 'false'", () => { + vi.stubEnv("SECURE_COOKIES", "false") + expect(shouldSecureCookies()).toBe(false) + }) + + it("returns false when SECURE_COOKIES is '1' (non-canonical truthy string)", () => { + vi.stubEnv("SECURE_COOKIES", "1") + expect(shouldSecureCookies()).toBe(false) + }) + + it("returns false when SECURE_COOKIES is 'TRUE' (wrong case)", () => { + vi.stubEnv("SECURE_COOKIES", "TRUE") + expect(shouldSecureCookies()).toBe(false) + }) + + it("returns true when BASE_URL starts with https://", () => { + vi.stubEnv("BASE_URL", "https://trackertracker.example.com") + expect(shouldSecureCookies()).toBe(true) + }) + + it("returns true when BASE_URL starts with HTTPS:// (uppercase scheme)", () => { + vi.stubEnv("BASE_URL", "HTTPS://trackertracker.example.com") + expect(shouldSecureCookies()).toBe(true) + }) + + it("returns true when BASE_URL starts with Https:// (mixed case scheme)", () => { + vi.stubEnv("BASE_URL", "Https://trackertracker.example.com") + expect(shouldSecureCookies()).toBe(true) + }) + + it("returns false when BASE_URL starts with http://", () => { + vi.stubEnv("BASE_URL", "http://trackertracker.local") + expect(shouldSecureCookies()).toBe(false) + }) + + it("returns false when BASE_URL starts with HTTP:// (uppercase http)", () => { + vi.stubEnv("BASE_URL", "HTTP://trackertracker.local") + expect(shouldSecureCookies()).toBe(false) + }) + + it("returns false when neither SECURE_COOKIES nor BASE_URL is set", () => { + expect(shouldSecureCookies()).toBe(false) + }) + + it("returns false when NODE_ENV is production but no SECURE_COOKIES or https BASE_URL", () => { + vi.stubEnv("NODE_ENV", "production") + expect(shouldSecureCookies()).toBe(false) + }) + + it("SECURE_COOKIES=true takes precedence regardless of BASE_URL scheme", () => { + vi.stubEnv("SECURE_COOKIES", "true") + vi.stubEnv("BASE_URL", "http://trackertracker.local") + expect(shouldSecureCookies()).toBe(true) + }) + + it("returns false when BASE_URL is an empty string", () => { + vi.stubEnv("BASE_URL", "") + expect(shouldSecureCookies()).toBe(false) + }) + + it("returns false when BASE_URL contains https in the path but not the scheme", () => { + vi.stubEnv("BASE_URL", "http://example.com/redirect?to=https://other.com") + expect(shouldSecureCookies()).toBe(false) + }) +}) diff --git a/src/lib/cookie-security.ts b/src/lib/cookie-security.ts new file mode 100644 index 00000000..7b053133 --- /dev/null +++ b/src/lib/cookie-security.ts @@ -0,0 +1,7 @@ +// src/lib/cookie-security.ts + +export function shouldSecureCookies(): boolean { + if (process.env.SECURE_COOKIES === "true") return true + if (process.env.BASE_URL?.toLowerCase().startsWith("https://")) return true + return false +} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 0d325ef4..1746fa4c 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -2,7 +2,8 @@ // // Tables: appSettings, trackers, trackerSnapshots, trackerRoles, downloadClients, // tagGroups, tagGroupMembers, clientSnapshots, backupHistory, dismissedAlerts, -// draftQuicklinks (column on appSettings), notificationTargets, notificationDeliveryState +// draftQuicklinks (column on appSettings), notificationTargets, notificationDeliveryState, +// trackerDailyCheckpoints, torrentDailyCheckpoints import { bigint, @@ -79,11 +80,13 @@ export const trackers = pgTable("trackers", { qbtTag: varchar("qbt_tag", { length: 100 }), remoteUserId: integer("remote_user_id"), platformMeta: text("platform_meta"), + mouseholeUrl: text("mousehole_url"), avatarData: text("avatar_data"), avatarCachedAt: timestamp("avatar_cached_at"), avatarRemoteUrl: text("avatar_remote_url"), useProxy: boolean("use_proxy").default(false).notNull(), countCrossSeedUnsatisfied: boolean("count_cross_seed_unsatisfied").default(false).notNull(), + hideUnreadBadges: boolean("hide_unread_badges").default(false).notNull(), isFavorite: boolean("is_favorite").default(false).notNull(), sortOrder: integer("sort_order"), joinedAt: date("joined_at"), @@ -281,6 +284,10 @@ export const notificationTargets = pgTable("notification_targets", { notifyZeroSeeding: boolean("notify_zero_seeding").default(false).notNull(), notifyRankChange: boolean("notify_rank_change").default(false).notNull(), notifyAnniversary: boolean("notify_anniversary").default(false).notNull(), + notifyBonusCap: boolean("notify_bonus_cap").default(false).notNull(), + notifyVipExpiring: boolean("notify_vip_expiring").default(false).notNull(), + notifyUnsatisfiedLimit: boolean("notify_unsatisfied_limit").default(false).notNull(), + notifyActiveHnrs: boolean("notify_active_hnrs").default(false).notNull(), // Event thresholds — nullable JSONB; null means use application defaults. // Shape: { ratioDropDelta?: number, bufferMilestoneBytes?: number } @@ -364,3 +371,59 @@ export const notificationDeliveryState = pgTable( index("idx_delivery_state_tracker_id").on(table.trackerId), ] ) + +// ─── Today At A Glance — daily checkpoint tables ────────────────────────────── +// +// These tables store end-of-day snapshots used by the "Today At A Glance" +// feature to compute daily deltas (e.g. how much was uploaded/downloaded today). +// +// trackerDailyCheckpoints: one row per tracker per calendar day, capturing the +// final metric values recorded that day. snapshotCount reflects how many raw +// snapshots were taken during the day, giving confidence in the reading quality. +// +// torrentDailyCheckpoints: one row per (client, torrent hash, calendar day), +// recording the starting uploaded/downloaded values for the day. Delta is +// computed at query time as (current − start). + +export const trackerDailyCheckpoints = pgTable( + "tracker_daily_checkpoints", + { + id: serial("id").primaryKey(), + trackerId: integer("tracker_id") + .references(() => trackers.id, { onDelete: "cascade" }) + .notNull(), + checkpointDate: date("checkpoint_date").notNull(), + uploadedBytesEnd: bigint("uploaded_bytes_end", { mode: "bigint" }).notNull(), + downloadedBytesEnd: bigint("downloaded_bytes_end", { mode: "bigint" }).notNull(), + bufferBytesEnd: bigint("buffer_bytes_end", { mode: "bigint" }), + ratioEnd: real("ratio_end"), + seedbonusEnd: real("seedbonus_end"), + snapshotCount: integer("snapshot_count").notNull().default(0), + }, + (table) => [ + uniqueIndex("uq_tracker_checkpoint_tracker_date").on(table.trackerId, table.checkpointDate), + ] +) + +export const torrentDailyCheckpoints = pgTable( + "torrent_daily_checkpoints", + { + id: serial("id").primaryKey(), + clientId: integer("client_id") + .references(() => downloadClients.id, { onDelete: "cascade" }) + .notNull(), + hash: varchar("hash", { length: 64 }).notNull(), + name: text("name").notNull(), + checkpointDate: date("checkpoint_date").notNull(), + uploadedStart: bigint("uploaded_start", { mode: "bigint" }).notNull(), + downloadedStart: bigint("downloaded_start", { mode: "bigint" }).notNull(), + }, + (table) => [ + uniqueIndex("uq_torrent_checkpoint_client_hash_date").on( + table.clientId, + table.hash, + table.checkpointDate + ), + index("idx_torrent_checkpoint_date").on(table.checkpointDate), + ] +) diff --git a/src/lib/error-utils.ts b/src/lib/error-utils.ts index 2bcd9476..86665a24 100644 --- a/src/lib/error-utils.ts +++ b/src/lib/error-utils.ts @@ -1,6 +1,17 @@ // src/lib/error-utils.ts // -// Functions: sanitizeNetworkError +// Functions: sanitizeNetworkError, isDecryptionError + +/** + * Returns true when an error originates from AES-256-GCM authentication failure + * or key/ciphertext mismatch — i.e. the session encryption key is stale. + * Used by route handlers to distinguish key-mismatch (→ 401) from genuinely + * missing or corrupt stored credentials (→ 422). + */ +export function isDecryptionError(error: unknown): boolean { + if (!(error instanceof Error)) return false + return /decrypt|authenticate\s*data|bad\s*decrypt|invalid\s*key|EVP_/i.test(error.message) +} /** * Maps raw network/auth error messages to safe user-facing messages. diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts index 58d0f5c5..aae13ecf 100644 --- a/src/lib/formatters.ts +++ b/src/lib/formatters.ts @@ -1,6 +1,10 @@ // src/lib/formatters.ts // -// Functions: formatBytesFromString, bytesToGiB, formatBytesNum, formatRatio, formatAccountAge, formatJoinedDate, hexToRgba, hexToHsl, hslToHex, generatePalette, getComplementaryColor, formatStatValue, computeDelta, formatDuration, formatTimeAgo, splitValueUnit +// Functions: formatBytesFromString, bytesToGiB, formatBytesNum, +// formatRatio, formatAccountAge, formatJoinedDate, hexToRgba, hexToHsl, +// hslToHex, generatePalette, getComplementaryColor, formatStatValue, +// computeDelta, formatDuration, formatTimeAgo, splitValueUnit, compareBigIntDesc, +// computePctChange, localDateStr, isUnixTimestampOnDate import type { Snapshot, TrackerLatestStats } from "@/types/api" @@ -14,7 +18,9 @@ export function formatBytesFromString(bytesStr: string | null | undefined): stri const tib = bytes / 1024 ** 4 if (tib >= 1) return `${tib.toFixed(2)} TiB` const gib = bytes / 1024 ** 3 - return `${gib.toFixed(2)} GiB` + if (gib >= 1) return `${gib.toFixed(2)} GiB` + const mib = bytes / 1024 ** 2 + return `${Math.round(mib)} MiB` } /** @@ -291,3 +297,45 @@ export function splitValueUnit(formatted: string): { num: string; unit: string } if (idx === -1) return { num: formatted, unit: "" } return { num: formatted.slice(0, idx), unit: formatted.slice(idx + 1) } } + +/** Comparator for sorting bigint values in descending order. */ +export function compareBigIntDesc(a: bigint, b: bigint): number { + if (b > a) return 1 + if (b < a) return -1 + return 0 +} + +/** Compute percentage change between two bigint-encoded decimal strings. */ +export function computePctChange(today: string, yesterday: string | null): number | null { + if (yesterday === null) return null + try { + // NOTE: Converting BigInt to Number can lose precision for values larger than + // Number.MAX_SAFE_INTEGER (~8 PiB). For typical tracker upload/download totals + // this is unlikely to be a problem, but a full bigint arithmetic refactor would + // be required to handle extreme values correctly. + const y = Number(BigInt(yesterday)) + if (y === 0) return null + const t = Number(BigInt(today)) + return ((t - y) / y) * 100 + } catch { + return null + } +} + +/** + * Returns a YYYY-MM-DD date string in the server's local timezone (respects TZ env). + * Use this instead of `.toISOString().slice(0, 10)` which always returns UTC. + */ +export function localDateStr(date?: Date | number): string { + const d = date instanceof Date ? date : date !== undefined ? new Date(date) : new Date() + return d.toLocaleDateString("en-CA") +} + +/** + * Returns true if a unix timestamp (seconds) falls on the given date string (YYYY-MM-DD) + * in the server's local timezone. Returns false for timestamps <= 0. + */ +export function isUnixTimestampOnDate(unixSeconds: number, dateStr: string): boolean { + if (unixSeconds <= 0) return false + return localDateStr(new Date(unixSeconds * 1000)) === dateStr +} diff --git a/src/lib/notifications/dispatch.ts b/src/lib/notifications/dispatch.ts index f6b4112d..0a650a81 100644 --- a/src/lib/notifications/dispatch.ts +++ b/src/lib/notifications/dispatch.ts @@ -3,6 +3,7 @@ // Functions: dispatchNotifications, detectEvents, buildEventData import { eq } from "drizzle-orm" +import { MAM_BONUS_CAP } from "@/lib/adapters/constants" import { db } from "@/lib/db" import { notificationDeliveryState, notificationTargets } from "@/lib/db/schema" import { log } from "@/lib/logger" @@ -15,12 +16,16 @@ import type { NotificationThresholds, } from "@/lib/notifications/types" import { + checkActiveHnrs, checkAnniversaryMilestone, + checkBonusCapReached, checkBufferMilestoneCrossed, checkHnrIncrease, checkRankChange, checkRatioBelowMinimumTransition, checkRatioDelta, + checkUnsatisfiedLimitApproaching, + checkVipExpiringSoon, checkWarnedTransition, checkZeroSeeding, EVENT_SNOOZE_MS, @@ -47,6 +52,16 @@ export interface SnapshotContext { trackerPausedAt: string | null trackerJoinedAt: string | null minimumRatio: number | undefined + // MAM-specific fields grouped into sub-object; undefined for non-MAM trackers + mamContext?: { + currentSeedbonus: number | null + previousSeedbonus: number | null + vipUntil: string | null + unsatisfiedCount: number | null + unsatisfiedLimit: number | null + inactiveHnrCount: number | null + previousInactiveHnrCount: number | null + } } export async function dispatchNotifications( @@ -102,6 +117,7 @@ export async function dispatchNotifications( const anniversaryLabel = events.includes("anniversary") ? checkAnniversaryMilestone(ctx.trackerJoinedAt)?.label : undefined + const targetThresholds = (target.thresholds as NotificationThresholds | null) ?? null // Only Discord is currently supported — skip unknown types if (target.type !== "discord") { @@ -136,7 +152,7 @@ export async function dispatchNotifications( trackerName: ctx.trackerName, includeTrackerName: target.includeTrackerName, storeUsernames: ctx.storeUsernames, - data: buildEventData(event, ctx, anniversaryLabel), + data: buildEventData(event, ctx, targetThresholds, anniversaryLabel), }) const result = await deliverDiscordWebhook(target.id, config.webhookUrl, [ @@ -257,12 +273,57 @@ export function detectEvents( events.push("anniversary") } + if (target.notifyBonusCap) { + const capLimit = (thresholds?.bonusCapLimit as number | undefined) ?? MAM_BONUS_CAP + if ( + checkBonusCapReached( + ctx.mamContext?.currentSeedbonus ?? null, + ctx.mamContext?.previousSeedbonus ?? null, + capLimit + ) + ) { + events.push("bonus_cap") + } + } + + if (target.notifyVipExpiring) { + const days = (thresholds?.vipExpiringDays as number | undefined) ?? 7 + if (checkVipExpiringSoon(ctx.mamContext?.vipUntil ?? null, days)) { + events.push("vip_expiring") + } + } + + if (target.notifyUnsatisfiedLimit) { + const pct = (thresholds?.unsatisfiedLimitPercent as number | undefined) ?? 80 + if ( + checkUnsatisfiedLimitApproaching( + ctx.mamContext?.unsatisfiedCount ?? null, + ctx.mamContext?.unsatisfiedLimit ?? null, + pct + ) + ) { + events.push("unsatisfied_limit") + } + } + + if (target.notifyActiveHnrs) { + if ( + checkActiveHnrs( + ctx.mamContext?.inactiveHnrCount ?? null, + ctx.mamContext?.previousInactiveHnrCount ?? null + ) + ) { + events.push("active_hnrs") + } + } + return events } export function buildEventData( event: NotificationEventType, ctx: SnapshotContext, + thresholds?: NotificationThresholds | null, anniversaryLabel?: string ): Record { switch (event) { @@ -284,6 +345,19 @@ export function buildEventData( return { newGroup: ctx.currentGroup, previousGroup: ctx.previousGroup } case "anniversary": return { label: anniversaryLabel ?? "Anniversary" } + case "bonus_cap": { + const effectiveCap = (thresholds?.bonusCapLimit as number | undefined) ?? MAM_BONUS_CAP + return { currentBonus: ctx.mamContext?.currentSeedbonus ?? null, capLimit: effectiveCap } + } + case "vip_expiring": + return { vipUntil: ctx.mamContext?.vipUntil ?? null } + case "unsatisfied_limit": + return { + count: ctx.mamContext?.unsatisfiedCount ?? null, + limit: ctx.mamContext?.unsatisfiedLimit ?? null, + } + case "active_hnrs": + return { count: ctx.mamContext?.inactiveHnrCount ?? null } default: return {} } diff --git a/src/lib/notifications/payload.ts b/src/lib/notifications/payload.ts index fba863a6..a0ac77f8 100644 --- a/src/lib/notifications/payload.ts +++ b/src/lib/notifications/payload.ts @@ -37,6 +37,10 @@ const EVENT_COLORS: Record = { zero_seeding: hexToInt(CHART_THEME.warn), rank_change: hexToInt(CHART_THEME.accent), anniversary: hexToInt(CHART_THEME.accent), + bonus_cap: hexToInt(CHART_THEME.warn), + vip_expiring: hexToInt(CHART_THEME.warn), + unsatisfied_limit: hexToInt(CHART_THEME.danger), + active_hnrs: hexToInt(CHART_THEME.danger), } const EVENT_TITLES: Record = { @@ -49,6 +53,10 @@ const EVENT_TITLES: Record = { zero_seeding: "Zero Active Seeds", rank_change: "Rank Change", anniversary: "Membership Anniversary", + bonus_cap: "Bonus Cap Reached", + vip_expiring: "VIP Expiring Soon", + unsatisfied_limit: "Unsatisfied Limit Approaching", + active_hnrs: "New Inactive Hit & Run", } export function buildDiscordEmbed(input: EmbedInput): DiscordEmbed { @@ -107,6 +115,26 @@ function buildDescription( const label = data.label as string | undefined return label ? `${source} — ${label}` : `${source} membership anniversary` } + case "bonus_cap": { + const current = Number(data.currentBonus ?? 0).toLocaleString() + const cap = Number(data.capLimit ?? 0).toLocaleString() + return `${source} bonus points at **${current}** (cap: ${cap}). Spend them before they're wasted!` + } + case "vip_expiring": { + const expiry = data.vipUntil ? new Date(String(data.vipUntil)) : null + const days = expiry ? Math.ceil((expiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24)) : 0 + return `${source} VIP status expires in **${days} day${days !== 1 ? "s" : ""}**` + } + case "unsatisfied_limit": { + const count = Number(data.count ?? 0) + const limit = Number(data.limit ?? 0) + const pct = limit > 0 ? Math.round((count / limit) * 100) : 0 + return `${source} unsatisfied torrents at **${count}/${limit}** (${pct}%). Download capacity running low.` + } + case "active_hnrs": { + const count = Number(data.count ?? 0) + return `${source} has **${count}** active Hit & Run${count !== 1 ? "s" : ""}. Seed them to avoid penalties.` + } default: return `${source} triggered a notification` } diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index 11381032..62a80872 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -15,14 +15,19 @@ export const VALID_EVENT_TYPES = [ "zero_seeding", "rank_change", "anniversary", + "bonus_cap", + "vip_expiring", + "unsatisfied_limit", + "active_hnrs", ] as const export type NotificationEventType = (typeof VALID_EVENT_TYPES)[number] -export type NotificationDeliveryStatus = "delivered" | "failed" | "rate_limited" - export interface NotificationThresholds { ratioDropDelta?: number // i.e 0.1 — alert when ratio falls by ≥0.1 bufferMilestoneBytes?: number //i.e 10737418240 — alert when buffer crosses 10 GiB + bonusCapLimit?: number // default 99999 — fires when seedbonus hits or exceeds cap + vipExpiringDays?: number // default 7 — fires when VIP expiry is within N days + unsatisfiedLimitPercent?: number // default 80 — fires at 80% of unsatisfied limit } // Per-type config shapes (decrypted form — never stored plaintext) diff --git a/src/lib/nuke.ts b/src/lib/nuke.ts index e55b1bc5..c4ed8866 100644 --- a/src/lib/nuke.ts +++ b/src/lib/nuke.ts @@ -1,6 +1,4 @@ // src/lib/nuke.ts -// -// Functions: scrubAndDeleteAll import { randomBytes } from "node:crypto" import { db } from "@/lib/db" @@ -14,6 +12,8 @@ import { notificationTargets, tagGroupMembers, tagGroups, + torrentDailyCheckpoints, + trackerDailyCheckpoints, trackerRoles, trackerSnapshots, trackers, @@ -73,6 +73,8 @@ export async function scrubAndDeleteAll(): Promise { await tx.delete(dismissedAlerts) await tx.delete(clientUptimeBuckets) + await tx.delete(torrentDailyCheckpoints) + await tx.delete(trackerDailyCheckpoints) await tx.delete(clientSnapshots) await tx.delete(trackerSnapshots) await tx.delete(trackerRoles) diff --git a/src/lib/qbt/aggregator.ts b/src/lib/qbt/aggregator.ts index 4f47738d..9033c32a 100644 --- a/src/lib/qbt/aggregator.ts +++ b/src/lib/qbt/aggregator.ts @@ -1,6 +1,4 @@ // src/lib/qbt/aggregator.ts -// -// Functions: aggregateByTag import { LEECHING_STATES, parseTorrentTags, SEEDING_STATES } from "@/lib/fleet" import type { ClientStats, QbtTorrent, TagStats } from "./types" @@ -12,11 +10,10 @@ export function aggregateByTag( ): ClientStats { const knownTags = [...trackerTags, ...crossSeedTags] - // Build a map of tag → accumulator const tagMap = new Map() for (const tag of knownTags) { - tagMap.set(tag, { - tag, + tagMap.set(tag.toLowerCase(), { + tag: tag.toLowerCase(), seedingCount: 0, leechingCount: 0, uploadSpeed: 0, diff --git a/src/lib/qbt/client.ts b/src/lib/qbt/client.ts index 02f2c3c0..aa3df638 100644 --- a/src/lib/qbt/client.ts +++ b/src/lib/qbt/client.ts @@ -1,15 +1,16 @@ // src/lib/qbt/client.ts // // Available functions: -// buildBaseUrl - Construct base URL from host/port/ssl -// login - Authenticate with qBittorrent Web API, returns SID cookie -// getSession - Return cached SID or perform a fresh login -// invalidateSession - Remove a cached SID (i.e after 403) -// clearAllSessions - Remove all cached SIDs (called on logout) -// withSessionRetry - Run an operation with automatic session retry on expiry -// qbtFetch - Shared fetch + error handler for authenticated qBT requests -// getTorrents - Fetch torrent info from qBittorrent (optionally filtered by tag) -// getTransferInfo - Fetch global transfer stats from qBittorrent +// buildBaseUrl - Construct base URL from host/port/ssl +// login - Authenticate with qBittorrent Web API, returns SID cookie +// getSession - Return cached SID or perform a fresh login +// invalidateSession - Remove a cached SID (i.e after 403) +// clearAllSessions - Remove all cached SIDs (called on logout) +// withSessionRetry - Run an operation with automatic session retry on expiry +// qbtFetch - Shared fetch + error handler for authenticated qBT requests +// parseCachedTorrents - Safely parse JSONB cachedTorrents column (string or object) +// getTorrents - Fetch torrent info from qBittorrent (optionally filtered by tag) +// getTransferInfo - Fetch global transfer stats from qBittorrent import type { QbtTorrent, QbtTransferInfo } from "./types" @@ -18,14 +19,13 @@ import type { QbtTorrent, QbtTransferInfo } from "./types" * TypeError("fetch failed") { cause: Error("ECONNREFUSED ...") } */ function describeFetchError(err: unknown): string { - // Unwrap Node fetch's cause chain for the real error const cause = err !== null && typeof err === "object" && "cause" in (err as object) ? (err as { cause: unknown }).cause : undefined if (cause instanceof Error) { const code = "code" in cause ? (cause as NodeJS.ErrnoException).code : undefined - if (code) return code // i.e ECONNREFUSED, ENOTFOUND, ETIMEDOUT + if (code) return code if (cause.message) return cause.message } @@ -45,8 +45,7 @@ export function buildBaseUrl(host: string, port: number, ssl: boolean): string { } // --------------------------------------------------------------------------- -// SID session cache — avoids re-authenticating on every poll cycle. -// SIDs are cached per baseUrl and reused until a 403 invalidates them. +// SID session cache to avoid re-authenticating on every poll cycle. // --------------------------------------------------------------------------- const gSid = globalThis as typeof globalThis & { @@ -195,6 +194,23 @@ async function qbtFetch( return response } +/** + * Parses the cachedTorrents JSONB column + */ +export function parseCachedTorrents(raw: unknown): QbtTorrent[] { + if (!raw) return [] + if (typeof raw === "string") { + try { + const parsed = JSON.parse(raw) + return Array.isArray(parsed) ? (parsed as QbtTorrent[]) : [] + } catch { + return [] + } + } + if (Array.isArray(raw)) return raw as QbtTorrent[] + return [] +} + export async function getTorrents( baseUrl: string, sid: string, diff --git a/src/lib/qbt/fetch-merged.ts b/src/lib/qbt/fetch-merged.ts index 0745d4e0..e7d9cb03 100644 --- a/src/lib/qbt/fetch-merged.ts +++ b/src/lib/qbt/fetch-merged.ts @@ -5,7 +5,7 @@ import "server-only" import { decryptClientCredentials } from "@/lib/client-decrypt" -import { sanitizeNetworkError } from "@/lib/error-utils" +import { isDecryptionError, sanitizeNetworkError } from "@/lib/error-utils" import { getTorrents, parseCrossSeedTags, @@ -32,6 +32,8 @@ export interface MergedResult { crossSeedTags: string[] clientErrors: string[] clientCount: number + /** True when every client failure was a decryption error, indicating a stale session key. */ + sessionExpired: boolean } async function fetchClientTorrents( @@ -90,6 +92,7 @@ export async function fetchAndMergeTorrents( crossSeedTags: [], clientErrors: [], clientCount: 0, + sessionExpired: false, } if (clients.length === 0 || tags.length === 0) { @@ -113,6 +116,7 @@ export async function fetchAndMergeTorrents( const torrentLists: QbtTorrent[][] = [] const crossSeedClients: { crossSeedTags: string[] }[] = [] const clientErrors: string[] = [] + let decryptionFailureCount = 0 for (let i = 0; i < results.length; i++) { const result = results[i] @@ -121,14 +125,18 @@ export async function fetchAndMergeTorrents( crossSeedClients.push({ crossSeedTags: result.value.crossSeedTags }) } else { const clientName = clients[i].name + const isDecrypt = isDecryptionError(result.reason) + if (isDecrypt) decryptionFailureCount++ const raw = result.reason instanceof Error ? result.reason.message : "Unknown error" - const message = /decrypt|crypt|EVP_/i.test(raw) - ? "Credential decryption failed" - : sanitizeNetworkError(raw) + const message = isDecrypt ? "Credential decryption failed" : sanitizeNetworkError(raw) clientErrors.push(`${clientName}: ${message}`) } } + // All clients failed with decryption errors → the session key is stale. + const sessionExpired = + clients.length > 0 && torrentLists.length === 0 && decryptionFailureCount === clients.length + // Build hash → client name(s) lookup before merge flattens provenance const hashClients = new Map() for (let i = 0; i < results.length; i++) { @@ -156,5 +164,6 @@ export async function fetchAndMergeTorrents( crossSeedTags, clientErrors, clientCount: clients.length, + sessionExpired, } } diff --git a/src/lib/qbt/index.ts b/src/lib/qbt/index.ts index b61018c0..5efcf6f8 100644 --- a/src/lib/qbt/index.ts +++ b/src/lib/qbt/index.ts @@ -9,6 +9,7 @@ export { getTransferInfo, invalidateSession, login, + parseCachedTorrents, withSessionRetry, } from "./client" export type { SpeedSnapshot } from "./speed-cache" diff --git a/src/lib/qbt/qbt.test.ts b/src/lib/qbt/qbt.test.ts index fa353ec6..fdd6bd31 100644 --- a/src/lib/qbt/qbt.test.ts +++ b/src/lib/qbt/qbt.test.ts @@ -165,7 +165,7 @@ describe("getTorrents", () => { progress: 1, content_path: "/downloads/My.Show.S01.BluRay", save_path: "/downloads", - isPrivate: true, + is_private: true, }, ] @@ -334,6 +334,10 @@ describe("getTransferInfo", () => { // --------------------------------------------------------------------------- describe("aggregateByTag", () => { + // Regression: prevents isPrivate camelCase/snake_case mismatch from masking real API shape. + // The real qBT API returns `is_private` (snake_case), not `isPrivate`. Tests that set + // `isPrivate: true` would hide bugs that relied on that field being defined. This factory + // deliberately omits it to match the real API response shape. function makeTorrent(overrides: Partial): QbtTorrent { return { hash: "deadbeef", @@ -363,7 +367,8 @@ describe("aggregateByTag", () => { progress: 1, content_path: "/downloads/Test Torrent", save_path: "/downloads", - isPrivate: true, + // isPrivate intentionally omitted — real qBT API returns `is_private` (snake_case). + // Only override explicitly in tests that specifically need to test isPrivate handling. ...overrides, } } @@ -517,3 +522,162 @@ describe("parseCrossSeedTags", () => { expect(parseCrossSeedTags(null)).toEqual([]) }) }) + +// --------------------------------------------------------------------------- +// Mock factory realism +// --------------------------------------------------------------------------- + +describe("makeTorrent factory shape", () => { + // Regression: prevents isPrivate camelCase mismatch from masking deep-poll dedup bug. + // The factory previously included `isPrivate: true`, which caused any code that + // checked `t.isPrivate` to behave differently from the real API (which returns + // `is_private` in snake_case). Tests built on the old factory would never catch + // bugs that depended on `t.isPrivate` being undefined. + it("produces API-realistic shape without isPrivate by default", () => { + // Re-declare the factory inline to confirm the standalone shape — this is the + // canonical check that should fail immediately if someone adds isPrivate back. + function makeTorrentForShapeCheck(overrides: Partial = {}): QbtTorrent { + return { + hash: "deadbeef", + name: "Test Torrent", + state: "uploading", + tags: "", + category: "", + upspeed: 0, + dlspeed: 0, + uploaded: 0, + downloaded: 0, + ratio: 0, + size: 0, + num_seeds: 0, + num_leechs: 0, + num_complete: 0, + num_incomplete: 0, + tracker: "", + added_on: 0, + completion_on: -1, + last_activity: 0, + seeding_time: 0, + time_active: 0, + seen_complete: 0, + availability: -1, + amount_left: 0, + progress: 1, + content_path: "/downloads/Test Torrent", + save_path: "/downloads", + ...overrides, + } + } + + const torrent = makeTorrentForShapeCheck() + + // The real qBT API does NOT return `isPrivate` (camelCase). Ensure the factory + // does not include it so tests built on this shape match real API responses. + expect(Object.hasOwn(torrent, "isPrivate")).toBe(false) + + // Core fields that the API does return must be present + expect(torrent).toHaveProperty("hash") + expect(torrent).toHaveProperty("state") + expect(torrent).toHaveProperty("tags") + }) +}) + +// --------------------------------------------------------------------------- +// aggregateByTag — case-insensitive tag matching +// --------------------------------------------------------------------------- + +describe("aggregateByTag case-insensitive tag matching", () => { + // Local helper that does NOT include isPrivate, matching real API shape + function makeRealTorrent(overrides: Partial): QbtTorrent { + return { + hash: "deadbeef", + name: "Test Torrent", + state: "uploading", + tags: "", + category: "", + upspeed: 0, + dlspeed: 0, + uploaded: 0, + downloaded: 0, + ratio: 0, + size: 0, + num_seeds: 0, + num_leechs: 0, + num_complete: 0, + num_incomplete: 0, + tracker: "", + added_on: 0, + completion_on: -1, + last_activity: 0, + seeding_time: 0, + time_active: 0, + seen_complete: 0, + availability: -1, + amount_left: 0, + progress: 1, + content_path: "/downloads/Test Torrent", + save_path: "/downloads", + ...overrides, + } + } + + // Regression: prevents tag case mismatch between DB-stored tags and parseTorrentTags output. + // aggregateByTag builds its internal map with lowercase keys. parseTorrentTags lowercases + // torrent tags by default. If the map were built with original-case keys (e.g. "Blutopia"), + // a torrent tagged "blutopia" (lowercased by parseTorrentTags) would never match and would + // fall into the untagged bucket instead. + it("matches torrent tags case-insensitively when DB tag has title case", () => { + // Torrent tags as parseTorrentTags returns them: lowercase + const torrents = [makeRealTorrent({ state: "uploading", tags: "blutopia", upspeed: 512 })] + // DB stores the tag with title case + const result = aggregateByTag(torrents, ["Blutopia"], []) + + const blutopiaStats = result.tagStats.find((t) => t.tag === "blutopia") + expect(blutopiaStats).toBeDefined() + expect(blutopiaStats?.seedingCount).toBe(1) + expect(blutopiaStats?.uploadSpeed).toBe(512) + + // Must NOT fall into the untagged bucket + const untagged = result.tagStats.find((t) => t.tag === "untagged") + expect(untagged).toBeUndefined() + }) + + // Regression: verifies the fix handles all common tracker tag casing patterns from DB. + it("handles mixed case tags from DB — ALL_CAPS, lowercase, TitleCase", () => { + const torrents = [ + makeRealTorrent({ state: "uploading", tags: "red", upspeed: 100 }), + makeRealTorrent({ state: "uploading", tags: "ops", upspeed: 200 }), + makeRealTorrent({ state: "uploading", tags: "nebulance", upspeed: 300 }), + ] + // DB may store these as "RED", "ops", "Nebulance" + const result = aggregateByTag(torrents, ["RED", "ops", "Nebulance"], []) + + const redStats = result.tagStats.find((t) => t.tag === "red") + const opsStats = result.tagStats.find((t) => t.tag === "ops") + const nebStats = result.tagStats.find((t) => t.tag === "nebulance") + + expect(redStats?.seedingCount).toBe(1) + expect(opsStats?.seedingCount).toBe(1) + expect(nebStats?.seedingCount).toBe(1) + + // No torrent should end up untagged + const untagged = result.tagStats.find((t) => t.tag === "untagged") + expect(untagged).toBeUndefined() + + expect(result.totalSeedingCount).toBe(3) + expect(result.uploadSpeedBytes).toBe(600) + }) + + it("cross-seed tags from DB are also lowercased for matching", () => { + const torrents = [makeRealTorrent({ state: "uploading", tags: "cross-seed", upspeed: 50 })] + // DB cross-seed tag stored with mixed case + const result = aggregateByTag(torrents, [], ["Cross-Seed"]) + + const csStats = result.tagStats.find((t) => t.tag === "cross-seed") + expect(csStats).toBeDefined() + expect(csStats?.seedingCount).toBe(1) + + const untagged = result.tagStats.find((t) => t.tag === "untagged") + expect(untagged).toBeUndefined() + }) +}) diff --git a/src/lib/qbt/types.ts b/src/lib/qbt/types.ts index e3485b9f..b86ec143 100644 --- a/src/lib/qbt/types.ts +++ b/src/lib/qbt/types.ts @@ -29,7 +29,7 @@ export interface QbtTorrent { progress: number // float 0-1, download progress content_path: string // full path to content save_path: string // save directory - isPrivate: boolean // true if from a private tracker + is_private?: boolean // qBT API returns this field in snake_case } // From GET /api/v2/transfer/info diff --git a/src/lib/scheduler.ts b/src/lib/scheduler.ts index d1684b54..e14c4714 100644 --- a/src/lib/scheduler.ts +++ b/src/lib/scheduler.ts @@ -1,13 +1,13 @@ // src/lib/scheduler.ts // -// Functions: pollTracker, pollAllTrackers, pruneOldSnapshots, startScheduler, stopScheduler, ensureSchedulerRunning, POLL_FAILURE_THRESHOLD +// Functions: pollTracker, pollAllTrackers, pruneOldSnapshots, pruneOldCheckpoints, startScheduler, stopScheduler, ensureSchedulerRunning, fetchTrackerStats, POLL_FAILURE_THRESHOLD import type { Agent as HttpAgent } from "node:http" import { and, desc, eq, isNotNull, lt, notInArray, sql } from "drizzle-orm" import cron, { type ScheduledTask } from "node-cron" import { findRegistryEntry } from "@/data/tracker-registry" import { buildFetchOptions, getAdapter } from "@/lib/adapters" -import type { TrackerStats } from "@/lib/adapters/types" +import type { MamPlatformMeta, TrackerStats } from "@/lib/adapters/types" import { pruneDismissedAlerts } from "@/lib/alert-pruning" import { startBackupScheduler, stopBackupScheduler } from "@/lib/backup-scheduler" import { @@ -17,8 +17,16 @@ import { } from "@/lib/client-scheduler" import { decrypt } from "@/lib/crypto" import { db } from "@/lib/db" -import { appSettings, notificationTargets, trackerSnapshots, trackers } from "@/lib/db/schema" +import { + appSettings, + notificationTargets, + torrentDailyCheckpoints, + trackerDailyCheckpoints, + trackerSnapshots, + trackers, +} from "@/lib/db/schema" import { sanitizeNetworkError } from "@/lib/error-utils" +import { localDateStr } from "@/lib/formatters" import { log } from "@/lib/logger" import { dispatchNotifications } from "@/lib/notifications/dispatch" import { maskUsername } from "@/lib/privacy" @@ -27,7 +35,7 @@ import { getPauseState } from "@/lib/tracker-status" // Store on globalThis to survive HMR in development. // Without this, each hot-reload orphans the old cron job (it keeps firing) -// while creating a new one — causing duplicate polls that hammer tracker APIs. +// while creating a new one which causes duplicate polls that hammer tracker APIs. const g = globalThis as typeof globalThis & { __schedulerTask?: ScheduledTask | null __schedulerKey?: Buffer | null @@ -68,18 +76,19 @@ export async function fetchTrackerStats( proxyAgent?: HttpAgent ): Promise<{ stats: TrackerStats; tracker: typeof trackers.$inferSelect }> { const [tracker] = await db.select().from(trackers).where(eq(trackers.id, trackerId)).limit(1) - if (!tracker || !tracker.isActive) throw new Error("Tracker not found or inactive") + if (!tracker?.isActive) throw new Error("Tracker not found or inactive") let apiToken: string try { apiToken = decrypt(tracker.encryptedApiToken, encryptionKey) - } catch { - throw new Error(`API key is missing or invalid for tracker "${tracker.name}"`) + } catch (err) { + const cause = err instanceof Error ? err.message : String(err) + throw new Error(`API key is missing or invalid for tracker "${tracker.name}": ${cause}`) } const adapter = getAdapter(tracker.platformType) if (tracker.useProxy && !proxyAgent) { - throw new Error("Proxy required but not available — refusing to leak IP via direct connection") + throw new Error("Proxy required but not available, refusing to leak IP via direct connection") } const fetchOptions = buildFetchOptions(tracker.baseUrl, { @@ -123,7 +132,7 @@ export async function pollTracker( ): Promise { const [tracker] = await db.select().from(trackers).where(eq(trackers.id, trackerId)).limit(1) - if (!tracker || !tracker.isActive) return + if (!tracker?.isActive) return const timestamp = batchTimestamp ?? new Date() @@ -131,14 +140,13 @@ export async function pollTracker( let apiToken: string try { apiToken = decrypt(tracker.encryptedApiToken, encryptionKey) - } catch (_err) { - throw new Error(`API key is missing or invalid for tracker "${tracker.name}"`) + } catch (err) { + const cause = err instanceof Error ? err.message : String(err) + throw new Error(`API key is missing or invalid for tracker "${tracker.name}": ${cause}`) } const adapter = getAdapter(tracker.platformType) if (tracker.useProxy && !proxyAgent) { - throw new Error( - "Proxy required but not available — refusing to leak IP via direct connection" - ) + throw new Error("Proxy required but not available, refusing to leak IP via direct connection") } const fetchOptions = buildFetchOptions(tracker.baseUrl, { proxyAgent: tracker.useProxy ? proxyAgent : undefined, @@ -173,7 +181,7 @@ export async function pollTracker( await db.update(trackers).set(metaUpdates).where(eq(trackers.id, tracker.id)) } - // Fetch previous snapshot BEFORE inserting the new one — used for change detection in notifications + // Fetch previous snapshot before inserting the new one (used for change detection in notifications) const [previousSnapshot] = await db .select({ ratio: trackerSnapshots.ratio, @@ -182,6 +190,7 @@ export async function pollTracker( warned: trackerSnapshots.warned, group: trackerSnapshots.group, seedingCount: trackerSnapshots.seedingCount, + seedbonus: trackerSnapshots.seedbonus, }) .from(trackerSnapshots) .where(eq(trackerSnapshots.trackerId, tracker.id)) @@ -208,6 +217,43 @@ export async function pollTracker( }) try { + // Upsert daily checkpoint for "Today At A Glance" comparisons + const checkpointDate = localDateStr(timestamp) + await db + .insert(trackerDailyCheckpoints) + .values({ + trackerId: tracker.id, + checkpointDate, + uploadedBytesEnd: stats.uploadedBytes !== null ? BigInt(stats.uploadedBytes) : 0n, + downloadedBytesEnd: stats.downloadedBytes !== null ? BigInt(stats.downloadedBytes) : 0n, + bufferBytesEnd: stats.bufferBytes !== null ? BigInt(stats.bufferBytes) : null, + ratioEnd: stats.ratio, + seedbonusEnd: stats.seedbonus, + snapshotCount: 1, + }) + .onConflictDoUpdate({ + target: [trackerDailyCheckpoints.trackerId, trackerDailyCheckpoints.checkpointDate], + set: { + uploadedBytesEnd: stats.uploadedBytes !== null ? BigInt(stats.uploadedBytes) : 0n, + downloadedBytesEnd: stats.downloadedBytes !== null ? BigInt(stats.downloadedBytes) : 0n, + bufferBytesEnd: stats.bufferBytes !== null ? BigInt(stats.bufferBytes) : null, + ratioEnd: stats.ratio, + seedbonusEnd: stats.seedbonus, + snapshotCount: sql`${trackerDailyCheckpoints.snapshotCount} + 1`, + }, + }) + } catch (checkpointErr) { + log.warn( + `Daily checkpoint upsert failed for tracker ${tracker.id}: ${checkpointErr instanceof Error ? checkpointErr.message : "Unknown"}` + ) + } + + try { + const mamMeta = + tracker.platformType === "mam" + ? (stats.platformMeta as MamPlatformMeta | undefined) + : undefined + await dispatchNotifications( { trackerId: tracker.id, @@ -217,7 +263,7 @@ export async function pollTracker( previousRatio: previousSnapshot?.ratio ?? null, currentHnrs: stats.hitAndRuns, previousHnrs: previousSnapshot?.hitAndRuns ?? null, - currentBufferBytes: stats.bufferBytes !== null ? stats.bufferBytes : null, + currentBufferBytes: stats.bufferBytes, previousBufferBytes: previousSnapshot?.bufferBytes ?? null, trackerDown: false, trackerError: null, @@ -230,6 +276,18 @@ export async function pollTracker( trackerPausedAt: null, trackerJoinedAt: tracker.joinedAt ?? null, minimumRatio: findRegistryEntry(tracker.baseUrl)?.rules?.minimumRatio, + mamContext: + tracker.platformType === "mam" + ? { + currentSeedbonus: stats.seedbonus ?? null, + previousSeedbonus: previousSnapshot?.seedbonus ?? null, + vipUntil: mamMeta?.vipUntil ?? null, + unsatisfiedCount: mamMeta?.unsatisfiedCount ?? null, + unsatisfiedLimit: mamMeta?.unsatisfiedLimit ?? null, + inactiveHnrCount: stats.hitAndRuns ?? null, + previousInactiveHnrCount: previousSnapshot?.hitAndRuns ?? null, + } + : undefined, }, encryptionKey, enabledTargets @@ -303,12 +361,13 @@ export async function pollTracker( trackerPausedAt: tracker?.pausedAt?.toISOString() ?? null, trackerJoinedAt: tracker?.joinedAt ?? null, minimumRatio: undefined, + mamContext: undefined, }, encryptionKey, enabledTargets ) } catch { - // security-audit-ignore: notification dispatch is non-critical — errors logged inside dispatchNotifications + // security-audit-ignore: notification dispatch is non-critical, errors logged inside dispatchNotifications } } } @@ -330,8 +389,24 @@ export async function pruneOldSnapshots(retentionDays: number): Promise return deleted.length } +export async function pruneOldCheckpoints(retentionDays: number): Promise { + const cutoffDate = localDateStr(Date.now() - retentionDays * 24 * 60 * 60 * 1000) + + const deletedTracker = await db + .delete(trackerDailyCheckpoints) + .where(lt(trackerDailyCheckpoints.checkpointDate, cutoffDate)) + .returning({ id: trackerDailyCheckpoints.id }) + + const deletedTorrent = await db + .delete(torrentDailyCheckpoints) + .where(lt(torrentDailyCheckpoints.checkpointDate, cutoffDate)) + .returning({ id: torrentDailyCheckpoints.id }) + + return deletedTracker.length + deletedTorrent.length +} + export async function pollAllTrackers(encryptionKey: Buffer): Promise { - // Query settings first — global interval is needed for overdue filtering + // Query settings first; global interval is needed for overdue filtering const [settings] = await db .select({ storeUsernames: appSettings.storeUsernames, @@ -370,7 +445,7 @@ export async function pollAllTrackers(encryptionKey: Buffer): Promise { // Build proxy agent once for all trackers that need it const proxyAgent = settings ? buildProxyAgentFromSettings(settings, encryptionKey) : undefined - // Fetch notification targets once for the entire poll cycle — avoids N identical queries + // Fetch notification targets once for the entire poll cycle (avoids N identical queries) let enabledNotificationTargets: (typeof notificationTargets.$inferSelect)[] = [] try { enabledNotificationTargets = await db @@ -384,10 +459,10 @@ export async function pollAllTrackers(encryptionKey: Buffer): Promise { } // Capture a single timestamp for the whole batch so all snapshots in one - // cycle share the same polledAt value — simplifies time-series queries + // cycle share the same polledAt value, simplifying time-series queries const batchTimestamp = new Date() - // Poll all overdue trackers in parallel — one slow tracker won't block the rest + // Poll all overdue trackers in parallel so one slow tracker won't block the rest await Promise.allSettled( overdue.map((tracker) => pollTracker( @@ -411,6 +486,17 @@ export async function pollAllTrackers(encryptionKey: Buffer): Promise { } catch (error) { log.error(error, "Snapshot pruning failed") } + + try { + const prunedCheckpoints = await pruneOldCheckpoints(settings.snapshotRetentionDays) + if (prunedCheckpoints > 0) { + log.info( + `Pruned ${prunedCheckpoints} checkpoint rows older than ${settings.snapshotRetentionDays} days` + ) + } + } catch (error) { + log.error(error, "Checkpoint pruning failed") + } } // Prune expired dismissed alerts (stale-data and zero-seeding types expire after 24h) @@ -426,7 +512,7 @@ export function startScheduler(encryptionKey: Buffer): void { setSchedulerKey(encryptionKey) - // Poll immediately on start — don't wait for first 5-minute tick + // Poll immediately on start, don't wait for first 5-minute tick pollAllTrackers(encryptionKey).catch((error) => { log.error(error, "Initial poll error") }) @@ -468,7 +554,7 @@ export function stopScheduler(): void { stopBackupScheduler() } -/** Exposed for testing — returns the raw schedulerKey buffer reference. */ +/** Exposed for testing. Returns the raw schedulerKey buffer reference. */ export function _getSchedulerKeyForTest(): Buffer | null { return getSchedulerKey() } diff --git a/src/lib/server-data.ts b/src/lib/server-data.ts index f3da1f28..59c8eeb2 100644 --- a/src/lib/server-data.ts +++ b/src/lib/server-data.ts @@ -2,7 +2,7 @@ // // Functions: fetchSettings, serializeSettingsResponse, getSettingsForClient, // getTrackerListForDashboard, getTrackerForClient, getSnapshotsForTracker, -// getTagGroupsWithMembers, getProxyTrackers +// getTagGroupsWithMembers, getProxyTrackers, getDatabaseSize // Constants: settingsColumns, trackerColumns // // Server-side data fetchers: single source of truth for safe DB queries @@ -44,7 +44,7 @@ import type { Snapshot, TagGroup, TagGroupChartType } from "@/types/api" * * Note: `hasProxyPassword` and `hasBackupPassword` select the encrypted column * references so the serializer can coerce them to booleans. The raw ciphertext - * is never returned to clients — serializeSettingsResponse() converts these to + * is never returned to clients and serializeSettingsResponse() converts these to * `!!value` before the data leaves the server. */ export const settingsColumns = { @@ -116,7 +116,7 @@ export function serializeSettingsResponse(row: SettingsRow) { } /** - * Convenience wrapper: fetches settings and serializes for the client. + * fetches settings and serializes for the client. * Returns null if no settings row exists (app not yet configured). */ export async function getSettingsForClient() { @@ -149,10 +149,12 @@ export const trackerColumns = { userPausedAt: trackers.userPausedAt, color: trackers.color, qbtTag: trackers.qbtTag, + mouseholeUrl: trackers.mouseholeUrl, remoteUserId: trackers.remoteUserId, platformMeta: trackers.platformMeta, useProxy: trackers.useProxy, countCrossSeedUnsatisfied: trackers.countCrossSeedUnsatisfied, + hideUnreadBadges: trackers.hideUnreadBadges, isFavorite: trackers.isFavorite, sortOrder: trackers.sortOrder, joinedAt: trackers.joinedAt, @@ -175,40 +177,16 @@ export async function getTrackerListForDashboard(): Promise { db.select({ storeUsernames: appSettings.storeUsernames }).from(appSettings).limit(1), ]) - // Enforce masking at response time -- even if DB has plaintext from before - // privacy was enabled, the API never leaks it when privacy mode is on. + // Enforce masking at response time // Fallback true = "store usernames" = no masking. Matches createPrivacyMask() // behavior when no settings row exists. Do NOT change to false. const mask = createPrivacyMaskSync(privacySettings?.storeUsernames ?? true) - // Batch-fetch the latest snapshot per tracker using DISTINCT ON. - // PG18's enable_distinct_reordering planner flag optimises exactly this pattern. - // Drizzle has no native DISTINCT ON support, so we use db.execute with a raw sql tag. I know, I know. - // security-audit-ignore: static SQL string with zero user input -- no injection risk - const latestSnapshots = (await db.execute(sql` - SELECT DISTINCT ON (tracker_id) - id, - tracker_id AS "trackerId", - polled_at AS "polledAt", - uploaded_bytes AS "uploadedBytes", - downloaded_bytes AS "downloadedBytes", - ratio, - buffer_bytes AS "bufferBytes", - seeding_count AS "seedingCount", - leeching_count AS "leechingCount", - seedbonus, - hit_and_runs AS "hitAndRuns", - required_ratio AS "requiredRatio", - warned, - freeleech_tokens AS "freeleechTokens", - share_score AS "shareScore", - username, - group_name AS "group" - FROM tracker_snapshots - ORDER BY tracker_id, polled_at DESC - `)) as unknown as (typeof trackerSnapshots.$inferSelect)[] - - // Build a lookup map for O(1) access + const latestSnapshots = await db + .selectDistinctOn([trackerSnapshots.trackerId]) + .from(trackerSnapshots) + .orderBy(trackerSnapshots.trackerId, desc(trackerSnapshots.polledAt)) + const snapshotByTracker = new Map(latestSnapshots.map((s) => [s.trackerId, s])) // SECURITY: Never include encryptedApiToken in response @@ -341,7 +319,7 @@ export async function getTagGroupsWithMembers(): Promise { /** * Fetches only id, name, and color for trackers that have useProxy enabled. - * Used by the settings page — avoids the heavy dashboard query for this narrow need. + * Used by the settings page */ export async function getProxyTrackers(): Promise<{ id: number; name: string; color: string }[]> { const rows = await db @@ -359,3 +337,15 @@ export async function getProxyTrackers(): Promise<{ id: number; name: string; co color: r.color ?? "#00d4ff", })) } + +// --------------------------------------------------------------------------- +// Database size +// --------------------------------------------------------------------------- + +// security-audit-ignore: read-only PostgreSQL system function, no user input +export async function getDatabaseSize(): Promise { + const rows = await db.execute( + sql`SELECT pg_size_pretty(pg_database_size(current_database())) as size` + ) + return (rows as unknown as Array<{ size: string }>)[0]?.size ?? "Unknown" +} diff --git a/src/lib/slot-types.ts b/src/lib/slot-types.ts index 86d70813..5bb0db56 100644 --- a/src/lib/slot-types.ts +++ b/src/lib/slot-types.ts @@ -6,6 +6,7 @@ import type { TrackerRegistryEntry } from "@/data/tracker-registry" import type { GazellePlatformMeta, GGnPlatformMeta, + MamPlatformMeta, NebulancePlatformMeta, } from "@/lib/adapters/types" import type { Snapshot, TrackerSummary } from "@/types/api" @@ -16,7 +17,7 @@ export interface SlotContext { tracker: TrackerSummary latestSnapshot: Snapshot | null snapshots: Snapshot[] - meta: GGnPlatformMeta | GazellePlatformMeta | NebulancePlatformMeta | null + meta: GGnPlatformMeta | GazellePlatformMeta | NebulancePlatformMeta | MamPlatformMeta | null registry: TrackerRegistryEntry | undefined accentColor: string } diff --git a/src/lib/today.ts b/src/lib/today.ts new file mode 100644 index 00000000..ea8da1df --- /dev/null +++ b/src/lib/today.ts @@ -0,0 +1,458 @@ +// src/lib/today.ts +// +// Functions: computeTodayAtAGlance, backfillTrackerCheckpoints + +import "server-only" + +import { eq, gte, inArray, sql } from "drizzle-orm" +import { db } from "@/lib/db" +import { + downloadClients, + torrentDailyCheckpoints, + trackerDailyCheckpoints, + trackerSnapshots, + trackers, +} from "@/lib/db/schema" +import { parseTorrentTags } from "@/lib/fleet" +import { compareBigIntDesc, isUnixTimestampOnDate, localDateStr } from "@/lib/formatters" +import { log } from "@/lib/logger" +import { parseCachedTorrents } from "@/lib/qbt/client" +import type { TodayAtAGlance } from "@/types/api" + +interface TrackerDelta { + id: number + name: string + color: string | null + uploadDelta: bigint + downloadDelta: bigint + bufferDelta: bigint + ratioChange: number + seedbonusChange: number +} + +function zeroDelta(tracker: { id: number; name: string; color: string | null }): TrackerDelta { + return { + id: tracker.id, + name: tracker.name, + color: tracker.color ?? null, + uploadDelta: 0n, + downloadDelta: 0n, + bufferDelta: 0n, + ratioChange: 0, + seedbonusChange: 0, + } +} + +interface TorrentMover { + hash: string + name: string + qbtTag: string | null + trackerColor: string | null + clientName: string | null + uploadedToday: bigint + downloadedToday: bigint +} + +/** + * Computes the entire TodayAtAGlance payload in a single call. + * Fetches all required data in parallel where possible, then aggregates + * fleet-level deltas, yesterday comparisons, torrent movers, and activity counts. + */ +export async function computeTodayAtAGlance(): Promise { + // ── Date boundaries (local timezone) ────────────────────────────────────── + const now = Date.now() + const todayStr = localDateStr() + // Start of today in local timezone for snapshot filtering + const todayStart = new Date(`${todayStr}T00:00:00`) + const yesterdayStr = localDateStr(now - 86400000) + const dayBeforeStr = localDateStr(now - 172800000) + + // ── Parallel data fetches ───────────────────────────────────────────────── + const [allTrackers, todaySnapshots, checkpointRows, torrentCps, clients] = await Promise.all([ + db + .select({ + id: trackers.id, + name: trackers.name, + color: trackers.color, + qbtTag: trackers.qbtTag, + }) + .from(trackers) + .where(eq(trackers.isActive, true)), + + db + .select({ + trackerId: trackerSnapshots.trackerId, + polledAt: trackerSnapshots.polledAt, + uploadedBytes: trackerSnapshots.uploadedBytes, + downloadedBytes: trackerSnapshots.downloadedBytes, + bufferBytes: trackerSnapshots.bufferBytes, + ratio: trackerSnapshots.ratio, + seedbonus: trackerSnapshots.seedbonus, + }) + .from(trackerSnapshots) + .where(gte(trackerSnapshots.polledAt, todayStart)) + .orderBy(trackerSnapshots.polledAt), + + db + .select({ + trackerId: trackerDailyCheckpoints.trackerId, + checkpointDate: trackerDailyCheckpoints.checkpointDate, + uploadedBytesEnd: trackerDailyCheckpoints.uploadedBytesEnd, + downloadedBytesEnd: trackerDailyCheckpoints.downloadedBytesEnd, + bufferBytesEnd: trackerDailyCheckpoints.bufferBytesEnd, + }) + .from(trackerDailyCheckpoints) + .where(inArray(trackerDailyCheckpoints.checkpointDate, [yesterdayStr, dayBeforeStr])), + + db + .select({ + clientId: torrentDailyCheckpoints.clientId, + hash: torrentDailyCheckpoints.hash, + uploadedStart: torrentDailyCheckpoints.uploadedStart, + downloadedStart: torrentDailyCheckpoints.downloadedStart, + }) + .from(torrentDailyCheckpoints) + .where(eq(torrentDailyCheckpoints.checkpointDate, todayStr)), + + db + .select({ + id: downloadClients.id, + name: downloadClients.name, + cachedTorrents: downloadClients.cachedTorrents, + cachedTorrentsAt: downloadClients.cachedTorrentsAt, + }) + .from(downloadClients) + .where(eq(downloadClients.enabled, true)), + ]) + + const yesterdayCheckpoints = checkpointRows.filter((r) => r.checkpointDate === yesterdayStr) + const dayBeforeCheckpoints = checkpointRows.filter((r) => r.checkpointDate === dayBeforeStr) + + // ── Per-tracker deltas from today's snapshots ───────────────────────────── + + // Group snapshots by trackerId + const snapshotsByTracker = new Map() + for (const snap of todaySnapshots) { + const bucket = snapshotsByTracker.get(snap.trackerId) ?? [] + bucket.push(snap) + snapshotsByTracker.set(snap.trackerId, bucket) + } + + const trackerDeltas: TrackerDelta[] = [] + + for (const tracker of allTrackers) { + const snaps = snapshotsByTracker.get(tracker.id) + + if (!snaps || snaps.length < 2) { + trackerDeltas.push(zeroDelta(tracker)) + continue + } + + const earliest = snaps[0] + const latest = snaps[snaps.length - 1] + + try { + const uploadDelta = BigInt(latest.uploadedBytes) - BigInt(earliest.uploadedBytes) + const downloadDelta = BigInt(latest.downloadedBytes) - BigInt(earliest.downloadedBytes) + + const latestBuffer = latest.bufferBytes ?? 0n + const earliestBuffer = earliest.bufferBytes ?? 0n + const bufferDelta = BigInt(latestBuffer) - BigInt(earliestBuffer) + + const ratioChange = (latest.ratio ?? 0) - (earliest.ratio ?? 0) + const seedbonusChange = (latest.seedbonus ?? 0) - (earliest.seedbonus ?? 0) + + trackerDeltas.push({ + id: tracker.id, + name: tracker.name, + color: tracker.color ?? null, + uploadDelta, + downloadDelta, + bufferDelta, + ratioChange, + seedbonusChange, + }) + } catch (err) { + log.warn(err, "BigInt conversion failed for tracker %d", tracker.id) + trackerDeltas.push(zeroDelta(tracker)) + } + } + + // ── Fleet aggregation ────────────────────────────────────────────────────── + + let fleetUploadDelta = 0n + let fleetDownloadDelta = 0n + let fleetBufferDelta = 0n + let fleetSeedbonusChange = 0 + + // Weighted average of ratio change: sum(ratioChange * uploadDelta) / sum(uploadDelta) + let weightedRatioSum = 0 + let totalUploadWeight = 0n + + for (const delta of trackerDeltas) { + fleetUploadDelta += delta.uploadDelta + fleetDownloadDelta += delta.downloadDelta + fleetBufferDelta += delta.bufferDelta + fleetSeedbonusChange += delta.seedbonusChange + + if (delta.uploadDelta > 0n) { + // NOTE: Number(delta.uploadDelta) can lose precision when uploadDelta + // exceeds Number.MAX_SAFE_INTEGER (~8 PiB). For realistic tracker upload + // volumes this is unlikely, but a full bigint weighted-average refactor + // would eliminate this limitation if ever needed. + const weight = Number(delta.uploadDelta) + weightedRatioSum += delta.ratioChange * weight + totalUploadWeight += delta.uploadDelta + } + } + + const fleetRatioChange = + totalUploadWeight > 0n ? weightedRatioSum / Number(totalUploadWeight) : null + + // ── Yesterday comparison from checkpoint tables ──────────────────────────── + + const yesterdayByTracker = new Map(yesterdayCheckpoints.map((cp) => [cp.trackerId, cp])) + const dayBeforeByTracker = new Map(dayBeforeCheckpoints.map((cp) => [cp.trackerId, cp])) + + let yesterdayFleetUpload: bigint | null = null + let yesterdayFleetDownload: bigint | null = null + let yesterdayFleetBuffer: bigint | null = null + + for (const tracker of allTrackers) { + const yesterday = yesterdayByTracker.get(tracker.id) + const dayBefore = dayBeforeByTracker.get(tracker.id) + + if (!yesterday || !dayBefore) continue + + try { + const trackerUploadYesterday = + BigInt(yesterday.uploadedBytesEnd) - BigInt(dayBefore.uploadedBytesEnd) + const trackerDownloadYesterday = + BigInt(yesterday.downloadedBytesEnd) - BigInt(dayBefore.downloadedBytesEnd) + + const yesterdayBuffer = yesterday.bufferBytesEnd ?? 0n + const dayBeforeBuffer = dayBefore.bufferBytesEnd ?? 0n + const trackerBufferYesterday = BigInt(yesterdayBuffer) - BigInt(dayBeforeBuffer) + + yesterdayFleetUpload = (yesterdayFleetUpload ?? 0n) + trackerUploadYesterday + yesterdayFleetDownload = (yesterdayFleetDownload ?? 0n) + trackerDownloadYesterday + yesterdayFleetBuffer = (yesterdayFleetBuffer ?? 0n) + trackerBufferYesterday + } catch (err) { + log.warn(err, "BigInt conversion failed for yesterday comparison, tracker %d", tracker.id) + } + } + + // ── Torrent movers ───────────────────────────────────────────────────────── + + // Build checkpoint lookup + const cpByKey = new Map(torrentCps.map((cp) => [`${cp.clientId}:${cp.hash}`, cp])) + + // Build tracker tag → color lookup for matching torrent tags + const trackerTagToColor = new Map() + for (const tracker of allTrackers) { + if (tracker.qbtTag) { + trackerTagToColor.set(tracker.qbtTag.toLowerCase(), tracker.color ?? null) + } + } + + const movers: TorrentMover[] = [] + let addedToday = 0 + let completedToday = 0 + + for (const client of clients) { + const torrents = parseCachedTorrents(client.cachedTorrents) + + for (const torrent of torrents) { + // Activity counts. added_on and completion_on are unix timestamps (seconds) + if (isUnixTimestampOnDate(torrent.added_on, todayStr)) { + addedToday++ + } + if (torrent.completion_on !== -1 && isUnixTimestampOnDate(torrent.completion_on, todayStr)) { + completedToday++ + } + + // Compare current uploaded/downloaded to today's checkpoint + const key = `${client.id}:${torrent.hash}` + const checkpoint = cpByKey.get(key) + if (!checkpoint) continue + + let uploadedToday: bigint + let downloadedToday: bigint + try { + uploadedToday = BigInt(torrent.uploaded) - BigInt(checkpoint.uploadedStart) + downloadedToday = BigInt(torrent.downloaded) - BigInt(checkpoint.downloadedStart) + } catch (err) { + log.warn(err, "BigInt conversion failed for torrent %s", torrent.hash) + continue + } + + // Match first qbtTag found in the torrent's comma-separated tags field + let matchedTag: string | null = null + let matchedColor: string | null = null + if (torrent.tags) { + const torrentTags = parseTorrentTags(torrent.tags) + for (const tag of torrentTags) { + const tagLower = tag.toLowerCase() + if (trackerTagToColor.has(tagLower)) { + matchedTag = tag + matchedColor = trackerTagToColor.get(tagLower) ?? null + break + } + } + } + + // Only include torrents that match a tracked tracker + if (!matchedTag) continue + + movers.push({ + hash: torrent.hash, + name: torrent.name, + qbtTag: matchedTag, + trackerColor: matchedColor, + clientName: client.name, + uploadedToday, + downloadedToday, + }) + } + } + + // Sort and take top 5 uploaders and downloaders + const topUploaders = movers + .filter((m) => m.uploadedToday > 0n) + .sort((a, b) => compareBigIntDesc(a.uploadedToday, b.uploadedToday)) + .slice(0, 5) + + const topDownloaders = movers + .filter((m) => m.downloadedToday > 0n) + .sort((a, b) => compareBigIntDesc(a.downloadedToday, b.downloadedToday)) + .slice(0, 5) + + // ── Assemble and return ─────────────────────────────────────────────────── + + const latestClientPoll = clients.reduce((latest, c) => { + if (c.cachedTorrentsAt && (!latest || c.cachedTorrentsAt > latest)) return c.cachedTorrentsAt + return latest + }, null) + + return { + fleet: { + uploadDelta: fleetUploadDelta.toString(), + downloadDelta: fleetDownloadDelta.toString(), + bufferDelta: fleetBufferDelta.toString(), + ratioChange: fleetRatioChange, + seedbonusChange: fleetSeedbonusChange, + uploadDeltaYesterday: yesterdayFleetUpload !== null ? yesterdayFleetUpload.toString() : null, + downloadDeltaYesterday: + yesterdayFleetDownload !== null ? yesterdayFleetDownload.toString() : null, + bufferDeltaYesterday: yesterdayFleetBuffer !== null ? yesterdayFleetBuffer.toString() : null, + }, + trackers: trackerDeltas.map((d) => ({ + id: d.id, + name: d.name, + color: d.color, + uploadDelta: d.uploadDelta.toString(), + downloadDelta: d.downloadDelta.toString(), + bufferDelta: d.bufferDelta.toString(), + })), + activity: { + addedToday, + completedToday, + }, + movers: { + topUploaders: topUploaders.map((t) => ({ + hash: t.hash, + name: t.name, + qbtTag: t.qbtTag, + trackerColor: t.trackerColor, + clientName: t.clientName, + uploadedToday: t.uploadedToday.toString(), + })), + topDownloaders: topDownloaders.map((t) => ({ + hash: t.hash, + name: t.name, + qbtTag: t.qbtTag, + trackerColor: t.trackerColor, + clientName: t.clientName, + downloadedToday: t.downloadedToday.toString(), + })), + }, + trackerLastUpdated: + todaySnapshots.length > 0 + ? todaySnapshots[todaySnapshots.length - 1].polledAt.toISOString() + : null, + clientLastUpdated: latestClientPoll?.toISOString() ?? null, + } +} + +// ── Backfill ─────────────────────────────────────────────────── + +/** + * One-time backfill: populates trackerDailyCheckpoints from existing trackerSnapshots. + * Should be called once when the checkpoint table is empty but snapshot data exists. + */ +export async function backfillTrackerCheckpoints(): Promise { + // Check if backfill is needed + const [existing] = await db + .select({ id: trackerDailyCheckpoints.id }) + .from(trackerDailyCheckpoints) + .limit(1) + + if (existing) return 0 // Already has data, skip + + // Check if there are snapshots to backfill from + const [hasSnapshots] = await db + .select({ id: trackerSnapshots.id }) + .from(trackerSnapshots) + .limit(1) + + if (!hasSnapshots) return 0 // No snapshots, nothing to backfill + + const polledDate = sql`DATE(${trackerSnapshots.polledAt})` + + const rows = await db + .selectDistinctOn([trackerSnapshots.trackerId, polledDate], { + trackerId: trackerSnapshots.trackerId, + checkpointDate: polledDate.as("checkpoint_date"), + uploadedBytesEnd: trackerSnapshots.uploadedBytes, + downloadedBytesEnd: trackerSnapshots.downloadedBytes, + bufferBytesEnd: trackerSnapshots.bufferBytes, + ratioEnd: trackerSnapshots.ratio, + seedbonusEnd: trackerSnapshots.seedbonus, + }) + .from(trackerSnapshots) + .orderBy(trackerSnapshots.trackerId, polledDate, sql`${trackerSnapshots.polledAt} DESC`) + + if (rows.length === 0) return 0 + + const CHUNK_SIZE = 500 + let inserted = 0 + + for (let i = 0; i < rows.length; i += CHUNK_SIZE) { + const chunk = rows.slice(i, i + CHUNK_SIZE) + await db + .insert(trackerDailyCheckpoints) + .values( + chunk.map((row) => ({ + trackerId: row.trackerId, + checkpointDate: row.checkpointDate, + uploadedBytesEnd: row.uploadedBytesEnd, + downloadedBytesEnd: row.downloadedBytesEnd, + bufferBytesEnd: row.bufferBytesEnd, + ratioEnd: row.ratioEnd != null ? Number(row.ratioEnd) : null, + seedbonusEnd: row.seedbonusEnd != null ? Number(row.seedbonusEnd) : null, + // snapshotCount is hard-coded to 1 because backfill selects only the + // last snapshot per day — the actual count is not available without a + // separate COUNT query per (trackerId, date) pair. Acceptable for backfill. + snapshotCount: 1, + })) + ) + .onConflictDoNothing() + // NOTE: inserted tracks chunk.length rather than actual DB rows written. + // onConflictDoNothing() silently skips duplicate rows, so this count may + // overstate the number of rows inserted. The return value is informational + // only (used for a log.info call) and does not affect correctness. + inserted += chunk.length + } + + return inserted +} diff --git a/src/lib/tracker-events.ts b/src/lib/tracker-events.ts index ebbe9a53..8388ca50 100644 --- a/src/lib/tracker-events.ts +++ b/src/lib/tracker-events.ts @@ -3,7 +3,8 @@ // Functions: checkRatioBelowMinimum, checkRatioDelta, checkRatioBelowMinimumTransition, // checkTrackerError, checkWarned, checkWarnedTransition, checkZeroSeeding, // checkHnrIncrease, checkBufferMilestoneCrossed, checkRankChange, -// checkAnniversaryMilestone, EVENT_SNOOZE_MS +// checkAnniversaryMilestone, checkBonusCapReached, checkVipExpiringSoon, +// checkUnsatisfiedLimitApproaching, checkActiveHnrs, EVENT_SNOOZE_MS // // Shared pure-function event detection checks. No framework imports, no DB imports. // Importable from both client-side dashboard code and server-side scheduler code. @@ -148,6 +149,51 @@ export function checkAnniversaryMilestone( return null } +// ─── MAM-specific events ────────────────────────────────────────────────────── + +/** Fires when seedbonus hits or exceeds the cap. Transition-based: only fires if previous was below cap. */ +export function checkBonusCapReached( + currentBonus: number | null | undefined, + previousBonus: number | null | undefined, + capLimit: number +): boolean { + if (currentBonus == null) return false + if (previousBonus != null && previousBonus >= capLimit) return false + return currentBonus >= capLimit +} + +/** Fires when VIP expiry is within N days from now. */ +export function checkVipExpiringSoon( + vipUntil: string | null | undefined, + thresholdDays: number +): boolean { + if (!vipUntil) return false + const expiry = new Date(vipUntil) + if (Number.isNaN(expiry.getTime())) return false + const daysRemaining = (expiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24) + return daysRemaining > 0 && daysRemaining <= thresholdDays +} + +/** Fires when unsatisfied count reaches or exceeds the percent threshold of the limit. */ +export function checkUnsatisfiedLimitApproaching( + unsatisfiedCount: number | null | undefined, + unsatisfiedLimit: number | null | undefined, + percentThreshold: number +): boolean { + if (unsatisfiedCount == null || unsatisfiedLimit == null || unsatisfiedLimit === 0) return false + return (unsatisfiedCount / unsatisfiedLimit) * 100 >= percentThreshold +} + +/** Fires when inactive HnR count increases (transition-based). */ +export function checkActiveHnrs( + inactiveHnrCount: number | null | undefined, + previousInactiveHnrCount: number | null | undefined +): boolean { + if (inactiveHnrCount == null || inactiveHnrCount <= 0) return false + if (previousInactiveHnrCount != null && previousInactiveHnrCount >= inactiveHnrCount) return false + return true +} + // ─── Snooze durations ──────────────────────────────────────────────────────── // Per-event-type snooze duration map. Events with different urgency/frequency profiles @@ -162,4 +208,8 @@ export const EVENT_SNOOZE_MS: Record = { zero_seeding: 24 * 60 * 60 * 1000, // 24 hours — state-based, fires while at 0 seeds rank_change: 7 * 24 * 60 * 60 * 1000, // 7 days — rare event, one notification per change anniversary: 7 * 24 * 60 * 60 * 1000, // 7 days — longer than the ±3-day detection window + bonus_cap: 24 * 60 * 60 * 1000, // 24 hours + vip_expiring: 24 * 60 * 60 * 1000, // 24 hours + unsatisfied_limit: 6 * 60 * 60 * 1000, // 6 hours + active_hnrs: 6 * 60 * 60 * 1000, // 6 hours } diff --git a/src/lib/tracker-serializer.ts b/src/lib/tracker-serializer.ts index 456bc164..3664403f 100644 --- a/src/lib/tracker-serializer.ts +++ b/src/lib/tracker-serializer.ts @@ -39,8 +39,10 @@ export function serializeTrackerResponse( userPausedAt: tracker.userPausedAt?.toISOString() ?? null, color: tracker.color ?? "#00d4ff", qbtTag: tracker.qbtTag, + mouseholeUrl: tracker.mouseholeUrl ?? null, useProxy: tracker.useProxy, countCrossSeedUnsatisfied: tracker.countCrossSeedUnsatisfied, + hideUnreadBadges: tracker.hideUnreadBadges, isFavorite: tracker.isFavorite, sortOrder: tracker.sortOrder, joinedAt: tracker.joinedAt, diff --git a/src/proxy.test.ts b/src/proxy.test.ts index 25eb485b..0dc2fe3f 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -1,9 +1,13 @@ // src/proxy.test.ts import { NextRequest } from "next/server" -import { describe, expect, it } from "vitest" +import { describe, expect, it, vi } from "vitest" import { proxy } from "./proxy" +vi.mock("@/lib/cookie-security", () => ({ + shouldSecureCookies: () => false, +})) + describe("auth middleware", () => { it("allows public auth routes without a session", () => { const response = proxy(new NextRequest("http://localhost/api/auth/status")) @@ -42,6 +46,8 @@ describe("auth middleware", () => { expect(setCookie).toContain("HttpOnly") expect(setCookie.toLowerCase()).toContain("samesite=strict") expect(setCookie).toContain("Max-Age=1800") + // shouldSecureCookies() is mocked to return false — verify Secure is absent + expect(setCookie).not.toContain("Secure") }) it("does not honor oversized tt_max_age cookie values", () => { diff --git a/src/proxy.ts b/src/proxy.ts index 7c2425d8..33a07b15 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,5 +1,6 @@ // src/proxy.ts import { type NextRequest, NextResponse } from "next/server" +import { shouldSecureCookies } from "@/lib/cookie-security" const PUBLIC_EXACT = ["/login", "/setup", "/api/health"] const PUBLIC_PREFIX = ["/api/auth/", "/api/verify-report", "/_next/", "/img/", "/favicon"] @@ -31,17 +32,17 @@ export function proxy(request: NextRequest) { const maxAge = maxAgeStr ? parseInt(maxAgeStr, 10) : null if (maxAge && maxAge > 0 && maxAge <= MAX_COOKIE_AGE) { - const isProduction = process.env.NODE_ENV === "production" + const secureCookies = shouldSecureCookies() response.cookies.set(SESSION_COOKIE, session.value, { httpOnly: true, - secure: isProduction, + secure: secureCookies, sameSite: "strict", maxAge, path: "/", }) response.cookies.set(MAX_AGE_COOKIE, String(maxAge), { httpOnly: true, - secure: isProduction, + secure: secureCookies, sameSite: "strict", maxAge, path: "/", diff --git a/src/types/api.ts b/src/types/api.ts index 71e77c27..fb781adf 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -3,6 +3,7 @@ import type { GazellePlatformMeta, GGnPlatformMeta, + MamPlatformMeta, NebulancePlatformMeta, } from "@/lib/adapters/types" @@ -10,6 +11,7 @@ export type { GazellePlatformMeta, GazelleRanks, GGnPlatformMeta, + MamPlatformMeta, NebulancePlatformMeta, } from "@/lib/adapters/types" @@ -22,7 +24,7 @@ export interface TrackerLatestStats { requiredRatio: number | null warned: boolean | null freeleechTokens: number | null - bufferBytes: string | null // bigint serialized as decimal string + bufferBytes: string | null hitAndRuns: number | null seedbonus: number | null shareScore: number | null @@ -43,14 +45,21 @@ export interface TrackerSummary { userPausedAt: string | null color: string qbtTag: string | null + mouseholeUrl: string | null useProxy: boolean countCrossSeedUnsatisfied: boolean + hideUnreadBadges: boolean isFavorite: boolean sortOrder: number | null joinedAt: string | null lastAccessAt: string | null remoteUserId: number | null - platformMeta: GGnPlatformMeta | GazellePlatformMeta | NebulancePlatformMeta | null + platformMeta: + | GGnPlatformMeta + | GazellePlatformMeta + | NebulancePlatformMeta + | MamPlatformMeta + | null createdAt: string latestStats: TrackerLatestStats | null } @@ -114,9 +123,56 @@ export interface QbitmanageTagConfig { export interface DashboardSettings { showHealthIndicators: boolean showLoginTimers: boolean + showTodayAtAGlance: boolean +} + +export interface TodayAtAGlance { + fleet: { + uploadDelta: string + downloadDelta: string + bufferDelta: string + ratioChange: number | null + seedbonusChange: number | null + uploadDeltaYesterday: string | null + downloadDeltaYesterday: string | null + bufferDeltaYesterday: string | null + } + trackers: Array<{ + id: number + name: string + color: string | null + uploadDelta: string + downloadDelta: string + bufferDelta: string + }> + activity: { + addedToday: number + completedToday: number + } + movers: { + topUploaders: Array<{ + hash: string + name: string + qbtTag: string | null + trackerColor: string | null + clientName: string | null + uploadedToday: string + }> + topDownloaders: Array<{ + hash: string + name: string + qbtTag: string | null + trackerColor: string | null + clientName: string | null + downloadedToday: string + }> + } + trackerLastUpdated: string | null + clientLastUpdated: string | null } export const DASHBOARD_SETTINGS_DEFAULTS: DashboardSettings = { showHealthIndicators: true, showLoginTimers: true, + showTodayAtAGlance: true, }