Compare commits

...
Author SHA1 Message Date
Lang 3e90bbe5cf chg: pkg: migrate from shitty Minio to RustFS #16
Deploy to Production / deploy (push) Successful in 3m9s
2026-10-24 17:45:19 +02:00
Lang 1d8d0ac110 fix: pkg: upgrade PHP due to the security considerations #16 2026-10-24 16:23:40 +02:00
Lang 007c91f7e9 chg: pkg: new version release !skipChangelog
Deploy to Production / deploy (push) Successful in 2m49s
2026-09-21 10:24:10 +02:00
Lang e70cb57176 chg: usr: completely disable the contact form #15
Deploy to Production / deploy (push) Successful in 3m0s
2026-09-21 10:23:38 +02:00
Lang db54899ed4 chg: pkg: new version release !skipChangelog
Deploy to Production / deploy (push) Successful in 3m30s
2026-08-18 17:43:52 +02:00
Lang 558c8a3bad chg: pkg: add backup/restore scripts #15
Deploy to Production / deploy (push) Successful in 3m26s
2026-08-18 17:41:43 +02:00
Lang 2be49ebdf1 chg: dev: fix the changing local url names from legacy .local - & change the minio default port #15 2026-08-18 17:19:43 +02:00
Lang 8b428260d7 chg: pkg: new version release !skipChangelog
Deploy to Production / deploy (push) Successful in 27s
2026-07-27 19:30:53 +02:00
Lang 306add033a chg: pkg: new version release !skipChangelog 2026-07-27 19:02:31 +02:00
Lang f451df283c chg: usr: add current version of the app in the footer #14
Deploy to Production / deploy (push) Successful in 3m42s
2026-07-27 19:01:42 +02:00
Lang 8d542e683e chg: pkg: upgrade FE to the latest possible version #14 2026-07-27 18:52:14 +02:00
Lang dff46cdc6f chg: test: all tests passed and migrated to Symfony 8 #14 2026-07-27 18:39:17 +02:00
Lang 82fff0cdf0 chg: pkg: upgrade the BE deps to the latest possible version #14 2026-07-27 18:35:42 +02:00
Lang 4bf7ac358d chg: dev: remove registration notification #14 2026-07-27 18:16:08 +02:00
Lang 1a97c2c4b4 chg: pkg: add a process that removes the unnecessary minio-init when it does not needed anymore #14 2026-07-27 18:09:33 +02:00
Lang 9ea83d7e6e new: dev: hardening the registration process with removing the not activated registrations #14 2026-07-27 18:08:52 +02:00
Lang f3e4ff211d chg: dev: removed the obsolete contact form entry - legacy code from Google ReCapthca #14 2026-07-27 14:57:33 +02:00
Lang 7a86767db1 chg: pkg: new version release !skipChangelog
Deploy to Production / deploy (push) Successful in 2m18s
2026-06-08 12:59:29 +02:00
Lang 2b1689b321 chg: usr: add clickable badges for Cap app-wide #13
Deploy to Production / deploy (push) Successful in 2m43s
2026-06-02 09:48:23 +02:00
Lang cf56b10aba chg: pkg: upgrade both backend and frontend to the latest available version #13 2026-06-01 22:36:19 +02:00
Lang 7063704539 chg: usr: replace Google ReCaptcha with Cap instance #13 2026-06-01 22:24:34 +02:00
82 changed files with 4348 additions and 2805 deletions
+23 -11
View File
@@ -6,6 +6,9 @@
APP_ENV=dev
APP_SECRET=changethis
APP_NAME=mineseeker
# Public application release identifier, e.g. 2026.3.0. In Gitea production
# deployments this is supplied by the APP_VERSION repository variable.
APP_VERSION=1.0.0
# APP_PUBLIC_HOSTNAME: The public hostname for your application (used for generating absolute URLs in emails)
# For production, set this to your domain (e.g., mineseeker.com)
APP_PUBLIC_HOSTNAME=localhost
@@ -17,20 +20,22 @@ APP_PUBLIC_HOSTNAME=localhost
###< symfony/framework-bundle ###
###> doctrine/doctrine-bundle ###
# Docker PostgreSQL is exposed on port 15432 — use that for bare-metal dev.
# Docker PostgreSQL is exposed on port 5452 — use that for bare-metal dev.
# Replace POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB with the values from your .env.
DATABASE_URL=postgresql://POSTGRES_USER:POSTGRES_PASSWORD@127.0.0.1:15432/POSTGRES_DB?serverVersion=18&charset=utf8
DATABASE_URL=postgresql://POSTGRES_USER:POSTGRES_PASSWORD@127.0.0.1:5452/POSTGRES_DB?serverVersion=18&charset=utf8
###< doctrine/doctrine-bundle ###
###> google/recaptcha ###
RECAPTCHA_SITE_KEY=changethis
RECAPTCHA_SECRET_KEY=changethis
###< google/recaptcha ###
###> trycap.dev/cap ###
# CAP_API_ENDPOINT: Full URL including site-key path, e.g. https://cap.example.com/my-site-key/
# After deploying the Cap service, create a site in the dashboard and paste the endpoint here.
CAP_API_ENDPOINT=http://localhost:3000/changethis/
CAP_SECRET_KEY=changethis
###< trycap.dev/cap ###
###> minio/minio ###
MINIO_ROOT_USER=changethis
MINIO_ROOT_PASSWORD=changethis
MINIO_ENDPOINT=http://localhost:9000
MINIO_ENDPOINT=http://localhost:9010
MINIO_PUBLIC_URL=https://your-minio-subdomain.example.com
###< minio/minio ###
@@ -42,9 +47,9 @@ MAIL_DOMAIN=localhost
###> symfony/mercure-bundle ###
# See https://symfony.com/doc/current/mercure.html#configuration
# The URL of the Mercure hub, used by the app to publish updates (can be a local URL)
MERCURE_URL=https://mine.local/.well-known/mercure
MERCURE_URL=https://mine.home.arpa/.well-known/mercure
# The public URL of the Mercure hub, used by the browser to connect
MERCURE_PUBLIC_URL=https://mine.local/.well-known/mercure
MERCURE_PUBLIC_URL=https://mine.home.arpa/.well-known/mercure
# The secret used to sign the JWTs
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
# Publisher JWT (must match publisher_jwt in your Caddyfile)
@@ -54,6 +59,13 @@ MERCURE_SUBSCRIBER_JWT=changethis
###< symfony/mercure-bundle ###
###> web-auth/webauthn-framework ###
WEBAUTHN_RP_ID=mine.local
WEBAUTHN_ORIGIN=https://mine.local
WEBAUTHN_RP_ID=mine.home.arpa
WEBAUTHN_ORIGIN=https://mine.home.arpa
###< web-auth/webauthn-framework ###
###> symfony/messenger ###
# Choose one of the transports below
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###< symfony/messenger ###
+1
View File
@@ -1,3 +1,4 @@
# define your env variables for the test env here
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'
DATABASE_URL="postgresql://system7:bazmeg@127.0.0.1:5452/mineseeker_test?serverVersion=18&charset=utf8"
+50 -2
View File
@@ -33,7 +33,55 @@ jobs:
cd "${{ vars.PROD_APP_DIR }}"
docker compose build
- name: Start services
- name: Migrate legacy MinIO and deploy
env:
APP_VERSION: ${{ vars.APP_VERSION }}
run: |
set -euo pipefail
cd "${{ vars.PROD_APP_DIR }}"
docker compose up -d
MIGRATION_COMPOSE="docker compose --profile storage-migration"
LEGACY_MINIO_CONTAINER=$($MIGRATION_COMPOSE ps -a -q minio)
if [ -n "$LEGACY_MINIO_CONTAINER" ]; then
echo "Legacy MinIO found; pausing writers and migrating its buckets to RustFS."
APP_WAS_RUNNING=$(docker compose ps --status running -q app)
SCHEDULER_WAS_RUNNING=$(docker compose ps --status running -q scheduler)
MIGRATION_SUCCEEDED=0
restart_old_workers_on_failure() {
result=$?
if [ "$result" -ne 0 ] && [ "$MIGRATION_SUCCEEDED" -eq 0 ]; then
# Before a complete copy, restore the old public MinIO endpoint
# before bringing back the old app.
docker compose stop rustfs || true
MINIO_PUBLISHED_API_PORT=9010 MINIO_PUBLISHED_CONSOLE_PORT=9011 $MIGRATION_COMPOSE up -d minio || true
[ -z "$APP_WAS_RUNNING" ] || docker compose start app || true
[ -z "$SCHEDULER_WAS_RUNNING" ] || docker compose start scheduler || true
elif [ "$result" -ne 0 ]; then
echo "Data migration succeeded; leaving RustFS in place for recovery from the app rollout failure." >&2
fi
return "$result"
}
trap restart_old_workers_on_failure EXIT
docker compose stop app scheduler || true
docker compose up -d db mail
# Move the legacy service off the RustFS public port before starting RustFS.
$MIGRATION_COMPOSE up -d minio
docker compose up -d rustfs
$MIGRATION_COMPOSE up --build --abort-on-container-exit --exit-code-from storage_migrate storage_migrate
MIGRATION_SUCCEEDED=1
echo "Migration succeeded; deploying the RustFS-only stack."
docker compose up -d --wait --wait-timeout 180 app scheduler
# Remove only the legacy service containers. Do not use -v: the old
# MinIO volume is retained as a rollback/backup until manually retired.
$MIGRATION_COMPOSE rm -sf minio storage_migrate
trap - EXIT
else
echo "No legacy MinIO container found; deploying the RustFS-only stack."
docker compose up -d
fi
+1
View File
@@ -1,6 +1,7 @@
###> system7 - jotunheimr ###
compose.override.yaml
+bak/
/backups/
.idea/
node_modules/
nohup.out
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "/phpactor.schema.json",
"symfony.enabled": true,
"indexer.exclude_patterns": [
"/vendor/**/Tests/**/*",
"/vendor/**/tests/**/*",
"/var/cache/**/*",
"/vendor/composer/**/*"
]
}
-1
View File
@@ -94,7 +94,6 @@ assets/
│ ├── utils/ # Shared utilities
│ ├── profile.jsx # Profile page entry
│ ├── passkey.jsx # Passkey management entry
│ └── contact.jsx # Contact form entry
├── css/
│ └── homepage/ # SCSS partials (imported by style.homepage.scss)
└── fonts/
+52
View File
@@ -1,6 +1,58 @@
# Changelog
## v2026.3.1-2 (2026-09-21)
### Changes
* Completely disable the contact form #15. [Lang]
## v2026.3.1-0 (2026-08-18)
### Changes
* Add backup/restore scripts #15. [Lang]
* Fix the changing local url names from legacy .local - & change the minio default port #15. [Lang]
## v2026.3.0-0 (2026-07-27)
### New
* Hardening the registration process with removing the not activated registrations #14. [Lang]
### Changes
* Add current version of the app in the footer #14. [Lang]
* Upgrade FE to the latest possible version #14. [Lang]
* All tests passed and migrated to Symfony 8 #14. [Lang]
* Upgrade the BE deps to the latest possible version #14. [Lang]
* Remove registration notification #14. [Lang]
* Add a process that removes the unnecessary minio-init when it does not needed anymore #14. [Lang]
* Removed the obsolete contact form entry - legacy code from Google ReCapthca #14. [Lang]
## v2026.2.10-0 (2026-06-02)
### Changes
* Add clickable badges for Cap app-wide #13. [Lang]
* Upgrade both backend and frontend to the latest available version #13. [Lang]
* Replace Google ReCaptcha with Cap instance #13. [Lang]
* Fix all eslint issues - & add example of the testing env #10. [Lang]
## v2026.2.9-0 (2026-04-23)
### New
+4 -1
View File
@@ -18,7 +18,10 @@
header X-Forwarded-Host {host}
mercure {
transport_url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db}
transport bolt {
path /data/mercure.db
}
protocol_version_compatibility 8
publisher_jwt {$MERCURE_JWT_SECRET} HS256
subscriber_jwt {$MERCURE_JWT_SECRET} HS256
anonymous
+28 -8
View File
@@ -4,8 +4,8 @@
help:
@echo "Available commands:"
@echo " make start - Start services without building (uses existing images)"
@echo " make start-build - Start services and build images if needed"
@echo " make start - Start services (storage migration runs automatically once)"
@echo " make start-build - Build and start services (storage migration runs automatically once)"
@echo " make stop - Stop running services"
@echo " make build - Build Docker images only"
@echo " make down - Stop and remove containers/networks"
@@ -19,21 +19,41 @@ help:
@echo " make test - Run PHPUnit tests"
start:
docker compose up -d
docker compose up -d db mail
@if [ -n "$$(docker compose --profile storage-migration ps -a -q minio)" ]; then \
echo "Legacy MinIO found; migrating its data to RustFS..."; \
docker compose --profile storage-migration up -d minio; \
docker compose up -d rustfs; \
docker compose --profile storage-migration up --abort-on-container-exit --exit-code-from storage_migrate storage_migrate; \
else \
echo "No legacy MinIO container found; starting RustFS only."; \
docker compose up -d rustfs; \
fi
docker compose up -d app scheduler
start-build:
composer i
bun run build
docker compose up -d --build
docker compose up -d --build db mail
@if [ -n "$$(docker compose --profile storage-migration ps -a -q minio)" ]; then \
echo "Legacy MinIO found; migrating its data to RustFS..."; \
docker compose --profile storage-migration up -d minio; \
docker compose up -d rustfs; \
docker compose --profile storage-migration up --build --abort-on-container-exit --exit-code-from storage_migrate storage_migrate; \
else \
echo "No legacy MinIO container found; starting RustFS only."; \
docker compose up -d rustfs; \
fi
docker compose up -d --build app scheduler
stop:
docker compose stop
docker compose --profile storage-migration stop
build:
docker compose build
docker compose --profile storage-migration build
down:
docker compose down
docker compose --profile storage-migration down
prune-everything:
@echo "WARNING: This will remove ALL containers, volumes, networks and images!"
@@ -42,7 +62,7 @@ prune-everything:
echo "Aborted."; \
exit 1; \
fi
docker compose down -v --rmi all --remove-orphans
docker compose --profile storage-migration down -v --rmi all --remove-orphans
mercure-jwt:
@php bin/generate-mercure-jwt.php
+17 -16
View File
@@ -14,7 +14,7 @@ Created by [SplendidBear](https://www.splendidbear.org).
- **Guest & registered play** — jump in anonymously or create an account for stats and history
- **Full authentication stack** — email/password, passkeys (WebAuthn), TOTP 2FA with backup codes
- **Player profiles** — win/loss/draw stats, per-month charts, recent battle history with shareable replay links
- **Profile pictures** — uploaded to MinIO object storage, thumbnails generated on-the-fly by LiipImagine
- **Profile pictures** — uploaded to RustFS object storage, thumbnails generated on-the-fly by LiipImagine
- **Battle replay sharing** — share a direct link to any finished game
- **Docker-ready** — single `make start-build` brings up the full production-like stack
@@ -28,7 +28,7 @@ Created by [SplendidBear](https://www.splendidbear.org).
| Frontend | React 19, Vite, MUI, SCSS |
| Database | PostgreSQL 18 |
| Real-time | Mercure (built into FrankenPHP / Caddy) |
| File storage | MinIO (S3-compatible) |
| File storage | RustFS (S3-compatible; one-time migration from legacy MinIO) |
| Image processing | LiipImagine + Flysystem |
| Server | FrankenPHP (Caddy + PHP in one binary) |
| Auth | Symfony Security, Scheb 2FA, web-auth/webauthn-framework |
@@ -44,7 +44,7 @@ Created by [SplendidBear](https://www.splendidbear.org).
- PostgreSQL 18
- Caddy with FrankenPHP and the Mercure module
- MailHog (or any SMTP server on port 1025)
- MinIO (listening on port 9000)
- RustFS (API on port 9010, console on port 9011); legacy MinIO source on 9012/9013 during migration
### Docker
- Docker Engine 24+
@@ -75,10 +75,11 @@ Edit `.env` and fill in every value. Key ones:
|---|---|
| `APP_SECRET` | Random 32-byte hex: `openssl rand -hex 32` |
| `POSTGRES_USER/PASSWORD/DB` | Your PostgreSQL credentials |
| `MINIO_ROOT_USER/PASSWORD` | MinIO admin credentials |
| `MINIO_ENDPOINT` | `http://localhost:9000` (bare-metal) |
| `MINIO_PUBLIC_URL` | Public URL browsers use to reach MinIO |
| `RECAPTCHA_SITE_KEY/SECRET_KEY` | Google reCAPTCHA v3 keys for your domain |
| `MINIO_ROOT_USER/PASSWORD` | S3 storage credentials (used by RustFS and legacy MinIO during migration) |
| `MINIO_ENDPOINT` | `http://localhost:9010` (bare-metal) |
| `MINIO_PUBLIC_URL` | Public URL browsers use to reach RustFS (including media and LiipImagine thumbnails) |
| `CAP_API_ENDPOINT` | Full URL of your Cap instance including the site-key path (e.g. `https://cap.example.com/my-site-key/`) |
| `CAP_SECRET_KEY` | Secret key from your Cap site dashboard (stored in `PROD_ENV_FILE` Gitea secret on prod) |
| `MERCURE_JWT_SECRET` | Random secret (generated in step 3) |
| `MERCURE_JWT_TOKEN` | Signed publisher JWT (generated in step 3) |
| `MERCURE_SUBSCRIBER_JWT` | Signed subscriber JWT (generated in step 3) |
@@ -103,7 +104,7 @@ sudo systemctl reload caddy
make start-build
```
This installs PHP dependencies, builds the frontend assets with Bun, builds the Docker image, and starts all services (`app`, `db`, `mail`, `minio`, `minio_init`).
This installs PHP dependencies, builds the frontend assets with Bun, builds the Docker images, and starts all services. RustFS migration runs automatically once before the app and scheduler start.
The app is available at `http://localhost:10080` (or the domain set in `APP_PUBLIC_HOSTNAME`).
@@ -118,16 +119,16 @@ bun run dev # Vite dev server with hot-reload
php bin/console doctrine:migrations:migrate --no-interaction
```
Start MinIO and MailHog, then open the URL configured in your Caddy vhost.
Start RustFS and MailHog, then open the URL configured in your Caddy vhost.
### 5. MinIO bucket setup
### 5. Object storage setup
**Docker** — handled automatically on first start by the `minio_init` service. No action needed.
**Docker** — `make start` and `make start-build` run the one-shot migration before starting the app. Production releases pause writers, migrate and verify all MinIO buckets, start the RustFS-backed app, then remove the MinIO container while retaining its volume. See [Object storage migration](docs/MINIO_INITIALIZATION.md).
**Bare-metal** — run once after MinIO is up:
```bash
mc alias set local http://localhost:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD
mc alias set local http://localhost:9010 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD
mc mb local/mineseeker
echo '' | mc pipe local/mineseeker/media/.keep
echo '' | mc pipe local/mineseeker/cache/.keep
@@ -216,14 +217,14 @@ POSTGRES_VERSION=18
MINIO_ROOT_USER=mineseeker
MINIO_ROOT_PASSWORD="<strong password>"
MINIO_ENDPOINT=http://minio:9000
MINIO_ENDPOINT=http://rustfs:9000
MINIO_PUBLIC_URL=https://aws.mineseeker.hu
MAILER_DSN=smtp://mail:25?verify_peer=0
MAIL_DOMAIN=mineseeker.hu
RECAPTCHA_SITE_KEY="<your reCAPTCHA v3 site key>"
RECAPTCHA_SECRET_KEY="<your reCAPTCHA v3 secret key>"
CAP_API_ENDPOINT="https://cap.example.com/your-site-key/"
CAP_SECRET_KEY="<secret key from your Cap site dashboard>"
MERCURE_URL=https://mineseeker.hu/.well-known/mercure
MERCURE_PUBLIC_URL=https://mineseeker.hu/.well-known/mercure
@@ -269,7 +270,7 @@ git tag v2026.01
git push origin v2026.01
```
This writes `.env`, builds the Docker image, starts all services, runs migrations, and initialises the MinIO buckets automatically via `minio_init`.
This writes `.env`, builds the Docker images, runs database migrations, and—if the legacy MinIO container exists—pauses app writers, copies and verifies all its buckets in RustFS, then removes the MinIO container after the app is running on RustFS. The MinIO data volume is retained.
#### 4. Verify
+60 -1
View File
@@ -75,7 +75,7 @@
border-radius: 10px;
padding: 44px 48px 40px;
width: 100%;
max-width: 420px;
max-width: 520px;
backdrop-filter: blur(4px);
box-shadow: 0 8px 48px rgba(0, 0, 0, 0.5);
}
@@ -87,6 +87,65 @@
margin-bottom: 6px;
}
.auth-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
.auth-title {
margin-bottom: 0;
}
}
.auth-cap-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid rgba(149, 207, 245, 0.35);
background:
linear-gradient(115deg, transparent 0 38%, rgba(255, 255, 255, 0.18) 48%, transparent 58% 100%),
rgba(35, 111, 135, 0.2);
background-position: 120% 50%, 0 0;
background-size: 260% 100%, auto;
color: #95cff5;
font: 600 11px 'Rajdhani', sans-serif;
letter-spacing: 0.8px;
text-decoration: none;
text-transform: uppercase;
transition:
background-position 650ms ease,
border-color 200ms ease,
box-shadow 200ms ease,
color 200ms ease,
transform 200ms ease;
white-space: nowrap;
&:hover,
&:focus-visible {
background-position: -60% 50%, 0 0;
border-color: rgba(149, 207, 245, 0.65);
box-shadow: 0 0 18px rgba(35, 111, 135, 0.28);
color: #d5f1ff;
transform: translateY(-1px);
}
&:hover,
&:focus,
&:active,
&:visited {
text-decoration: none;
}
&:focus-visible {
outline: 2px solid rgba(149, 207, 245, 0.75);
outline-offset: 2px;
}
}
.auth-sub {
font: 400 14px 'Rajdhani', sans-serif;
color: rgba(149, 207, 245, 0.6);
+23
View File
@@ -119,3 +119,26 @@ footer {
&:hover { color: #95cff5; }
}
}
.footer-version {
display: inline-flex;
align-items: center;
gap: 5px;
margin: 0 7px;
padding: 3px 8px 2px;
border: 1px solid rgba(149, 207, 245, 0.24);
border-radius: 999px;
background: linear-gradient(135deg, rgba(35, 111, 135, 0.22), rgba(149, 207, 245, 0.08));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 0 12px rgba(35, 111, 135, 0.12);
color: rgba(180, 224, 248, 0.9);
font: 700 10px 'Rajdhani', sans-serif;
letter-spacing: 1px;
line-height: 1;
text-transform: uppercase;
vertical-align: middle;
i {
color: #95cff5;
font-size: 9px;
}
}
+75 -3
View File
@@ -7,12 +7,17 @@
* file that was distributed with this source code.
*/
.back-from-game {
display: inline-block;
.game-brand-bar {
display: flex;
align-items: center;
position: fixed;
top: 20px;
left: 20px;
z-index: 20;
}
.back-from-game {
display: inline-block;
-ms-transform: scale(1);
-webkit-transform: scale(1);
transform: scale(1);
@@ -30,4 +35,71 @@
transform: scale(1.2);
-webkit-transition: all 250ms cubic-bezier(.17, .67, .83, .67);
transition: all 250ms cubic-bezier(.17, .67, .83, .67);
}
}
.game-cap-badge {
display: inline-flex;
align-items: center;
gap: 6px;
position: fixed;
bottom: 20px;
left: 50%;
z-index: 20;
padding: 5px 10px;
border: 1px solid rgba(255, 216, 92, 0.42);
-webkit-border-radius: 999px;
border-radius: 999px;
background:
linear-gradient(115deg, transparent 0 38%, rgba(255, 255, 255, 0.22) 48%, transparent 58% 100%),
rgba(14, 16, 18, 0.72);
background-position: 120% 50%, 0 0;
background-size: 260% 100%, auto;
-webkit-box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.32), 0 8px 22px rgba(0, 0, 0, 0.28);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.32), 0 8px 22px rgba(0, 0, 0, 0.28);
color: #ffe58a;
font: 700 11px 'Rajdhani', sans-serif;
letter-spacing: 0.9px;
line-height: 1;
text-decoration: none;
text-transform: uppercase;
-ms-transform: translateX(-50%);
-webkit-transform: translateX(-50%);
transform: translateX(-50%);
white-space: nowrap;
-webkit-transition:
background-position 650ms ease,
border-color 200ms ease,
box-shadow 200ms ease,
color 200ms ease,
transform 200ms ease;
transition:
background-position 650ms ease,
border-color 200ms ease,
box-shadow 200ms ease,
color 200ms ease,
transform 200ms ease;
}
.game-cap-badge:hover,
.game-cap-badge:focus-visible {
background-position: -60% 50%, 0 0;
border-color: rgba(255, 229, 138, 0.78);
-webkit-box-shadow: 0 0 0 1px rgba(255, 229, 138, 0.18), 0 0 24px rgba(255, 195, 50, 0.28);
box-shadow: 0 0 0 1px rgba(255, 229, 138, 0.18), 0 0 24px rgba(255, 195, 50, 0.28);
color: #fff4bd;
text-decoration: none;
-ms-transform: translateX(-50%) translateY(-1px);
-webkit-transform: translateX(-50%) translateY(-1px);
transform: translateX(-50%) translateY(-1px);
}
.game-cap-badge:focus,
.game-cap-badge:active,
.game-cap-badge:visited {
text-decoration: none;
}
.game-cap-badge:focus-visible {
outline: 2px solid rgba(255, 229, 138, 0.75);
outline-offset: 2px;
}
+87
View File
@@ -0,0 +1,87 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import 'cap-widget';
const FORMS_SELECTOR = '.auth-form';
const SUBMIT_SELECTOR = 'button[type="submit"], input[type="submit"]';
const createInvisibleCap = apiEndpoint => {
const host = document.createElement('cap-widget');
host.style.display = 'none';
const cap = new window.Cap({ apiEndpoint }, host);
return { cap, host };
};
const lockSubmit = (form, locked) => {
const submit = form.querySelector(SUBMIT_SELECTOR);
if (!submit) return;
submit.disabled = locked;
};
const bindInvisibleCap = (form, apiEndpoint) => {
const { cap, host } = createInvisibleCap(apiEndpoint);
form.appendChild(host);
let solving = null;
const solveToken = async () => {
if (!solving) {
solving = cap.solve()
.finally(() => {
solving = null;
});
}
const result = await solving;
return result?.token ?? cap.token ?? '';
};
lockSubmit(form, true);
solveToken()
.catch(() => '')
.finally(() => lockSubmit(form, false));
form.addEventListener('submit', async e => {
if (cap.token) {
return;
}
e.preventDefault();
lockSubmit(form, true);
try {
const token = await solveToken();
if (!token) {
lockSubmit(form, false);
return;
}
form.requestSubmit();
} catch (_) {
lockSubmit(form, false);
}
});
};
const initInvisibleCap = () => {
const endpointSource = document.querySelector('[data-cap-api-endpoint]')
|| document.getElementById('mine-wrapper');
const apiEndpoint = endpointSource?.dataset.capApiEndpoint;
if ('function' !== typeof window.Cap || !apiEndpoint) {
return;
}
document.querySelectorAll(FORMS_SELECTOR).forEach(form => bindInvisibleCap(form, apiEndpoint));
};
initInvisibleCap();
-89
View File
@@ -1,89 +0,0 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import { useEffect, useRef } from 'react';
import { string } from 'prop-types';
/**
* ContactForm Component
*
* Handles reCAPTCHA v3 integration for the contact form.
* Intercepts form submission, executes reCAPTCHA, and submits the form with the token.
*
* @param {string} siteKey - Google reCAPTCHA site key
* @param {string} recaptchaFieldId - ID of the hidden recaptcha input field
*/
const ContactForm = ({ siteKey, recaptchaFieldId }) => {
const formRef = useRef(null);
const isSubmittingRef = useRef(false);
useEffect(() => {
const form = document.querySelector('.auth-form');
if (!form) {
console.warn('ContactForm: No .auth-form found');
return;
}
formRef.current = form;
const handleSubmit = e => {
e.preventDefault();
if (isSubmittingRef.current) {
return;
}
isSubmittingRef.current = true;
if ('undefined' !== typeof grecaptcha) {
grecaptcha.ready(() => {
grecaptcha
.execute(siteKey, { action: 'contact' })
.then(token => {
const recaptchaField = document.getElementById(recaptchaFieldId);
if (recaptchaField) {
recaptchaField.value = token;
} else {
console.error(`ContactForm: Recaptcha field with ID "${recaptchaFieldId}" not found`);
}
isSubmittingRef.current = false;
form.submit();
})
.catch(error => {
console.error('ContactForm: reCAPTCHA execution failed', error);
isSubmittingRef.current = false;
});
});
} else {
console.error('ContactForm: grecaptcha is not loaded');
isSubmittingRef.current = false;
}
};
form.addEventListener('submit', handleSubmit);
return () => {
if (formRef.current) {
formRef.current.removeEventListener('submit', handleSubmit);
}
};
}, [siteKey, recaptchaFieldId]);
return null;
};
ContactForm.propTypes = {
siteKey: string.isRequired,
recaptchaFieldId: string.isRequired,
};
export default ContactForm;
-1
View File
@@ -9,7 +9,6 @@
export { AvatarUpload } from './AvatarUpload';
export { BattleDialog } from './BattleDialog';
export { default as ContactForm } from './ContactForm';
export { default as PasskeyLogin } from './PasskeyLogin';
export { default as PasskeyManager } from './PasskeyManager';
export { default as ProfileCharts } from './ProfileCharts';
-30
View File
@@ -1,30 +0,0 @@
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import React from 'react';
import { createRoot } from 'react-dom/client';
import { ContactForm } from '@global-components';
const wrapper = document.getElementById('contact-form-wrapper');
if (wrapper) {
const siteKey = wrapper.dataset.siteKey;
const recaptchaFieldId = wrapper.dataset.recaptchaFieldId;
if (siteKey && recaptchaFieldId) {
createRoot(wrapper).render(
<ContactForm
siteKey={siteKey}
recaptchaFieldId={recaptchaFieldId}
/>
);
} else {
console.error('ContactForm: Missing siteKey or recaptchaFieldId in data attributes');
}
}
@@ -7,17 +7,16 @@
* file that was distributed with this source code.
*/
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import 'cap-widget';
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react';
import { func, node, string } from 'prop-types';
const CAPTCHA_STORAGE_KEY = 'mineseeker_captcha_verified';
const CAPTCHA_TOKEN_KEY = 'mineseeker_captcha_token';
const RECAPTCHA_ACTION = 'mineseeker_play';
const CaptchaOverlay = ({ siteKey, onVerified, children }) => {
const CaptchaOverlay = ({ apiEndpoint, onVerified, children }) => {
const [verified, setVerified] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const capRef = useRef(null);
const handleToken = useCallback(token => {
const wrapper = document.getElementById('mine-wrapper');
@@ -30,12 +29,6 @@ const CaptchaOverlay = ({ siteKey, onVerified, children }) => {
onVerified?.();
}, [onVerified]);
const buttonClasses = useMemo(() => [
'captcha-button',
error && 'captcha-button--error',
loading && 'captcha-button--loading',
].filter(Boolean).join(' '), [error, loading]);
useEffect(() => {
const storedToken = sessionStorage.getItem(CAPTCHA_TOKEN_KEY);
const storedTime = sessionStorage.getItem(CAPTCHA_STORAGE_KEY);
@@ -52,40 +45,42 @@ const CaptchaOverlay = ({ siteKey, onVerified, children }) => {
return;
}
}
}, [onVerified]);
if (window.grecaptcha) {
window.grecaptcha.ready(() => {
window.grecaptcha
.execute(siteKey, { action: RECAPTCHA_ACTION })
.then(token => {
handleToken(token);
})
.catch(() => {
setError(true);
});
});
}
}, [siteKey, onVerified, handleToken]);
useEffect(() => {
const widget = document.createElement('cap-widget');
widget.style.display = 'none';
capRef.current = widget;
document.body.appendChild(widget);
const cap = new window.Cap({ apiEndpoint }, widget);
let cancelled = false;
const handleClick = () => {
setLoading(true);
setError(false);
const run = async () => {
try {
const result = await cap.solve();
if (!cancelled && result?.token) {
handleToken(result.token);
}
} catch (_) {
if (!cancelled) {
setTimeout(() => {
if (!cancelled) {
run();
}
}, 1200);
}
}
};
window.grecaptcha.ready(() => {
window.grecaptcha
.execute(siteKey, { action: RECAPTCHA_ACTION })
.then(token => {
handleToken(token);
setLoading(false);
})
.catch(() => {
setLoading(false);
setError(true);
setTimeout(() => setError(false), 2000);
});
});
};
run();
return () => {
cancelled = true;
widget.remove();
capRef.current = null;
};
}, [apiEndpoint, handleToken]);
if (verified) {
return <Fragment>{children}</Fragment>;
@@ -99,16 +94,8 @@ const CaptchaOverlay = ({ siteKey, onVerified, children }) => {
</div>
<h1 className="captcha-title">Ready to Play?</h1>
<p className="captcha-description">
Click below to verify you&apos;re human and start playing.
Verifying your session...
</p>
<button
className={buttonClasses}
onClick={handleClick}
disabled={loading}
>
<i className={`fa ${loading ? 'fa-spinner fa-spin' : error ? 'fa-exclamation-circle' : 'fa-play'}`} />
{loading ? 'Verifying...' : error ? 'Try Again' : 'Start Playing'}
</button>
</div>
</div>
);
@@ -117,7 +104,7 @@ const CaptchaOverlay = ({ siteKey, onVerified, children }) => {
export default CaptchaOverlay;
CaptchaOverlay.propTypes = {
siteKey: string.isRequired,
onVerified: func,
children: node,
apiEndpoint: string.isRequired,
onVerified: func,
children: node,
};
@@ -19,7 +19,7 @@ export const GameBoard = ({ gameAssoc, gameInherited, opponentName = '', isEnvDe
const { onClick, resign } = useServerCommunication(gameAssoc, gameInherited, opponentName, isEnvDev);
const [captchaVerified, setCaptchaVerified] = useState(false);
const siteKey = document.getElementById('mine-wrapper')?.dataset.recaptchaSiteKey;
const apiEndpoint = document.getElementById('mine-wrapper')?.dataset.capApiEndpoint;
if (!gridReady) {
return (
@@ -29,9 +29,9 @@ export const GameBoard = ({ gameAssoc, gameInherited, opponentName = '', isEnvDe
);
}
if (!captchaVerified && siteKey) {
if (!captchaVerified && apiEndpoint) {
return (
<CaptchaOverlay siteKey={siteKey} onVerified={() => setCaptchaVerified(true)} />
<CaptchaOverlay apiEndpoint={apiEndpoint} onVerified={() => setCaptchaVerified(true)} />
);
}
+42
View File
@@ -0,0 +1,42 @@
#!/bin/sh
set -eu
PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$PROJECT_DIR"
CONTAINER_ID=$(docker compose ps -q minio)
if [ -z "$CONTAINER_ID" ]; then
echo "Error: The MineSeeker MinIO service is not running."
exit 1
fi
VOLUME_NAME=$(docker inspect "$CONTAINER_ID" --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Name}}{{end}}{{end}}')
if [ -z "$VOLUME_NAME" ]; then
echo "Error: Could not determine the MinIO data volume."
exit 1
fi
BACKUP_DIR=${1:-"$PROJECT_DIR/backups"}
mkdir -p "$BACKUP_DIR"
BACKUP_DIR=$(CDPATH= cd -- "$BACKUP_DIR" && pwd)
TIMESTAMP=$(date +%Y-%m-%d.%H%M%S)
BACKUP_FILE="mineseeker-minio.${TIMESTAMP}.tar.gz"
BACKUP_PATH="$BACKUP_DIR/$BACKUP_FILE"
umask 077
restart_minio() {
docker compose start minio || echo "Warning: Failed to restart MinIO."
}
trap restart_minio EXIT
echo "Stopping MinIO for a consistent backup..."
docker compose stop minio
echo "Creating MinIO backup: $BACKUP_PATH"
docker run --rm \
--volume "$VOLUME_NAME:/data:ro" \
--volume "$BACKUP_DIR:/backup" \
busybox tar czf "/backup/$BACKUP_FILE" -C /data .
echo "Backup complete: $BACKUP_PATH"
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
set -eu
PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$PROJECT_DIR"
CONTAINER_ID=$(docker compose ps -q db)
if [ -z "$CONTAINER_ID" ]; then
echo "Error: The MineSeeker PostgreSQL service is not running."
exit 1
fi
DB_USER=$(docker inspect "$CONTAINER_ID" --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^POSTGRES_USER=//p')
DB_NAME=$(docker inspect "$CONTAINER_ID" --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^POSTGRES_DB=//p')
if [ -z "$DB_USER" ] || [ -z "$DB_NAME" ]; then
echo "Error: Could not read PostgreSQL credentials from the db service."
exit 1
fi
BACKUP_DIR=${1:-"$PROJECT_DIR/backups"}
mkdir -p "$BACKUP_DIR"
BACKUP_DIR=$(CDPATH= cd -- "$BACKUP_DIR" && pwd)
TIMESTAMP=$(date +%Y-%m-%d.%H%M%S)
BACKUP_FILE="mineseeker-postgres.${TIMESTAMP}.dump"
BACKUP_PATH="$BACKUP_DIR/$BACKUP_FILE"
TEMP_PATH="$BACKUP_PATH.tmp"
umask 077
trap 'rm -f "$TEMP_PATH"' EXIT
echo "Creating PostgreSQL backup: $BACKUP_PATH"
docker exec "$CONTAINER_ID" pg_dump \
--username="$DB_USER" \
--format=custom \
--create \
"$DB_NAME" > "$TEMP_PATH"
mv "$TEMP_PATH" "$BACKUP_PATH"
trap - EXIT
echo "Backup complete: $BACKUP_PATH"
+61
View File
@@ -0,0 +1,61 @@
#!/bin/sh
set -eu
PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$PROJECT_DIR"
if [ "$#" -ne 1 ]; then
echo "Usage: $0 <backup-file>"
echo "Example: $0 backups/mineseeker-minio.2026-08-18.120000.tar.gz"
exit 1
fi
BACKUP_FILE=$1
if [ ! -f "$BACKUP_FILE" ]; then
echo "Error: Backup file not found: $BACKUP_FILE"
exit 1
fi
BACKUP_DIR=$(CDPATH= cd -- "$(dirname -- "$BACKUP_FILE")" && pwd)
BACKUP_NAME=$(basename "$BACKUP_FILE")
CONTAINER_ID=$(docker compose ps -q minio)
if [ -z "$CONTAINER_ID" ]; then
echo "Error: The MineSeeker MinIO service is not running."
exit 1
fi
VOLUME_NAME=$(docker inspect "$CONTAINER_ID" --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Name}}{{end}}{{end}}')
if [ -z "$VOLUME_NAME" ]; then
echo "Error: Could not determine the MinIO data volume."
exit 1
fi
echo "Verifying MinIO archive..."
docker run --rm \
--volume "$BACKUP_DIR:/backup:ro" \
busybox tar -tzf "/backup/$BACKUP_NAME" > /dev/null
echo "Archive OK."
printf 'WARNING: This erases all current MineSeeker MinIO data. Type yes to continue: '
read -r CONFIRM
if [ "$CONFIRM" != 'yes' ]; then
echo "Aborted."
exit 1
fi
restart_minio() {
docker compose start minio || echo "Warning: Failed to restart MinIO."
}
trap restart_minio EXIT
echo "Stopping MinIO before restore..."
docker compose stop minio
echo "Restoring MinIO backup: $BACKUP_FILE"
docker run --rm \
--volume "$VOLUME_NAME:/data" \
--volume "$BACKUP_DIR:/backup:ro" \
busybox sh -c 'find /data -mindepth 1 -maxdepth 1 -exec rm -rf {} + && tar xzf "/backup/$1" -C /data' sh "$BACKUP_NAME"
echo "Restore complete."
+84
View File
@@ -0,0 +1,84 @@
#!/bin/sh
set -eu
PROJECT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$PROJECT_DIR"
if [ "$#" -ne 1 ]; then
echo "Usage: $0 <backup-file>"
echo "Example: $0 backups/mineseeker-postgres.2026-08-18.120000.dump"
exit 1
fi
BACKUP_FILE=$1
if [ ! -f "$BACKUP_FILE" ]; then
echo "Error: Backup file not found: $BACKUP_FILE"
exit 1
fi
CONTAINER_ID=$(docker compose ps -q db)
if [ -z "$CONTAINER_ID" ]; then
echo "Error: The MineSeeker PostgreSQL service is not running."
exit 1
fi
DB_USER=$(docker inspect "$CONTAINER_ID" --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^POSTGRES_USER=//p')
if [ -z "$DB_USER" ]; then
echo "Error: Could not read POSTGRES_USER from the db service."
exit 1
fi
DB_NAME=$(docker inspect "$CONTAINER_ID" --format '{{range .Config.Env}}{{println .}}{{end}}' | sed -n 's/^POSTGRES_DB=//p')
if [ -z "$DB_NAME" ]; then
echo "Error: Could not read POSTGRES_DB from the db service."
exit 1
fi
echo "Verifying PostgreSQL archive..."
docker exec -i "$CONTAINER_ID" pg_restore --list < "$BACKUP_FILE" > /dev/null
echo "Archive OK."
printf 'WARNING: This replaces the current MineSeeker database. Type yes to continue: '
read -r CONFIRM
if [ "$CONFIRM" != 'yes' ]; then
echo "Aborted."
exit 1
fi
APP_WAS_RUNNING=0
SCHEDULER_WAS_RUNNING=0
[ -n "$(docker compose ps -q app)" ] && APP_WAS_RUNNING=1
[ -n "$(docker compose ps -q scheduler)" ] && SCHEDULER_WAS_RUNNING=1
restart_services() {
[ "$APP_WAS_RUNNING" -eq 1 ] && docker compose start app || true
[ "$SCHEDULER_WAS_RUNNING" -eq 1 ] && docker compose start scheduler || true
}
trap restart_services EXIT
echo "Stopping MineSeeker services that use PostgreSQL..."
docker compose stop app scheduler
echo "Dropping configured database '$DB_NAME'..."
docker exec "$CONTAINER_ID" dropdb \
--username="$DB_USER" \
--if-exists \
--force \
"$DB_NAME"
echo "Creating '$DB_NAME' owned by '$DB_USER'..."
docker exec "$CONTAINER_ID" createdb \
--username="$DB_USER" \
--owner="$DB_USER" \
--template=template0 \
"$DB_NAME"
echo "Restoring PostgreSQL backup without source ownership/ACLs: $BACKUP_FILE"
docker exec -i "$CONTAINER_ID" pg_restore \
--username="$DB_USER" \
--dbname="$DB_NAME" \
--no-owner \
--no-acl \
--exit-on-error < "$BACKUP_FILE"
echo "Restore complete."
+116 -95
View File
@@ -7,30 +7,31 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/changa-one": "^5.2.8",
"@fontsource/open-sans": "^5.2.7",
"@fontsource/rajdhani": "^5.2.7",
"@fortawesome/fontawesome-free": "^7.2.0",
"@mui/material": "^9.0.0",
"@mui/x-charts": "^9.0.2",
"@tanstack/react-query": "^5.99.2",
"@fontsource/changa-one": "^5.3.0",
"@fontsource/open-sans": "^5.3.0",
"@fontsource/rajdhani": "^5.3.0",
"@fortawesome/fontawesome-free": "^7.3.1",
"@mui/material": "^9.2.0",
"@mui/x-charts": "^9.10.1",
"@tanstack/react-query": "^5.101.4",
"cap-widget": "^0.1.56",
"howler": "^2.2.4",
"lodash": "^4.18.1",
"prop-types": "^15.8.1",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react": "^19.2.8",
"react-dom": "^19.2.8",
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@eslint/eslintrc": "^3.3.6",
"@eslint/js": "10.0.1",
"@stylistic/eslint-plugin": "5.10.0",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "10.2.1",
"@vitejs/plugin-react": "^6.0.4",
"eslint": "^10.8.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "7.1.1",
"globals": "17.5.0",
"sass": "^1.99.0",
"vite": "^8.0.8",
"globals": "^17.8.0",
"sass": "^1.102.0",
"vite": "^8.1.5",
"vite-plugin-symfony": "^8.2.4",
},
},
@@ -70,11 +71,7 @@
"@babel/types": ["@babel/types@7.29.0", "http://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@emnapi/core": ["@emnapi/core@1.9.2", "http://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "http://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "http://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@base-ui/utils": ["@base-ui/utils@0.3.1", "http://registry.npmjs.org/@base-ui/utils/-/utils-0.3.1.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg=="],
"@emotion/babel-plugin": ["@emotion/babel-plugin@11.13.5", "http://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", { "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/serialize": "^1.3.3", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", "find-root": "^1.1.0", "source-map": "^0.5.7", "stylis": "4.2.0" } }, "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ=="],
@@ -108,25 +105,27 @@
"@eslint/config-array": ["@eslint/config-array@0.23.5", "http://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "http://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.7.0", "http://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw=="],
"@eslint/core": ["@eslint/core@1.2.1", "http://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "http://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="],
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.6", "http://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.3.0", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA=="],
"@eslint/js": ["@eslint/js@10.0.1", "http://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="],
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "http://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "http://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "http://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="],
"@fontsource/changa-one": ["@fontsource/changa-one@5.2.8", "http://registry.npmjs.org/@fontsource/changa-one/-/changa-one-5.2.8.tgz", {}, "sha512-gagiU8sMWLs9ejh41NmrYlGdgasSYWFegz5/+22WqYdJVS9HZbaUEXj/6jj3ZKgi+dQZT0kYI+Nha2UQGbO/mA=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.12", "http://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
"@fontsource/open-sans": ["@fontsource/open-sans@5.2.7", "http://registry.npmjs.org/@fontsource/open-sans/-/open-sans-5.2.7.tgz", {}, "sha512-8yfgDYjE5O0vmTPdrcjV35y4yMnctsokmi9gN49Gcsr0sjzkMkR97AnKDe6OqZh2SFkYlR28fxOvi21bYEgMSw=="],
"@fontsource/changa-one": ["@fontsource/changa-one@5.3.0", "http://registry.npmjs.org/@fontsource/changa-one/-/changa-one-5.3.0.tgz", {}, "sha512-ACriykYOe12aupip0QfAaNo9/XNEt0WD1U1CiL/ZUGnrzIXFizJEkUzjnXWg+M4GXkY4n0o/W6TUtcNqMn0GrA=="],
"@fontsource/rajdhani": ["@fontsource/rajdhani@5.2.7", "http://registry.npmjs.org/@fontsource/rajdhani/-/rajdhani-5.2.7.tgz", {}, "sha512-7Gy10U688fCdeFfYKebUF2TZotdgH/ghKyMsseXPmB60lpaUHC8aoCSJl5/OpZ+KHKSU2TqBfKfteVkcIXxTAQ=="],
"@fontsource/open-sans": ["@fontsource/open-sans@5.3.0", "http://registry.npmjs.org/@fontsource/open-sans/-/open-sans-5.3.0.tgz", {}, "sha512-V1bdMlGNyZrJwzrdusshMOw2YR0aqYDL7kOo9dkxUEKl8dFv66A9HLckOOXl572sG/7ohhDQrCrTFGSn9LZcSw=="],
"@fortawesome/fontawesome-free": ["@fortawesome/fontawesome-free@7.2.0", "http://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.2.0.tgz", {}, "sha512-3DguDv/oUE+7vjMeTSOjCSG+KeawgVQOHrKRnvUuqYh1mfArrh7s+s8hXW3e4RerBA1+Wh+hBqf8sJNpqNrBWg=="],
"@fontsource/rajdhani": ["@fontsource/rajdhani@5.3.0", "http://registry.npmjs.org/@fontsource/rajdhani/-/rajdhani-5.3.0.tgz", {}, "sha512-oBJgSIibWJvVBKseme1JczqPLoDHN97LHti4uh0LjfcJqQoVmnQQlRKIpEjTJV559rmVQ6qbVZZwKwk2wkBTtw=="],
"@fortawesome/fontawesome-free": ["@fortawesome/fontawesome-free@7.3.1", "http://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.3.1.tgz", {}, "sha512-wmglKKPDIkgV3aWlZzWECCPoGIkYCulzBwxG9+w7rc5BGapZ6cPMpoPOT8k36J0Ni7PPX6c/rsoMWfS4d1MUMg=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "http://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
@@ -146,29 +145,27 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "http://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@mui/core-downloads-tracker": ["@mui/core-downloads-tracker@9.0.0", "http://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.0.0.tgz", {}, "sha512-uwQNGkhv0lf7ufxw6QXev77BW6pWbW+7uxYjU5+rfp4lBkFtMEgJCsarTM3Tn+i0lGx6+Ol2u88JdGXr0GDskA=="],
"@mui/core-downloads-tracker": ["@mui/core-downloads-tracker@9.3.1", "", {}, "sha512-IAyAFNQbT7hysJ9HXphiOmWJF7G1OglzHanqCgvQgH9LA2ydxtmaTBDbcBqw6euZesyShiwvpvbnYO1GY1AyXQ=="],
"@mui/material": ["@mui/material@9.0.0", "http://registry.npmjs.org/@mui/material/-/material-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@mui/core-downloads-tracker": "^9.0.0", "@mui/system": "^9.0.0", "@mui/types": "^9.0.0", "@mui/utils": "^9.0.0", "@popperjs/core": "^2.11.8", "@types/react-transition-group": "^4.4.12", "clsx": "^2.1.1", "csstype": "^3.2.3", "prop-types": "^15.8.1", "react-is": "^19.2.4", "react-transition-group": "^4.4.5" }, "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", "@mui/material-pigment-css": "^9.0.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled", "@mui/material-pigment-css", "@types/react"] }, "sha512-+VP/oQCDhDR87NQQgXnNBG8dwy6GNuQLnenS1pZvkbn2dKFSxRSRMybTpH9xUxXP+316mlYDy5CSbYtusnCWtw=="],
"@mui/material": ["@mui/material@9.3.1", "", { "dependencies": { "@babel/runtime": "^7.29.7", "@mui/core-downloads-tracker": "^9.3.1", "@mui/system": "^9.3.0", "@mui/types": "^9.3.0", "@mui/utils": "^9.3.0", "@popperjs/core": "^2.11.8", "@types/react-transition-group": "^4.4.12", "clsx": "^2.1.1", "csstype": "^3.2.3", "prop-types": "^15.8.1", "react-is": "^19.2.8", "react-transition-group": "^4.4.5" }, "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", "@mui/material-pigment-css": "^9.3.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled", "@mui/material-pigment-css", "@types/react"] }, "sha512-NahAEGIXqS1K0bA4th1jeFxBguS59NOcLbMA0vU+fSaPWKjtwGBGGHeTwlc9PSmzMjOZKceeFWESB8fVHr31hA=="],
"@mui/private-theming": ["@mui/private-theming@9.0.0", "http://registry.npmjs.org/@mui/private-theming/-/private-theming-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@mui/utils": "^9.0.0", "prop-types": "^15.8.1" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-JtuZoaiCqwD6vjgYu6Xp3T7DZkrxJlgtDz5yESzhI34fEX5hHMh2VJUbuL9UOg8xrfIFMrq6dcYoH/7Zi4G0RA=="],
"@mui/private-theming": ["@mui/private-theming@9.3.0", "", { "dependencies": { "@babel/runtime": "^7.29.7", "@mui/utils": "^9.3.0", "prop-types": "^15.8.1" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-ERvqk5pejf9aRnQcDILSWGtFmsEMiVxlQ4+xsVCjsEvmK0fV9BiVP/cQwAF5dwyDFbve4lTrlcgeFEecVzTNiA=="],
"@mui/styled-engine": ["@mui/styled-engine@9.0.0", "http://registry.npmjs.org/@mui/styled-engine/-/styled-engine-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@emotion/cache": "^11.14.0", "@emotion/serialize": "^1.3.3", "@emotion/sheet": "^1.4.0", "csstype": "^3.2.3", "prop-types": "^15.8.1" }, "peerDependencies": { "@emotion/react": "^11.4.1", "@emotion/styled": "^11.3.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled"] }, "sha512-9RLGdX4Jg0aQPRuvqh/OLzYSPlgd5zyEw5/1HIRfdavSiOd03WtUaGZH9/w1RoTYuRKwpgy0hpIFaMHIqPVIWg=="],
"@mui/styled-engine": ["@mui/styled-engine@9.3.0", "", { "dependencies": { "@babel/runtime": "^7.29.7", "@emotion/cache": "^11.14.0", "@emotion/serialize": "^1.3.3", "@emotion/sheet": "^1.4.0", "csstype": "^3.2.3", "prop-types": "^15.8.1" }, "peerDependencies": { "@emotion/react": "^11.4.1", "@emotion/styled": "^11.3.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled"] }, "sha512-x9+KYxhjoHYZ4nioxdKnvQWdw0RScbhoZfQ4tv3Db742683U3wlYSp6zV6Us78dMFnKnyTrPTkQKyutp14gnKA=="],
"@mui/system": ["@mui/system@9.0.0", "http://registry.npmjs.org/@mui/system/-/system-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@mui/private-theming": "^9.0.0", "@mui/styled-engine": "^9.0.0", "@mui/types": "^9.0.0", "@mui/utils": "^9.0.0", "clsx": "^2.1.1", "csstype": "^3.2.3", "prop-types": "^15.8.1" }, "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled", "@types/react"] }, "sha512-YnC5Zg6j04IxiLc/boAKs0464jfZlLFVa7mf5E8lF0XOtZVUvG6R6gJK50lgUYdaaLdyLfxF6xR7LaPuEpeT/g=="],
"@mui/system": ["@mui/system@9.3.0", "", { "dependencies": { "@babel/runtime": "^7.29.7", "@mui/private-theming": "^9.3.0", "@mui/styled-engine": "^9.3.0", "@mui/types": "^9.3.0", "@mui/utils": "^9.3.0", "clsx": "^2.1.1", "csstype": "^3.2.3", "prop-types": "^15.8.1" }, "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled", "@types/react"] }, "sha512-0l4LqHJxZj65xSrioniGsxm7VNoGXonPo203oZjhBUvIDPeBqRTb7Mqc45Qxs6sO6WGR7WE/9cJ6lb4lkvHkjg=="],
"@mui/types": ["@mui/types@9.0.0", "http://registry.npmjs.org/@mui/types/-/types-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.29.2" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-i1cuFCAWN44b3AJWO7mh7tuh1sqbQSeVr/94oG0TX5uXivac8XalgE4/6fQZcmGZigzbQ35IXxj/4jLpRIBYZg=="],
"@mui/types": ["@mui/types@9.3.0", "", { "dependencies": { "@babel/runtime": "^7.29.7" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-2JSxyfpEFWNUB2vKs/T1BvkfyNisMHWph8bLMj8T0uHwmLl/0qfAwQkfwMT6kxLXN9uIum9AEbECXU8er3amIg=="],
"@mui/utils": ["@mui/utils@9.0.0", "http://registry.npmjs.org/@mui/utils/-/utils-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@mui/types": "^9.0.0", "@types/prop-types": "^15.7.15", "clsx": "^2.1.1", "prop-types": "^15.8.1", "react-is": "^19.2.4" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-bQcqyg/gjULUqTuyUjSAFr6LQGLvtkNtDbJerAtoUn9kGZ0hg5QJiN1PLHMLbeFpe3te1831uq7GFl2ITokGdg=="],
"@mui/utils": ["@mui/utils@9.3.0", "", { "dependencies": { "@babel/runtime": "^7.29.7", "@mui/types": "^9.3.0", "@types/prop-types": "^15.7.15", "clsx": "^2.1.1", "prop-types": "^15.8.1", "react-is": "^19.2.8" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-2HZdHwWJ6eB+7lVGSOHsByGw8jeRulT4g0NZ608Wb8Q57DE2jbNqrWPFuJsvkQQiBiTmlpvQL3i+/62zsiPrkw=="],
"@mui/x-charts": ["@mui/x-charts@9.0.2", "http://registry.npmjs.org/@mui/x-charts/-/x-charts-9.0.2.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@mui/utils": "9.0.0", "@mui/x-charts-vendor": "^9.0.0", "@mui/x-internal-gestures": "^9.0.2", "@mui/x-internals": "^9.0.0", "bezier-easing": "^2.1.0", "clsx": "^2.1.1", "prop-types": "^15.8.1", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", "@mui/material": "^7.3.0 || ^9.0.0", "@mui/system": "^7.3.0 || ^9.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled"] }, "sha512-bKgjGD+uJbDN/g7tMjVmlNdm+iM4UkCJoYruQmgpQ0l+cip8Kn4kmn1iD//rZ35an+LdWaUZ4MHvMzV76D6EJw=="],
"@mui/x-charts": ["@mui/x-charts@9.11.1", "", { "dependencies": { "@babel/runtime": "^7.29.7", "@mui/utils": "^9.3.0", "@mui/x-charts-vendor": "^9.11.1", "@mui/x-internal-gestures": "^9.10.0", "@mui/x-internals": "^9.11.0", "bezier-easing": "^3.0.1", "clsx": "^2.1.1", "prop-types": "^15.8.1", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", "@mui/material": "^7.3.0 || ^9.0.0", "@mui/system": "^7.3.0 || ^9.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled"] }, "sha512-5Z6fzVm0NvD4auoyGZopp1wwg1mMVDXjbPEi4jahdxn14EzIAB99kKw4NUkKtmSaZJ6DiBU7zJXQAec1IZTaLA=="],
"@mui/x-charts-vendor": ["@mui/x-charts-vendor@9.0.0", "http://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@types/d3-array": "^3.2.2", "@types/d3-color": "^3.1.3", "@types/d3-format": "^3.0.4", "@types/d3-interpolate": "^3.0.4", "@types/d3-path": "^3.1.1", "@types/d3-scale": "^4.0.9", "@types/d3-shape": "^3.1.8", "@types/d3-time": "^3.0.4", "@types/d3-time-format": "^4.0.3", "@types/d3-timer": "^3.0.2", "d3-array": "^3.2.4", "d3-color": "^3.1.0", "d3-format": "^3.1.2", "d3-interpolate": "^3.0.1", "d3-path": "^3.1.0", "d3-scale": "^4.0.2", "d3-shape": "^3.2.0", "d3-time": "^3.1.0", "d3-time-format": "^4.1.0", "d3-timer": "^3.0.1", "flatqueue": "^3.0.0", "internmap": "^2.0.3" } }, "sha512-Do91i+fZiNj/4LN5oaGpJoutolzDBDwdfw6tHrx2LKXDMCRlaImCfreLbdbkk7dFsi9fuIP7hWiMV4vDJKPJTA=="],
"@mui/x-charts-vendor": ["@mui/x-charts-vendor@9.11.1", "", { "dependencies": { "@babel/runtime": "^7.29.7", "@types/d3-array": "^3.2.2", "@types/d3-color": "^3.1.3", "@types/d3-format": "^3.0.4", "@types/d3-geo": "^3.1.0", "@types/d3-interpolate": "^3.0.4", "@types/d3-path": "^3.1.1", "@types/d3-scale": "^4.0.9", "@types/d3-shape": "^3.1.8", "@types/d3-time": "^3.0.4", "@types/d3-time-format": "^4.0.3", "@types/d3-timer": "^3.0.2", "d3-array": "^3.2.4", "d3-color": "^3.1.0", "d3-format": "^3.1.2", "d3-geo": "^3.1.1", "d3-interpolate": "^3.0.1", "d3-path": "^3.1.0", "d3-scale": "^4.0.2", "d3-shape": "^3.2.0", "d3-time": "^3.1.0", "d3-time-format": "^4.1.0", "d3-timer": "^3.0.1", "flatqueue": "^3.1.0", "internmap": "^2.0.3" } }, "sha512-Q7cuPaKZLLsyvtEiP+ENG7xLlPatXyNQ7u5WkbAcAIZwyMK8MJw4MPgJiyWyfy9eVQO/IkRLIupabvu8cA2jPA=="],
"@mui/x-internal-gestures": ["@mui/x-internal-gestures@9.0.2", "http://registry.npmjs.org/@mui/x-internal-gestures/-/x-internal-gestures-9.0.2.tgz", { "dependencies": { "@babel/runtime": "^7.28.6" } }, "sha512-xCp99a7cSb7iH1bj4G524ooMOFe92H8m/rONCUiKyj7LvV1YUGzTfHgJysQgDCZJqHYaW7YAGLvwMUyEMZVzqQ=="],
"@mui/x-internal-gestures": ["@mui/x-internal-gestures@9.10.0", "http://registry.npmjs.org/@mui/x-internal-gestures/-/x-internal-gestures-9.10.0.tgz", { "dependencies": { "@babel/runtime": "^7.29.7", "@base-ui/utils": "^0.3.1" } }, "sha512-Fgmh+vJlOjLst238/J5SlEOZLdnSe0yWjrYctYVWJr3pKbtmYSbDkjYvWjG97PeDvq5Wa1+W1qagiBoJgkUIng=="],
"@mui/x-internals": ["@mui/x-internals@9.0.0", "http://registry.npmjs.org/@mui/x-internals/-/x-internals-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@mui/utils": "9.0.0", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-E/4rdg69JjhyybpPGypCjAKSKLLnSdCFM+O6P/nkUg47+qt3uftxQEhjQO53rcn6ahHl6du/uNZ9BLgeY6kYxQ=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@mui/x-internals": ["@mui/x-internals@9.11.0", "", { "dependencies": { "@babel/runtime": "^7.29.7", "@base-ui/utils": "^0.3.1", "@mui/utils": "^9.3.0", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JjKe9k1+gVWNPwMTZLNSc92eVmoZqP5Xq3Ui6mJkstotUrbWIZC0o9+4AfTR1lYqWwnKmqm+VtLLVCa7MEBXWQ=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "http://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
@@ -176,7 +173,7 @@
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "http://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@oxc-project/types": ["@oxc-project/types@0.126.0", "", {}, "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ=="],
"@oxc-project/types": ["@oxc-project/types@0.144.0", "", {}, "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg=="],
"@parcel/watcher": ["@parcel/watcher@2.5.6", "http://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],
@@ -210,45 +207,41 @@
"@popperjs/core": ["@popperjs/core@2.11.8", "http://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.16", "", { "os": "android", "cpu": "arm64" }, "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.16", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm" }, "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "s390x" }, "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.16", "", { "os": "linux", "cpu": "x64" }, "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.16", "", { "os": "none", "cpu": "arm64" }, "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.4", "", { "os": "none", "cpu": "arm64" }, "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.16", "", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.16", "", { "os": "win32", "cpu": "x64" }, "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "http://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "http://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.10.0", "http://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.56.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": "^9.0.0 || ^10.0.0" } }, "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ=="],
"@tanstack/query-core": ["@tanstack/query-core@5.99.2", "http://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.2.tgz", {}, "sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA=="],
"@tanstack/query-core": ["@tanstack/query-core@5.101.4", "http://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", {}, "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw=="],
"@tanstack/react-query": ["@tanstack/react-query@5.99.2", "http://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.2.tgz", { "dependencies": { "@tanstack/query-core": "5.99.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vM91UEe45QUS9ED6OklsVL15i8qKcRqNwpWzPTVWvRPRSEgDudDgHpvyTjcdlwHcrKNa80T+xXYcchT2noPnZA=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "http://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tanstack/react-query": ["@tanstack/react-query@5.101.4", "http://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", { "dependencies": { "@tanstack/query-core": "5.101.4" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "http://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
@@ -256,6 +249,8 @@
"@types/d3-format": ["@types/d3-format@3.0.4", "http://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="],
"@types/d3-geo": ["@types/d3-geo@3.1.0", "http://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "http://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "http://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
@@ -274,6 +269,8 @@
"@types/estree": ["@types/estree@1.0.8", "http://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/geojson": ["@types/geojson@7946.0.16", "http://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "http://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/parse-json": ["@types/parse-json@4.0.2", "http://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="],
@@ -286,7 +283,7 @@
"@typescript-eslint/types": ["@typescript-eslint/types@8.58.1", "http://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", {}, "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "http://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.5", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA=="],
"acorn": ["acorn@8.16.0", "http://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
@@ -320,7 +317,7 @@
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.20", "http://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ=="],
"bezier-easing": ["bezier-easing@2.1.0", "http://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz", {}, "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig=="],
"bezier-easing": ["bezier-easing@3.0.1", "http://registry.npmjs.org/bezier-easing/-/bezier-easing-3.0.1.tgz", {}, "sha512-vvvg10Anq7py0+KkIqlwJpUVdde9Z8kSzeUzAkQNYAJdA3mZnLG9r9ExdwtjcASCYER6UkNHdBNHHMnVimnU7A=="],
"brace-expansion": ["brace-expansion@1.1.11", "http://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
@@ -338,7 +335,9 @@
"caniuse-lite": ["caniuse-lite@1.0.30001788", "http://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", {}, "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ=="],
"chokidar": ["chokidar@4.0.3", "http://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"cap-widget": ["cap-widget@0.1.57", "", {}, "sha512-SyIK5XgYIFSDgH683M9KpfBmDSDB0ZfPZQBxL6MggNrjjnbIQYpbsNpj0S7PC/wXp7oJgAuBD2vtJnN9jJghBg=="],
"chokidar": ["chokidar@5.0.0", "http://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"clsx": ["clsx@2.1.1", "http://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
@@ -358,6 +357,8 @@
"d3-format": ["d3-format@3.1.2", "http://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
"d3-geo": ["d3-geo@3.1.1", "http://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "http://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "http://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
@@ -418,7 +419,7 @@
"escape-string-regexp": ["escape-string-regexp@4.0.0", "http://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@10.2.1", "http://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q=="],
"eslint": ["eslint@10.8.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ=="],
"eslint-plugin-react": ["eslint-plugin-react@7.37.5", "http://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="],
@@ -460,7 +461,7 @@
"flat-cache": ["flat-cache@4.0.1", "http://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatqueue": ["flatqueue@3.0.0", "http://registry.npmjs.org/flatqueue/-/flatqueue-3.0.0.tgz", {}, "sha512-y1deYaVt+lIc/d2uIcWDNd0CrdQTO5xoCjeFdhX0kSXvm2Acm0o+3bAOiYklTEoRyzwio3sv3/IiBZdusbAe2Q=="],
"flatqueue": ["flatqueue@3.1.0", "", {}, "sha512-Ia4qIYrrsEqIRx3c3XhkT+QDLQuUV5ovsr6ah1rIgKT5wclhoGK3lAMS1bWRAWxlx7wtlTBpV7QXB5d9fOSRxA=="],
"flatted": ["flatted@3.4.2", "http://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
@@ -486,7 +487,7 @@
"glob-parent": ["glob-parent@6.0.2", "http://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@17.5.0", "http://registry.npmjs.org/globals/-/globals-17.5.0.tgz", {}, "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g=="],
"globals": ["globals@17.11.0", "", {}, "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw=="],
"globalthis": ["globalthis@1.0.4", "http://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
@@ -584,7 +585,7 @@
"js-tokens": ["js-tokens@4.0.0", "http://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "http://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"js-yaml": ["js-yaml@4.3.0", "http://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
"jsesc": ["jsesc@3.1.0", "http://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
@@ -604,29 +605,29 @@
"levn": ["levn@0.4.1", "http://registry.npmjs.org/levn/-/levn-0.4.1.tgz", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lightningcss": ["lightningcss@1.32.0", "http://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "http://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "http://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "http://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "http://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "http://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "http://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "http://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "http://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "http://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "http://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "http://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "http://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
@@ -650,7 +651,7 @@
"ms": ["ms@2.1.3", "http://registry.npmjs.org/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "http://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
"natural-compare": ["natural-compare@1.4.0", "http://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
@@ -700,7 +701,7 @@
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "http://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
"prelude-ls": ["prelude-ls@1.2.1", "http://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
@@ -710,21 +711,21 @@
"queue-microtask": ["queue-microtask@1.2.3", "http://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"react": ["react@19.2.5", "http://registry.npmjs.org/react/-/react-19.2.5.tgz", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
"react": ["react@19.2.8", "http://registry.npmjs.org/react/-/react-19.2.8.tgz", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
"react-dom": ["react-dom@19.2.5", "http://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
"react-dom": ["react-dom@19.2.8", "http://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
"react-is": ["react-is@19.2.5", "http://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", {}, "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ=="],
"react-is": ["react-is@19.2.8", "http://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", {}, "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ=="],
"react-transition-group": ["react-transition-group@4.4.5", "http://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
"readdirp": ["readdirp@4.1.2", "http://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"readdirp": ["readdirp@5.0.0", "http://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "http://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "http://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
"reselect": ["reselect@5.1.1", "http://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
"reselect": ["reselect@5.2.0", "http://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="],
"resolve": ["resolve@2.0.0-next.6", "http://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA=="],
@@ -732,7 +733,7 @@
"reusify": ["reusify@1.1.0", "http://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rolldown": ["rolldown@1.0.0-rc.16", "", { "dependencies": { "@oxc-project/types": "=0.126.0", "@rolldown/pluginutils": "1.0.0-rc.16" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-arm64": "1.0.0-rc.16", "@rolldown/binding-darwin-x64": "1.0.0-rc.16", "@rolldown/binding-freebsd-x64": "1.0.0-rc.16", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.16", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.16", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.16", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g=="],
"rolldown": ["rolldown@1.2.4", "", { "dependencies": { "@oxc-project/types": "=0.144.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.2.4", "@rolldown/binding-darwin-arm64": "1.2.4", "@rolldown/binding-darwin-x64": "1.2.4", "@rolldown/binding-freebsd-x64": "1.2.4", "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", "@rolldown/binding-linux-arm64-gnu": "1.2.4", "@rolldown/binding-linux-arm64-musl": "1.2.4", "@rolldown/binding-linux-ppc64-gnu": "1.2.4", "@rolldown/binding-linux-s390x-gnu": "1.2.4", "@rolldown/binding-linux-x64-gnu": "1.2.4", "@rolldown/binding-linux-x64-musl": "1.2.4", "@rolldown/binding-openharmony-arm64": "1.2.4", "@rolldown/binding-win32-arm64-msvc": "1.2.4", "@rolldown/binding-win32-x64-msvc": "1.2.4" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w=="],
"run-parallel": ["run-parallel@1.2.0", "http://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
@@ -742,7 +743,7 @@
"safe-regex-test": ["safe-regex-test@1.1.0", "http://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
"sass": ["sass@1.99.0", "http://registry.npmjs.org/sass/-/sass-1.99.0.tgz", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": "sass.js" }, "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q=="],
"sass": ["sass@1.102.0", "http://registry.npmjs.org/sass/-/sass-1.102.0.tgz", { "dependencies": { "chokidar": "^5.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-NSOyTnaQF7rTAEOtI2fwb386vL+akyiQLBZu8Na7hXCb+umJy0GAqlcMIaqACZ6Z1VgTBS4K9PG6B3IdjHGJsw=="],
"scheduler": ["scheduler@0.27.0", "http://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
@@ -790,14 +791,12 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "http://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tinyglobby": ["tinyglobby@0.2.16", "http://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"tinyglobby": ["tinyglobby@0.2.17", "http://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"to-regex-range": ["to-regex-range@5.0.1", "http://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"totalist": ["totalist@3.0.1", "http://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
"tslib": ["tslib@2.8.1", "http://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-check": ["type-check@0.4.0", "http://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typed-array-buffer": ["typed-array-buffer@1.0.3", "http://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
@@ -816,7 +815,7 @@
"use-sync-external-store": ["use-sync-external-store@1.6.0", "http://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"vite": ["vite@8.0.9", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.16", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw=="],
"vite": ["vite@8.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="],
"vite-plugin-symfony": ["vite-plugin-symfony@8.2.4", "http://registry.npmjs.org/vite-plugin-symfony/-/vite-plugin-symfony-8.2.4.tgz", { "dependencies": { "debug": "^4.4.1", "fast-glob": "^3.3.3", "picocolors": "^1.1.1", "sirv": "^3.0.1" }, "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-ph98EMPx8FhA6QIp43ZiK0zjODV9jumB7EHXfZEhRme2lo9oBa9sAXCNCCIvdVk/m9EWkNZpcRBjequjXZiSuA=="],
@@ -842,6 +841,8 @@
"zod-validation-error": ["zod-validation-error@4.0.2", "http://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"@base-ui/utils/@babel/runtime": ["@babel/runtime@7.29.7", "http://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "http://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "http://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
@@ -850,6 +851,26 @@
"@eslint/eslintrc/globals": ["globals@14.0.0", "http://registry.npmjs.org/globals/-/globals-14.0.0.tgz", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@mui/material/@babel/runtime": ["@babel/runtime@7.29.7", "http://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@mui/private-theming/@babel/runtime": ["@babel/runtime@7.29.7", "http://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@mui/styled-engine/@babel/runtime": ["@babel/runtime@7.29.7", "http://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@mui/system/@babel/runtime": ["@babel/runtime@7.29.7", "http://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@mui/types/@babel/runtime": ["@babel/runtime@7.29.7", "http://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@mui/utils/@babel/runtime": ["@babel/runtime@7.29.7", "http://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@mui/x-charts/@babel/runtime": ["@babel/runtime@7.29.7", "http://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@mui/x-charts-vendor/@babel/runtime": ["@babel/runtime@7.29.7", "http://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@mui/x-internal-gestures/@babel/runtime": ["@babel/runtime@7.29.7", "http://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@mui/x-internals/@babel/runtime": ["@babel/runtime@7.29.7", "http://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"babel-plugin-macros/resolve": ["resolve@1.22.12", "http://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
"eslint/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "http://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
@@ -866,7 +887,7 @@
"prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.16", "", {}, "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA=="],
"vite/picomatch": ["picomatch@4.0.5", "http://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
"@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@5.0.5", "http://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
+120 -19
View File
@@ -6,10 +6,11 @@ services:
restart: unless-stopped
container_name: '${APP_NAME}-app'
ports:
- "10080:80"
- "${APP_PUBLISHED_PORT:-10080}:80"
environment:
SERVER_NAME: ${SERVER_NAME:-:80}
APP_ENV: prod
APP_VERSION: ${APP_VERSION:-1.0.0}
APP_SECRET: ${APP_SECRET}
APP_PUBLIC_HOSTNAME: ${APP_PUBLIC_HOSTNAME:-localhost}
APP_CONTACT_MAIL_ADDRESS: ${APP_CONTACT_MAIL_ADDRESS:-7system7@gmail.com}
@@ -25,14 +26,14 @@ services:
MERCURE_JWT_TOKEN: ${MERCURE_JWT_TOKEN}
MERCURE_SUBSCRIBER_JWT: ${MERCURE_SUBSCRIBER_JWT}
MAILER_DSN: smtp://mail:25?verify_peer=0
RECAPTCHA_SITE_KEY: ${RECAPTCHA_SITE_KEY}
RECAPTCHA_SECRET_KEY: ${RECAPTCHA_SECRET_KEY}
CAP_API_ENDPOINT: ${CAP_API_ENDPOINT}
CAP_SECRET_KEY: ${CAP_SECRET_KEY}
WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:-localhost}
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-https://localhost}
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
MINIO_ENDPOINT: http://minio:9000
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9000}
MINIO_ENDPOINT: http://rustfs:9000
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9010}
# IMPORTANT: Set TRUSTED_PROXIES to your reverse proxy IP in production.
# For Docker on same host, use: 172.17.0.1 (default bridge) or 172.16.0.0/12 (overlay network)
# For Kubernetes or external proxy, use the proxy's IP address.
@@ -42,19 +43,113 @@ services:
- app_var:/app/var
- caddy_data:/data
- caddy_config:/config
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1/ -o /dev/null"]
interval: 10s
timeout: 5s
retries: 12
start_period: 60s
depends_on:
db:
condition: service_healthy
mail:
condition: service_started
rustfs:
condition: service_healthy
storage_init:
condition: service_completed_successfully
scheduler:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
container_name: '${APP_NAME}-scheduler'
command: ['php', 'bin/console', 'messenger:consume', 'scheduler_default', '--env=prod', '--no-interaction', '--memory-limit=128M']
environment:
APP_ENV: prod
APP_SECRET: ${APP_SECRET}
APP_PUBLIC_HOSTNAME: ${APP_PUBLIC_HOSTNAME:-localhost}
DATABASE_URL: >-
postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?serverVersion=${POSTGRES_VERSION}&charset=utf8
POSTGRES_URL: db
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
MERCURE_URL: http://localhost/.well-known/mercure
MERCURE_PUBLIC_URL: https://${APP_PUBLIC_HOSTNAME:-localhost}/.well-known/mercure
MERCURE_JWT_SECRET: ${MERCURE_JWT_SECRET}
MERCURE_JWT_TOKEN: ${MERCURE_JWT_TOKEN}
MERCURE_SUBSCRIBER_JWT: ${MERCURE_SUBSCRIBER_JWT}
MAILER_DSN: smtp://mail:25?verify_peer=0
CAP_API_ENDPOINT: ${CAP_API_ENDPOINT}
CAP_SECRET_KEY: ${CAP_SECRET_KEY}
WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:-localhost}
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:-https://localhost}
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
MINIO_ENDPOINT: http://rustfs:9000
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9010}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1}
volumes:
- app_var:/app/var
depends_on:
db:
condition: service_healthy
mail:
condition: service_started
rustfs:
condition: service_healthy
storage_init:
condition: service_completed_successfully
healthcheck:
disable: true
rustfs:
image: rustfs/rustfs:1.0.3
restart: unless-stopped
container_name: '${APP_NAME}-rustfs'
environment:
RUSTFS_ACCESS_KEY: ${MINIO_ROOT_USER}
RUSTFS_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
RUSTFS_ADDRESS: ':9000'
RUSTFS_CONSOLE_ADDRESS: ':9001'
RUSTFS_CONSOLE_ENABLE: 'true'
volumes:
- rustfs_data:/data
ports:
- "${RUSTFS_PUBLISHED_API_PORT:-9010}:9000"
- "${RUSTFS_PUBLISHED_CONSOLE_PORT:-9011}:9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/health"]
interval: 5s
timeout: 5s
retries: 20
start_period: 15s
storage_init:
build:
context: ./docker/storage-migrate
entrypoint: ["python"]
command: ["/usr/local/bin/storage-init.py"]
restart: "no"
container_name: '${APP_NAME}-storage-init'
depends_on:
rustfs:
condition: service_healthy
environment:
TARGET_ENDPOINT: http://rustfs:9000
STORAGE_ACCESS_KEY: ${MINIO_ROOT_USER}
STORAGE_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
STORAGE_BUCKET: mineseeker
minio:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z-cpuv1
profiles: ["storage-migration"]
image: ${MINIO_IMAGE:-minio/minio:RELEASE.2025-09-07T16-13-09Z-cpuv1}
pull_policy: never
restart: unless-stopped
container_name: '${APP_NAME}-minio'
command: server /data --console-address ":9001"
ports:
- "9000:9000"
- "9001:9001"
- "${MINIO_PUBLISHED_API_PORT:-9012}:9000"
- "${MINIO_PUBLISHED_CONSOLE_PORT:-9013}:9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
@@ -66,19 +161,25 @@ services:
timeout: 5s
retries: 10
start_period: 10s
minio_init:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z-cpuv1
storage_migrate:
profiles: ["storage-migration"]
build:
context: ./docker/storage-migrate
restart: "no"
container_name: '${APP_NAME}-minio-init'
container_name: '${APP_NAME}-storage-migrate'
depends_on:
minio:
condition: service_healthy
rustfs:
condition: service_healthy
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
SOURCE_ENDPOINT: http://minio:9000
TARGET_ENDPOINT: http://rustfs:9000
STORAGE_ACCESS_KEY: ${MINIO_ROOT_USER}
STORAGE_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
STORAGE_BUCKET: mineseeker
volumes:
- ./docker/minio-init.sh:/minio-init.sh:ro
entrypoint: ["/bin/sh", "/minio-init.sh"]
- storage_migration_state:/state
mail:
image: boky/postfix:latest
restart: unless-stopped
@@ -105,7 +206,7 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
- postgres_data:/var/lib/postgresql
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}" ]
interval: 5s
@@ -113,7 +214,7 @@ services:
retries: 10
start_period: 10s
ports:
- "15432:5432"
- "${POSTGRES_PUBLISHED_PORT:-5452}:5432"
volumes:
app_var:
postgres_data:
@@ -121,5 +222,5 @@ volumes:
caddy_config:
postfix_spool:
minio_data:
rustfs_data:
storage_migration_state:
+22 -20
View File
@@ -1,6 +1,6 @@
{
"name": "splendid-bear/mineseeker",
"version": "2026.2.1",
"version": "2026.3.0",
"license": "GPL-3.0-or-later",
"author": "https://www.splendidbear.org",
"bugs": "https://source.splendidbear.org",
@@ -10,7 +10,7 @@
"prefer-stable": true,
"private": true,
"require": {
"php": ">=8.5",
"php": ">=8.5.12",
"ext-gd": "*",
"ext-iconv": "*",
"ext-json": "*",
@@ -27,32 +27,34 @@
"scheb/2fa-backup-code": "^8.5",
"scheb/2fa-bundle": "^8.5",
"scheb/2fa-totp": "^8.5",
"symfony/console": "7.4.*",
"symfony/console": "8.1.*",
"symfony/flex": "^2.10.0",
"symfony/form": "7.4.*",
"symfony/framework-bundle": "7.4.*",
"symfony/http-client": "7.4.*",
"symfony/mailer": "7.4.*",
"symfony/mercure": "^0.7",
"symfony/mercure-bundle": "^0.4",
"symfony/form": "8.1.*",
"symfony/framework-bundle": "8.1.*",
"symfony/http-client": "8.1.*",
"symfony/mailer": "8.1.*",
"symfony/mercure": "^0.9",
"symfony/mercure-bundle": "^0.6",
"symfony/messenger": "8.1.*",
"symfony/monolog-bundle": "^4.0",
"symfony/security-bundle": "7.4.*",
"symfony/translation": "7.4.*",
"symfony/twig-bundle": "7.4.*",
"symfony/validator": "7.4.*",
"symfony/yaml": "7.4.*",
"symfony/scheduler": "8.1.*",
"symfony/security-bundle": "8.1.*",
"symfony/translation": "8.1.*",
"symfony/twig-bundle": "8.1.*",
"symfony/validator": "8.1.*",
"symfony/yaml": "8.1.*",
"web-auth/webauthn-framework": "^5.2"
},
"require-dev": {
"dama/doctrine-test-bundle": "^8.6",
"phpunit/phpunit": "^13.1",
"roave/security-advisories": "dev-master",
"symfony/browser-kit": "7.4.*",
"symfony/css-selector": "7.4.*",
"symfony/dotenv": "7.4.*",
"symfony/browser-kit": "8.1.*",
"symfony/css-selector": "8.1.*",
"symfony/dotenv": "8.1.*",
"symfony/maker-bundle": "^1.5",
"symfony/stopwatch": "7.4.*",
"symfony/web-profiler-bundle": "7.4.*",
"symfony/stopwatch": "8.1.*",
"symfony/web-profiler-bundle": "8.1.*",
"zenstruck/foundry": "^2.9"
},
"config": {
@@ -102,7 +104,7 @@
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.4.*"
"require": "8.1.*"
}
}
}
Generated
+2396 -2001
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -3,6 +3,7 @@ mercure:
default:
url: '%env(MERCURE_URL)%'
public_url: '%env(MERCURE_PUBLIC_URL)%'
protocol_version: '0.x'
jwt:
secret: '%env(MERCURE_JWT_SECRET)%'
publish: '*'
+22
View File
@@ -0,0 +1,22 @@
framework:
messenger:
# Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
# failure_transport: failed
transports:
# https://symfony.com/doc/current/messenger.html#transport-configuration
# async: '%env(MESSENGER_TRANSPORT_DSN)%'
# failed: 'doctrine://default?queue_name=failed'
sync: 'sync://'
routing:
# Route your messages to the transports
# 'App\Message\YourMessage': async
# when@test:
# framework:
# messenger:
# transports:
# # replace with your transport name here (e.g., my_transport: 'in-memory://')
# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test
# async: 'in-memory://'
+1 -1
View File
@@ -4,4 +4,4 @@ twig:
strict_variables: '%kernel.debug%'
globals:
version: "%jotunheimr.version%"
recaptcha_site_key: "%env(RECAPTCHA_SITE_KEY)%"
cap_api_endpoint: "%env(CAP_API_ENDPOINT)%"
+1
View File
@@ -1,6 +1,7 @@
when@dev: &dev
# See full configuration: https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#full-default-bundle-configuration
zenstruck_foundry:
enable_auto_refresh_with_lazy_objects: true
persistence:
# Flush only once per call of `PersistentObjectFactory::create()`
flush_once: true
+2 -2
View File
@@ -1,3 +1,3 @@
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.xml'
prefix: /_error
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
prefix: /_error
+2 -2
View File
@@ -1,7 +1,7 @@
web_profiler_wdt:
resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml'
resource: '@WebProfilerBundle/Resources/config/routing/wdt.php'
prefix: /_wdt
web_profiler_profiler:
resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml'
resource: '@WebProfilerBundle/Resources/config/routing/profiler.php'
prefix: /_profiler
+1 -1
View File
@@ -2,7 +2,7 @@
# https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration
parameters:
locale: 'en'
jotunheimr.version: 1.1.0-20191026
jotunheimr.version: '%env(APP_VERSION)%'
services:
# default configuration for services in *this* file
+5
View File
@@ -8,6 +8,11 @@ until php -r "new PDO('pgsql:host=db;port=5432;dbname=${POSTGRES_DB}', '${POSTGR
done
echo "[entrypoint] Database is ready."
if [ "$#" -gt 0 ]; then
echo "[entrypoint] Starting worker command: $*"
exec "$@"
fi
echo "[entrypoint] Clearing and warming Symfony cache..."
php bin/console cache:clear
+6 -2
View File
@@ -1,8 +1,12 @@
#!/bin/sh
set -e
echo "[minio-init] Configuring alias..."
mc alias set local http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"
echo "[minio-init] Waiting for MinIO..."
until mc alias set local http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" > /dev/null 2>&1; do
sleep 2
done
echo "[minio-init] Configuring bucket..."
if mc ls local/mineseeker > /dev/null 2>&1; then
echo "[minio-init] Already initialized, skipping."
+8
View File
@@ -0,0 +1,8 @@
FROM python:3.13-slim
RUN pip install --no-cache-dir boto3==1.43.118
COPY migrate.py /usr/local/bin/storage-migrate.py
COPY init.py /usr/local/bin/storage-init.py
ENTRYPOINT ["python", "/usr/local/bin/storage-migrate.py"]
+45
View File
@@ -0,0 +1,45 @@
"""Create the application bucket and public object-read policy on RustFS."""
import json
import os
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
BUCKET = os.environ.get("STORAGE_BUCKET", "mineseeker")
s3 = boto3.client(
"s3",
endpoint_url=os.environ["TARGET_ENDPOINT"],
aws_access_key_id=os.environ["STORAGE_ACCESS_KEY"],
aws_secret_access_key=os.environ["STORAGE_SECRET_KEY"],
region_name="us-east-1",
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
)
try:
s3.head_bucket(Bucket=BUCKET)
except ClientError as error:
status = error.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
code = error.response.get("Error", {}).get("Code", "")
if status != 404 and code not in {"NoSuchBucket", "404", "NotFound"}:
raise
s3.create_bucket(Bucket=BUCKET)
policy = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject"],
"Resource": [
f"arn:aws:s3:::{BUCKET}/media/*",
f"arn:aws:s3:::{BUCKET}/cache/*",
],
}
],
}
s3.put_bucket_policy(Bucket=BUCKET, Policy=json.dumps(policy))
print(f"RustFS bucket {BUCKET!r} is initialized", flush=True)
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""Copy the legacy MinIO bucket into RustFS exactly once, without deleting source data."""
import json
import itertools
import os
import sys
import tempfile
import time
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError, EndpointConnectionError
MARKER = "/state/minio-to-rustfs-v2.json"
BUCKET = os.environ.get("STORAGE_BUCKET", "mineseeker")
ACCESS_KEY = os.environ["STORAGE_ACCESS_KEY"]
SECRET_KEY = os.environ["STORAGE_SECRET_KEY"]
def client(endpoint):
return boto3.client(
"s3",
endpoint_url=endpoint,
aws_access_key_id=ACCESS_KEY,
aws_secret_access_key=SECRET_KEY,
region_name="us-east-1",
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
)
def wait_for_service(s3, label):
for attempt in range(60):
try:
s3.list_buckets()
print(f"{label} is ready", flush=True)
return
except (EndpointConnectionError, ClientError) as error:
if attempt == 59:
raise RuntimeError(f"{label} did not become ready: {error}") from error
time.sleep(2)
def bucket_exists(s3, bucket):
try:
s3.head_bucket(Bucket=bucket)
return True
except ClientError as error:
code = error.response.get("Error", {}).get("Code", "")
status = error.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
if code in {"NoSuchBucket", "404", "NotFound"} or status == 404:
return False
raise
def ensure_bucket(s3, bucket):
if not bucket_exists(s3, bucket):
s3.create_bucket(Bucket=bucket)
def reject_versioned_bucket(source, bucket):
versioning = source.get_bucket_versioning(Bucket=bucket).get("Status")
if versioning in {"Enabled", "Suspended"}:
raise RuntimeError(
f"Bucket {bucket!r} has versioning status {versioning}; "
"refusing to omit historical object versions or delete markers"
)
def copy_bucket(source, target, bucket):
copied = 0
paginator = source.get_paginator("list_objects_v2")
for page in paginator.paginate(Bucket=bucket):
for item in page.get("Contents", []):
key = item["Key"]
response = source.get_object(Bucket=bucket, Key=key)
try:
# Botocore's request checksum calculation needs a seekable body.
# Spool to disk beyond 16 MiB so large objects don't consume RAM.
with tempfile.SpooledTemporaryFile(max_size=16 * 1024 * 1024, mode="w+b") as body:
for chunk in response["Body"].iter_chunks(chunk_size=1024 * 1024):
if chunk:
body.write(chunk)
body.seek(0)
args = {
"Bucket": bucket,
"Key": key,
"Body": body,
"ContentLength": response["ContentLength"],
"Metadata": response.get("Metadata", {}),
}
for field in (
"ContentType",
"ContentEncoding",
"CacheControl",
"ContentDisposition",
"ContentLanguage",
"Expires",
):
if response.get(field) is not None:
args[field] = response[field]
if response.get("WebsiteRedirectLocation") is not None:
args["WebsiteRedirectLocation"] = response["WebsiteRedirectLocation"]
target.put_object(**args)
finally:
response["Body"].close()
copied += 1
if copied % 100 == 0:
print(f"Copied {copied} objects", flush=True)
return copied
def inventory(s3, bucket):
paginator = s3.get_paginator("list_objects_v2")
for page in paginator.paginate(Bucket=bucket):
for item in page.get("Contents", []):
yield item["Key"], item["Size"]
def verify_copy(source, target, bucket):
for source_item, target_item in itertools.zip_longest(
inventory(source, bucket), inventory(target, bucket)
):
if source_item != target_item:
raise RuntimeError(
f"Object inventory mismatch in bucket {bucket!r}: "
f"source={source_item!r}, target={target_item!r}"
)
def apply_bucket_policy(source, target, bucket):
if bucket != BUCKET:
try:
response = source.get_bucket_policy(Bucket=bucket)
except ClientError as error:
code = error.response.get("Error", {}).get("Code", "")
status = error.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
if code in {"NoSuchBucketPolicy", "NoSuchKey", "404", "NotFound"} or status == 404:
return
raise
target.put_bucket_policy(Bucket=bucket, Policy=response["Policy"])
return
policy = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject"],
"Resource": [
f"arn:aws:s3:::{bucket}/media/*",
f"arn:aws:s3:::{bucket}/cache/*",
],
}
],
}
target.put_bucket_policy(Bucket=bucket, Policy=json.dumps(policy))
def migrate_all_buckets(source, target):
buckets = sorted(bucket["Name"] for bucket in source.list_buckets().get("Buckets", []))
if BUCKET not in buckets:
raise RuntimeError(f"Required source bucket {BUCKET!r} does not exist; refusing to mark migration complete")
totals = {}
for bucket in buckets:
reject_versioned_bucket(source, bucket)
ensure_bucket(target, bucket)
totals[bucket] = copy_bucket(source, target, bucket)
verify_copy(source, target, bucket)
apply_bucket_policy(source, target, bucket)
print(f"Migrated bucket {bucket!r}: {totals[bucket]} objects", flush=True)
return totals
def main():
if os.path.exists(MARKER):
print("Storage migration already completed; skipping", flush=True)
return
source = client(os.environ["SOURCE_ENDPOINT"])
target = client(os.environ["TARGET_ENDPOINT"])
wait_for_service(source, "MinIO")
wait_for_service(target, "RustFS")
totals = migrate_all_buckets(source, target)
# Publish the completion marker only after the data and policy are confirmed.
temporary_marker = f"{MARKER}.tmp"
with open(temporary_marker, "w", encoding="utf-8") as marker:
json.dump({"buckets": totals, "objects_copied": sum(totals.values())}, marker)
marker.write("\n")
marker.flush()
os.fsync(marker.fileno())
os.replace(temporary_marker, MARKER)
print(
f"Migration complete: {sum(totals.values())} objects copied across "
f"{len(totals)} buckets; source data was left untouched",
flush=True,
)
if __name__ == "__main__":
try:
main()
except Exception as error: # Keep the failed container's logs useful to operators.
print(f"Storage migration failed: {error}", file=sys.stderr, flush=True)
raise
+67
View File
@@ -0,0 +1,67 @@
# Object storage migration
RustFS is the normal S3-compatible store. MinIO and `storage_migrate` are gated
behind the `storage-migration` Compose profile and are not started by a normal
`docker compose up -d`.
On the first production release with a legacy MinIO container, the deployment
workflow pauses app/scheduler writers, starts both stores, copies every bucket
and its current objects (including `mineseeker` media and LiipImagine cache
thumbnails), verifies each source/target key-and-size inventory, then starts
the app on RustFS. It also carries over bucket policies, while enforcing the
existing public-read policy for `mineseeker/media/*` and `mineseeker/cache/*`.
Versioned or suspended-versioning buckets fail safely instead of silently
losing historical versions/delete markers. Only after successful migration
and app startup does it remove the
MinIO and migration containers. It never removes the named `minio_data` volume;
keep that volume as the rollback/backup until RustFS has been verified and a
separate retirement decision is made. A missing source bucket or inventory
mismatch fails the deployment rather than recording an empty/partial migration.
RustFS retains the public host ports `9010` (S3 API) and `9011` (console); the
legacy MinIO temporarily uses `9012` and `9013`. Confirm the public storage
hostname configured by `MINIO_PUBLIC_URL` (for example, `d.aws...`) proxies to
the RustFS API port before serving the new app. The legacy MinIO uses the local
image named by `MINIO_IMAGE` (defaulting to the previously configured release
tag) and does not try to pull it. If production has another local image tag,
set `MINIO_IMAGE` to that exact reference before deployment.
For future releases, the workflow sees no MinIO container and deploys only the
RustFS stack. Review the release logs and `docker compose logs rustfs` after
rollout; never use `docker compose down -v` as part of migration/retirement.
## Testing a backup in isolation
`bin/restore-minio.sh` erases the MinIO volume belonging to the selected Compose
project before extracting the archive. Test with a separate project name and
`APP_NAME`; Compose then uses separate containers, network, and named volumes.
Use alternate published ports so the test services can run beside the regular
development stack:
```bash
export COMPOSE_PROJECT_NAME=mine-minio-test
export APP_NAME=mine-minio-test
export MINIO_PUBLISHED_API_PORT=19102
export MINIO_PUBLISHED_CONSOLE_PORT=19103
export RUSTFS_PUBLISHED_API_PORT=19100
export RUSTFS_PUBLISHED_CONSOLE_PORT=19101
export APP_PUBLISHED_PORT=19180
export POSTGRES_PUBLISHED_PORT=15452
export MAILHOG_SMTP_PUBLISHED_PORT=19125
export MAILHOG_UI_PUBLISHED_PORT=19126
docker compose --profile storage-migration up -d minio rustfs
bin/restore-minio.sh bazmeg/mineseeker-minio.2026-10-24.162934.tar.gz
docker compose --profile storage-migration up --build --abort-on-container-exit --exit-code-from storage_migrate storage_migrate
docker compose logs storage_migrate
```
The restore confirmation names the test MinIO volume; verify it says
`mine-minio-test_minio_data` before typing `yes`. The migration's state volume
is also isolated. This starts only MinIO and RustFS before the explicit
migration—not the app, scheduler, or database. To start the test app only after
the migration succeeds, restore its PostgreSQL backup, then run
`MINIO_PUBLIC_URL=http://localhost:19100 docker compose up -d app scheduler`
so browser-generated cache URLs reach the isolated RustFS. `docker compose down`
does not delete the test volumes; avoid `down -v` until you intentionally want
to discard the test copy.
+6
View File
@@ -57,6 +57,12 @@ Quick command reference for testing:
## CI/CD & Deployment
### [Scheduled Tasks](./SCHEDULER.md)
Symfony Scheduler tasks and the Docker worker that runs them.
### [Object storage migration](./MINIO_INITIALIZATION.md)
Automatic, one-shot migration from legacy MinIO to RustFS.
### [CI/CD Integration Guide](./CI_CD.md)
Comprehensive guide for continuous integration and deployment:
- Gitea Actions workflows (CI and CD pipelines)
+31
View File
@@ -0,0 +1,31 @@
# Scheduled Tasks
MineSeeker uses Symfony Scheduler for recurring maintenance work. The Docker
Compose `scheduler` service runs the Scheduler worker continuously and is
restarted automatically if it exits.
## Pending-account cleanup
`PurgeExpiredPendingUsersCommand` is scheduled every hour. It deletes only
unverified users whose activation-token expiry has passed.
To inspect the registered schedule locally:
```bash
php bin/console debug:scheduler
```
To run the cleanup manually without deleting data:
```bash
php bin/console app:users:purge-expired-pending --dry-run
```
The production worker is started by Docker Compose:
```bash
docker compose up -d scheduler
docker compose logs -f scheduler
```
No host-level cron entry is required.
+17 -16
View File
@@ -1,6 +1,6 @@
{
"name": "mine-seeker",
"version": "2026.2.1",
"version": "2026.3.0",
"author": "https://www.splendidbear.org",
"license": "GPL-3.0-or-later",
"bugs": "https://source.splendidbear.org",
@@ -16,30 +16,31 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/changa-one": "^5.2.8",
"@fontsource/open-sans": "^5.2.7",
"@fontsource/rajdhani": "^5.2.7",
"@fortawesome/fontawesome-free": "^7.2.0",
"@mui/material": "^9.0.0",
"@mui/x-charts": "^9.0.2",
"@tanstack/react-query": "^5.99.2",
"@fontsource/changa-one": "^5.3.0",
"@fontsource/open-sans": "^5.3.0",
"@fontsource/rajdhani": "^5.3.0",
"@fortawesome/fontawesome-free": "^7.3.1",
"@mui/material": "^9.3.1",
"@mui/x-charts": "^9.11.1",
"@tanstack/react-query": "^5.101.4",
"cap-widget": "^0.1.57",
"howler": "^2.2.4",
"lodash": "^4.18.1",
"prop-types": "^15.8.1",
"react": "^19.2.5",
"react-dom": "^19.2.5"
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@eslint/eslintrc": "^3.3.6",
"@eslint/js": "10.0.1",
"@stylistic/eslint-plugin": "5.10.0",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "10.2.1",
"@vitejs/plugin-react": "^6.0.5",
"eslint": "^10.8.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "7.1.1",
"globals": "17.5.0",
"sass": "^1.99.0",
"vite": "^8.0.9",
"globals": "^17.11.0",
"sass": "^1.102.0",
"vite": "^8.2.1",
"vite-plugin-symfony": "^8.2.4"
},
"scripts": {
-1
View File
@@ -16,7 +16,6 @@
<server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" />
<env name="SYMFONY_DEPRECATIONS_HELPER" value="disabled" />
<env name="DATABASE_URL" value="postgresql://system7:bazmeg@127.0.0.1:15432/mineseeker_test?serverVersion=16&amp;charset=utf8" />
<env name="DAMA_DISABLE_STATIC_CONNECTION" value="0" />
</php>
@@ -0,0 +1,78 @@
<?php declare(strict_types=1);
/*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Command;
use App\Repository\UserRepository;
use DateTime;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Scheduler\Attribute\AsPeriodicTask;
/**
* Class PurgeExpiredPendingUsersCommand
*
* @package App\Command
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 07. 27.
*/
#[AsCommand(
name: 'app:users:purge-expired-pending',
description: 'Delete unverified accounts whose activation token has expired.',
)]
#[AsPeriodicTask(frequency: '1 hour')]
final class PurgeExpiredPendingUsersCommand extends Command
{
public function __construct(private readonly UserRepository $userRepository)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$now = new DateTime();
$count = $this->userRepository->countExpiredPendingUsers($now);
if ($input->getOption('dry-run')) {
$io->note(sprintf('%d expired pending account(s) would be deleted.', $count));
return Command::SUCCESS;
}
if ($count === 0) {
$io->success('No expired pending accounts found.');
return Command::SUCCESS;
}
$deleted = $this->userRepository->deleteExpiredPendingUsers($now);
$io->success(sprintf('Deleted %d expired pending account(s).', $deleted));
return Command::SUCCESS;
}
protected function configure(): void
{
$this->addOption(
'dry-run',
null,
InputOption::VALUE_NONE,
'Report how many accounts would be deleted without deleting them.',
);
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php declare(strict_types=1);
/*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Controller;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
/**
* Class ApiAuthController
*
* Provides a JSON login endpoint for native desktop clients.
* This endpoint is intentionally exempt from the CAPTCHA listener
* because desktop clients cannot display or solve the Cap widget.
*
* After a successful password login, if the user has TOTP enabled the response
* returns { requiresTwoFactor: true }. The client must then POST the 6-digit
* code to the standard /2fa_check endpoint (which is already exempt from
* the CAPTCHA listener via LoginCaptchaListener).
*
* @package App\Controller
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 26.
*/
#[AsController]
class ApiAuthController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly Security $security,
) {
}
/**
* POST /api/auth/login
*
* Request body (JSON): { "username": "...", "password": "..." }
*
* Responses:
* 200 { "success": true, "requiresTwoFactor": false }
* 200 { "success": true, "requiresTwoFactor": true }
* 400 { "success": false, "error": "..." }
* 401 { "success": false, "error": "..." }
*/
#[Route('/api/auth/login', name: 'MineSeekerBundle_api_auth_login', methods: ['POST'])]
public function login(Request $request): JsonResponse
{
$data = $request->toArray();
$username = trim($data['username'] ?? '');
$password = $data['password'] ?? '';
if ($username === '' || $password === '') {
return $this->json(
['success' => false, 'error' => 'Username and password are required.'],
Response::HTTP_BAD_REQUEST
);
}
/** @var User|null $user */
$user = $this->em->getRepository(User::class)->findOneBy(['username' => $username]);
if ($user === null || !$this->passwordHasher->isPasswordValid($user, $password)) {
return $this->json(
['success' => false, 'error' => 'Invalid username or password.'],
Response::HTTP_UNAUTHORIZED
);
}
if (!$user->isVerified) {
return $this->json(
['success' => false, 'error' => 'Account not yet activated. Check your email.'],
Response::HTTP_UNAUTHORIZED
);
}
// Log the user in via the Symfony security system.
// If TOTP is enabled, scheb/2fa will place the session into
// IS_AUTHENTICATED_2FA_IN_PROGRESS state, and the client must
// complete 2FA by POSTing the code to /2fa_check.
$this->security->login($user, 'form_login');
return $this->json([
'success' => true,
'requiresTwoFactor' => $user->isTotpAuthenticationEnabled(),
]);
}
}
+5 -2
View File
@@ -21,7 +21,6 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Routing\Attribute\Route;
/**
@@ -60,6 +59,7 @@ class GameController extends AbstractController
{
return $this->render('Game/play.html.twig', [
'env' => $this->env,
'game_assoc' => $gameAssoc,
'mercure_hub_url' => $this->mercurePublicUrl,
'mercure_subscriber_jwt' => $this->mercureJwtService->mintSubscriberToken(
$gameAssoc ?? '', $this->opponentNameService->resolveUserName(),
@@ -84,8 +84,11 @@ class GameController extends AbstractController
public function contact(
Request $request,
EntityManagerInterface $em,
MailerInterface $mailer,
): Response {
// Keep the contact form implementation available, but disable public
// access while automated abuse protection is insufficient.
throw $this->createNotFoundException();
$contactMessage = new ContactMessage();
$form = $this->createForm(ContactFormType::class, $contactMessage);
$form->handleRequest($request);
+6 -5
View File
@@ -18,7 +18,6 @@ use App\Repository\UserRepository;
use App\Service\Email\SendActivationEmailService;
use App\Service\Email\SendPasswordResetEmailService;
use App\Service\Email\SendUserActivationNotificationService;
use App\Service\Email\SendUserRegistrationNotificationService;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use LogicException;
@@ -44,6 +43,8 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
#[AsController]
class SecurityController extends AbstractController
{
private const string ACTIVATION_TOKEN_TTL = '+24 hours';
public function __construct(
private readonly EntityManagerInterface $em,
private readonly RequestStack $requestStack,
@@ -53,7 +54,6 @@ class SecurityController extends AbstractController
private readonly SendActivationEmailService $activationEmail,
private readonly SendPasswordResetEmailService $passwordResetEmail,
private readonly SendUserActivationNotificationService $activationNotificationEmail,
private readonly SendUserRegistrationNotificationService $registrationNotificationEmail,
) {
}
@@ -92,6 +92,7 @@ class SecurityController extends AbstractController
$user->isVerified = false;
$user->verificationToken = $token;
$user->verificationTokenExpiresAt = new DateTime(self::ACTIVATION_TOKEN_TTL);
$user->password = $this->passwordHasher->hashPassword($user, $form->get('plainPassword')->getData());
$this->em->persist($user);
@@ -109,7 +110,6 @@ class SecurityController extends AbstractController
}
$this->activationEmail->send($user, $activationUrl);
$this->registrationNotificationEmail->send($user, new DateTime());
$this->addFlash('verify_email', $user->email);
@@ -194,13 +194,14 @@ class SecurityController extends AbstractController
{
$user = $this->em->getRepository(User::class)->findOneBy(['verificationToken' => $token]);
if (!$user) {
$this->addFlash('error', 'This activation link is invalid or has already been used.');
if (!$user || $user->verificationTokenExpiresAt === null || $user->verificationTokenExpiresAt <= new DateTime()) {
$this->addFlash('error', 'This activation link is invalid, expired, or has already been used.');
return $this->redirectToRoute('MineSeekerBundle_login');
}
$user->isVerified = true;
$user->verificationToken = null;
$user->verificationTokenExpiresAt = null;
$this->em->flush();
$this->activationNotificationEmail->send($user, new DateTime());
+3
View File
@@ -62,6 +62,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, TotpTwo
#[Column(length: 64, nullable: true)]
public ?string $verificationToken = null;
#[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
public ?DateTime $verificationTokenExpiresAt = null;
#[Column(length: 64, nullable: true)]
public ?string $resetToken = null;
+4 -4
View File
@@ -19,7 +19,7 @@ use Symfony\Component\Security\Http\Event\CheckPassportEvent;
/**
* Class LoginCaptchaListener
*
* Validates the Google reCAPTCHA v3 token during form-login authentication.
* Validates the Cap CAPTCHA token during form-login authentication.
* Fires on CheckPassportEvent, which is dispatched after credentials are
* collected but before the user is authenticated.
*
@@ -53,12 +53,12 @@ readonly class LoginCaptchaListener
return;
}
$token = $request->request->getString('g-recaptcha-response');
$token = $request->request->getString('cap-token');
if ($this->recaptcha->verify($token, $request->getClientIp())) {
if ($this->recaptcha->verify($token)) {
return;
}
throw new CustomUserMessageAuthenticationException('reCAPTCHA verification failed. Please try again.');
throw new CustomUserMessageAuthenticationException('CAPTCHA verification failed. Please try again.');
}
}
+4 -5
View File
@@ -22,8 +22,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Class RecaptchaType
*
* Reads the Google reCAPTCHA v3 token from the raw POST field
* `g-recaptcha-response` (populated by JS before form submit) and injects
* Reads the Cap CAPTCHA token from the raw POST field
* `cap-token` (auto-injected by the cap-widget web component) and injects
* it as this field's value before validation runs.
*
* @package App\Form
@@ -41,9 +41,8 @@ class RecaptchaType extends AbstractType
{
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (PreSubmitEvent $event): void {
$request = $this->requestStack->getCurrentRequest();
$token = $request?->request->getString('g-recaptcha-response') ?? '';
// For forms that set the token directly on the field (e.g. registration_form[recaptcha])
// rather than via a standalone g-recaptcha-response input, fall back to the submitted value.
$token = $request?->request->getString('cap-token') ?? '';
// For forms that set the token directly on the field, fall back to the submitted value.
if ($token === '') {
$token = (string) ($event->getData() ?? '');
}
@@ -0,0 +1,45 @@
<?php declare(strict_types=1);
/*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Class Version20260727120000
*
* @package App\Migrations
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 07. 27.
*/
final class Version20260727120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add expiry timestamps for account activation tokens.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE app_user ADD verification_token_expires_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql("UPDATE app_user SET verification_token_expires_at = CURRENT_TIMESTAMP + INTERVAL '24 hours' WHERE is_verified = FALSE AND verification_token IS NOT NULL");
$this->addSql('CREATE INDEX IDX_APP_USER_PENDING_ACTIVATION_EXPIRY ON app_user (verification_token_expires_at) WHERE is_verified = FALSE AND verification_token_expires_at IS NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX IDX_APP_USER_PENDING_ACTIVATION_EXPIRY');
$this->addSql('ALTER TABLE app_user DROP verification_token_expires_at');
}
}
+29
View File
@@ -11,6 +11,7 @@
namespace App\Repository;
use App\Entity\User;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\Persistence\ManagerRegistry;
@@ -87,6 +88,34 @@ class UserRepository extends ServiceEntityRepository implements PasswordUpgrader
}
}
public function countExpiredPendingUsers(DateTimeInterface $now): int
{
$qb = $this->createQueryBuilder('u');
return (int) $qb
->select($qb->expr()->count('u.id'))
->where($qb->expr()->eq('u.isVerified', ':isVerified'))
->andWhere($qb->expr()->lte('u.verificationTokenExpiresAt', ':now'))
->setParameter('isVerified', false)
->setParameter('now', $now)
->getQuery()
->getSingleScalarResult();
}
public function deleteExpiredPendingUsers(DateTimeInterface $now): int
{
$qb = $this->createQueryBuilder('u');
return $qb
->delete()
->where($qb->expr()->eq('u.isVerified', ':isVerified'))
->andWhere($qb->expr()->lte('u.verificationTokenExpiresAt', ':now'))
->setParameter('isVerified', false)
->setParameter('now', $now)
->getQuery()
->execute();
}
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
if (!$user instanceof User) {
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\Schedule as SymfonySchedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Symfony\Contracts\Cache\CacheInterface;
/**
* Class Schedule
*
* @package App
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 07. 27.
*/
#[AsSchedule]
class Schedule implements ScheduleProviderInterface
{
public function __construct(private CacheInterface $cache) { }
public function getSchedule(): SymfonySchedule
{
return (new SymfonySchedule())
->stateful($this->cache) // ensure missed tasks are executed
->processOnlyLastMissedRun(true) // ensure only last missed task is run
// add your own tasks here
// see https://symfony.com/doc/current/scheduler.html#attaching-recurring-messages-to-a-schedule
;
}
}
+2 -1
View File
@@ -11,6 +11,7 @@
namespace App\Security;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
@@ -40,5 +41,5 @@ final class UserChecker implements UserCheckerInterface
}
}
public function checkPostAuth(UserInterface $user): void { }
public function checkPostAuth(UserInterface $user, ?TokenInterface $token = null): void { }
}
-4
View File
@@ -186,7 +186,6 @@ final class BattleCardGenerator
$this->centeredText($im, 'mineseeker.hu', 16, self::WIDTH / 2, self::HEIGHT - 20, $muted);
imagepng($im, $dest);
imagedestroy($im);
}
/** Draw avatar or initials centered on $cx, $cy. */
@@ -263,9 +262,6 @@ final class BattleCardGenerator
}
}
imagedestroy($avatarImg);
imagedestroy($resized);
imagedestroy($mask);
} else {
/** Draw circular background with initials*/
imagefilledellipse($im, (int)$cx, (int)$cy, self::AVATAR_SIZE, self::AVATAR_SIZE, $color);
@@ -1,75 +0,0 @@
<?php declare(strict_types=1);
/**
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
/*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Service\Email;
use App\Entity\User;
use DateTime;
use Exception;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
/**
* Class SendUserRegistrationNotificationService
*
* @package App\Service\Email
* @author Lang <https://www.splendidbear.org>
* @category Class
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
* @link www.splendidbear.org
* @since 2026. 04. 20.
*/
readonly final class SendUserRegistrationNotificationService
{
public function __construct(
#[Autowire(env: 'APP_CONTACT_MAIL_ADDRESS')]
private string $appContactMailAddress,
private LoggerInterface $logger,
private MailerInterface $mailer,
) {
}
public function send(User $user, DateTime $registeredAt): void
{
try {
$this->mailer->send(
new TemplatedEmail()
->from('noreply@mineseeker.hu')
->to($this->appContactMailAddress)
->subject("🎉 New User Registration: {$user->getUsername()}")
->htmlTemplate('emails/user_registration_notification.html.twig')
->context([
'user' => $user,
'registeredAt' => $registeredAt,
])
);
} catch (TransportExceptionInterface|Exception $e) {
$this->logger->error("Failed to send user registration notification: {$e->getMessage()}", [
'exception' => $e,
'user' => $user->getUsername(),
]);
throw new RuntimeException("Failed to send user registration notification: {$e->getMessage()}");
}
}
}
+13 -25
View File
@@ -26,50 +26,38 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
*/
readonly final class RecaptchaService
{
private const string SITEVERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
/**
* Minimum score to accept a request (0.0 = bot, 1.0 = human).
* 0.5 is Google's recommended default threshold.
*/
private const float SCORE_THRESHOLD = 0.5;
public function __construct(
private HttpClientInterface $httpClient,
private LoggerInterface $logger,
#[Autowire(env: 'RECAPTCHA_SECRET_KEY')]
#[Autowire(env: 'CAP_API_ENDPOINT')]
private string $apiEndpoint,
#[Autowire(env: 'CAP_SECRET_KEY')]
private string $secretKey,
) {}
public function verify(string $token, string $remoteIp = ''): bool
public function verify(string $token): bool
{
if ($token === '') {
return false;
}
try {
$body = ['secret' => $this->secretKey, 'response' => $token];
if ($remoteIp !== '') {
$body['remoteip'] = $remoteIp;
}
$siteverifyUrl = rtrim($this->apiEndpoint, '/') . '/siteverify';
$data = $this->httpClient
->request('POST', self::SITEVERIFY_URL, ['body' => $body])
->request('POST', $siteverifyUrl, [
'body' => ['secret' => $this->secretKey, 'response' => $token],
])
->toArray();
$this->logger->info('reCAPTCHA verify response', [
'success' => $data['success'] ?? null,
'score' => $data['score'] ?? null,
'hostname' => $data['hostname'] ?? null,
'error-codes' => $data['error-codes'] ?? [],
'token_length' => strlen($token),
$this->logger->info('Cap verify response', [
'success' => $data['success'] ?? null,
'token_length' => strlen($token),
]);
return ($data['success'] ?? false) === true
&& ($data['score'] ?? 0.0) >= self::SCORE_THRESHOLD;
return ($data['success'] ?? false) === true;
} catch (\Throwable $e) {
$this->logger->error('reCAPTCHA verification failed: ' . $e->getMessage());
$this->logger->error('Cap verification failed: ' . $e->getMessage());
return false;
}
+1 -6
View File
@@ -11,7 +11,6 @@
namespace App\Validator;
use App\Service\RecaptchaService;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
@@ -30,7 +29,6 @@ final class RecaptchaValidator extends ConstraintValidator
{
public function __construct(
private readonly RecaptchaService $recaptcha,
private readonly RequestStack $requestStack,
) {
}
@@ -40,10 +38,7 @@ final class RecaptchaValidator extends ConstraintValidator
throw new UnexpectedTypeException($constraint, Recaptcha::class);
}
$request = $this->requestStack->getCurrentRequest();
$remoteIp = $request !== null ? ((string)$request->getClientIp()) : '';
if ($this->recaptcha->verify((string)$value, $remoteIp)) {
if ($this->recaptcha->verify((string)$value)) {
return;
}
+24
View File
@@ -316,6 +316,18 @@
"config/packages/mercure.yaml"
]
},
"symfony/messenger": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.0",
"ref": "d8936e2e2230637ef97e5eecc0eea074eecae58b"
},
"files": [
"config/packages/messenger.yaml"
]
},
"symfony/mime": {
"version": "v4.3.5"
},
@@ -375,6 +387,18 @@
"ref": "cda8b550123383d25827705d05a42acf6819fe4e"
}
},
"symfony/scheduler": {
"version": "7.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.2",
"ref": "caea3c928ee9e1b21288fd76aef36f16ea355515"
},
"files": [
"src/Schedule.php"
]
},
"symfony/security": {
"version": "v4.0.9"
},
+6 -2
View File
@@ -260,12 +260,16 @@
<li><a href="{{ path('MineSeekerBundle_rules') }}">Game Rules</a></li>
<li><a href="{{ path('MineSeekerBundle_terms') }}">Terms of Use</a></li>
<li><a href="{{ path('MineSeekerBundle_privacy') }}">Privacy Policy</a></li>
<li><a href="{{ path('MineSeekerBundle_contact') }}">Contact</a></li>
</ul>
</nav>
</div>
<div class="footer-copy">
<p>&copy; {{ "now"|date("Y") }} MineSeeker &nbsp;&bull;&nbsp; All rights reserved &nbsp;&bull;&nbsp;
<p>&copy; {{ "now"|date("Y") }} MineSeeker
<span class="footer-version" title="MineSeeker version {{ version }}">
<i class="fas fa-code-branch" aria-hidden="true"></i>
<span>v{{ version }}</span>
</span>
&nbsp;&bull;&nbsp; All rights reserved &nbsp;&bull;&nbsp;
<a href="https://www.splendidbear.org">www.splendidbear.org</a>
</p>
</div>
+10 -6
View File
@@ -3,18 +3,23 @@
{% block title %} - Play!{% endblock %}
{% block body %}
<a class="back-from-game" href="{{ path('MineSeekerBundle_homepage') }}">
<img src="{{ asset('/images/mine-logo-txt.png') }}" alt="Mineseeker Logo">
</a>
<div class="game-brand-bar">
<a class="back-from-game" href="{{ path('MineSeekerBundle_homepage') }}">
<img src="{{ asset('/images/mine-logo-txt.png') }}" alt="Mineseeker Logo">
</a>
<a class="game-cap-badge" href="https://trycap.dev" target="_blank" rel="noopener noreferrer">
<i class="fas fa-shield-halved"></i> Protected by Cap
</a>
</div>
<div class="mine-container">
<div id="mine-wrapper"
data-env="{{ env }}"
data-game-id="{{ app.request.get('gameAssoc') }}"
data-game-id="{{ game_assoc }}"
data-opponent-name="{{ opponent_name }}"
data-is-authenticated="{{ app.user ? '1' : '0' }}"
data-mercure-hub-url="{{ mercure_hub_url }}"
data-mercure-subscriber-jwt="{{ mercure_subscriber_jwt }}"
data-recaptcha-site-key="{{ recaptcha_site_key }}">
data-cap-api-endpoint="{{ cap_api_endpoint }}">
</div>
</div>
{% endblock %}
@@ -39,6 +44,5 @@
{% block javascripts %}
{{ parent() }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
{{ vite_entry_script_tags('mineseeker', { dependency: 'react' }) }}
{% endblock %}
+9 -6
View File
@@ -37,6 +37,14 @@
</p>
<div class="auth-card" style="max-width: 600px; margin: 0 auto;">
<div class="auth-title-row">
<h2 class="auth-title">Contact Form</h2>
<a class="auth-cap-badge" href="https://trycap.dev" target="_blank" rel="noopener noreferrer">
<i class="fas fa-shield-halved"></i> Protected by Cap
</a>
</div>
<p class="auth-sub">Your message is protected by automated abuse checks.</p>
<div data-cap-api-endpoint="{{ cap_api_endpoint }}" style="display: none;" aria-hidden="true"></div>
{{ form_start(form, {attr: {class: 'auth-form'}}) }}
<div class="auth-field">
@@ -127,10 +135,5 @@
{% block javascripts %}
{{ parent() }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
{{ vite_entry_script_tags('contact', { dependency: 'react' }) }}
<div id="contact-form-wrapper"
data-site-key="{{ recaptcha_site_key }}"
data-recaptcha-field-id="{{ form.recaptcha.vars.id }}">
</div>
{{ vite_entry_script_tags('cap') }}
{% endblock %}
+8 -17
View File
@@ -34,8 +34,14 @@
</div>
{% else %}
<div class="auth-card">
<h2 class="auth-title">Forgot Password</h2>
<div class="auth-title-row">
<h2 class="auth-title">Forgot Password</h2>
<a class="auth-cap-badge" href="https://trycap.dev" target="_blank" rel="noopener noreferrer">
<i class="fas fa-shield-halved"></i> Protected by Cap
</a>
</div>
<p class="auth-sub">Enter your email and we'll send you a reset link</p>
<div data-cap-api-endpoint="{{ cap_api_endpoint }}" style="display: none;" aria-hidden="true"></div>
{{ form_start(form, {attr: {class: 'auth-form'}}) }}
@@ -75,20 +81,5 @@
{% block javascripts %}
{{ parent() }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
<script>
(function () {
const form = document.querySelector('.auth-form');
if (!form) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
grecaptcha.ready(function () {
grecaptcha.execute('{{ recaptcha_site_key }}', {action: 'forgot_password'}).then(function (token) {
document.getElementById('{{ form.recaptcha.vars.id }}').value = token;
form.submit();
});
});
});
}());
</script>
{{ vite_entry_script_tags('cap') }}
{% endblock %}
+9 -18
View File
@@ -36,7 +36,12 @@
{% endfor %}
<div class="auth-card">
<h2 class="auth-title">Sign In</h2>
<div class="auth-title-row">
<h2 class="auth-title">Sign In</h2>
<a class="auth-cap-badge" href="https://trycap.dev" target="_blank" rel="noopener noreferrer">
<i class="fas fa-shield-halved"></i> Protected by Cap
</a>
</div>
<p class="auth-sub">Welcome back, commander</p>
{% if error %}
@@ -46,6 +51,8 @@
</div>
{% endif %}
<div data-cap-api-endpoint="{{ cap_api_endpoint }}" style="display: none;" aria-hidden="true"></div>
<form class="auth-form" method="post" action="{{ path('MineSeekerBundle_login') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}"/>
@@ -92,8 +99,6 @@
</p>
</div>
<input type="hidden" id="g-recaptcha-response" name="g-recaptcha-response"/>
<button type="submit" class="auth-submit">
<i class="fas fa-right-to-bracket"></i> Sign In
</button>
@@ -121,20 +126,6 @@
{% block javascripts %}
{{ parent() }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
<script>
document.querySelector('.auth-form').addEventListener('submit', function (e) {
e.preventDefault();
const form = this;
grecaptcha.ready(function () {
grecaptcha.execute('{{ recaptcha_site_key }}', {action: 'login'}).then(function (token) {
document.getElementById('g-recaptcha-response').value = token;
form.submit();
});
});
});
</script>
{{ vite_entry_script_tags('cap') }}
{{ vite_entry_script_tags('passkey', { dependency: 'react' }) }}
{% endblock %}
+8 -17
View File
@@ -42,8 +42,14 @@
</div>
{% else %}
<div class="auth-card">
<h2 class="auth-title">Create Account</h2>
<div class="auth-title-row">
<h2 class="auth-title">Create Account</h2>
<a class="auth-cap-badge" href="https://trycap.dev" target="_blank" rel="noopener noreferrer">
<i class="fas fa-shield-halved"></i> Protected by Cap
</a>
</div>
<p class="auth-sub">Join the battle — no subscription required</p>
<div data-cap-api-endpoint="{{ cap_api_endpoint }}" style="display: none;" aria-hidden="true"></div>
{{ form_start(form, {attr: {class: 'auth-form'}}) }}
@@ -153,20 +159,5 @@
{% block javascripts %}
{{ parent() }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
<script>
(function () {
const form = document.querySelector('.auth-form');
if (!form) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
grecaptcha.ready(function () {
grecaptcha.execute('{{ recaptcha_site_key }}', {action: 'register'}).then(function (token) {
document.getElementById('{{ form.recaptcha.vars.id }}').value = token;
form.submit();
});
});
});
}());
</script>
{{ vite_entry_script_tags('cap') }}
{% endblock %}
+8 -16
View File
@@ -16,8 +16,14 @@
{% block body %}
<div class="auth-page">
<div class="auth-card">
<h2 class="auth-title">Reset Password</h2>
<div class="auth-title-row">
<h2 class="auth-title">Reset Password</h2>
<a class="auth-cap-badge" href="https://trycap.dev" target="_blank" rel="noopener noreferrer">
<i class="fas fa-shield-halved"></i> Protected by Cap
</a>
</div>
<p class="auth-sub">Choose a new password for your account</p>
<div data-cap-api-endpoint="{{ cap_api_endpoint }}" style="display: none;" aria-hidden="true"></div>
{{ form_start(form, {attr: {class: 'auth-form'}}) }}
@@ -65,19 +71,5 @@
{% block javascripts %}
{{ parent() }}
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
<script>
(function () {
document.querySelector('.auth-form').addEventListener('submit', function (e) {
e.preventDefault();
const form = this;
grecaptcha.ready(function () {
grecaptcha.execute('{{ recaptcha_site_key }}', {action: 'reset_password'}).then(function (token) {
document.getElementById('{{ form.recaptcha.vars.id }}').value = token;
form.submit();
});
});
});
}());
</script>
{{ vite_entry_script_tags('cap') }}
{% endblock %}
@@ -1,92 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>New User Registration</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
border-radius: 8px 8px 0 0;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 24px;
}
.content {
background: #f9f9f9;
padding: 30px;
border-radius: 0 0 8px 8px;
}
.field {
margin-bottom: 20px;
}
.field-label {
font-weight: 600;
color: #666;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 5px;
}
.field-value {
background: white;
padding: 12px;
border-radius: 4px;
border-left: 3px solid #667eea;
}
.footer {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #ddd;
font-size: 12px;
color: #999;
text-align: center;
}
</style>
</head>
<body>
<div class="header">
<h1>👤 New User Registration</h1>
</div>
<div class="content">
<div class="field">
<div class="field-label">Username</div>
<div class="field-value">
<strong>{{ user.username }}</strong>
</div>
</div>
<div class="field">
<div class="field-label">Email</div>
<div class="field-value">
<a href="mailto:{{ user.email }}">{{ user.email }}</a>
</div>
</div>
<div class="field">
<div class="field-label">Details</div>
<div class="field-value">
<strong>Registered:</strong> {{ registeredAt|date('Y-m-d H:i:s') }}<br>
<strong>Status:</strong> {% if user.isVerified %}✓ Verified{% else %}⏳ Awaiting Email Verification{% endif %}<br>
<strong>Email Verified:</strong> {% if user.isVerified %}Yes{% else %}No - activation link sent{% endif %}
</div>
</div>
<div class="footer">
A new user has registered on MineSeeker.<br>
User must verify their email before account is fully activated.
</div>
</div>
</body>
</html>
@@ -0,0 +1,74 @@
<?php declare(strict_types=1);
/*
* This file is part of the SplendidBear Websites' projects.
*
* Copyright (c) 2026 @ www.splendidbear.org
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Command;
use App\Command\PurgeExpiredPendingUsersCommand;
use App\Entity\User;
use App\Tests\Factory\UserFactory;
use App\Tests\WebTestCase;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use Symfony\Component\Console\Tester\CommandTester;
#[TestDox('Purge Expired Pending Users Command')]
class PurgeExpiredPendingUsersCommandTest extends WebTestCase
{
#[Test]
#[TestDox('Deletes expired unverified users only')]
public function deletesOnlyExpiredUnverifiedUsers(): void
{
$expiredUser = UserFactory::createOne([
'isVerified' => false,
'verificationToken' => 'expired-token',
'verificationTokenExpiresAt' => new DateTime('-1 minute'),
]);
$pendingUser = UserFactory::createOne([
'isVerified' => false,
'verificationToken' => 'valid-token',
'verificationTokenExpiresAt' => new DateTime('+1 day'),
]);
$verifiedUser = UserFactory::createOne([
'isVerified' => true,
'verificationToken' => 'verified-token',
'verificationTokenExpiresAt' => new DateTime('-1 minute'),
]);
$command = static::getContainer()->get(PurgeExpiredPendingUsersCommand::class);
$tester = new CommandTester($command);
$tester->execute([]);
$em = static::getContainer()->get(EntityManagerInterface::class);
$em->clear();
self::assertNull($em->find(User::class, $expiredUser->id));
self::assertNotNull($em->find(User::class, $pendingUser->id));
self::assertNotNull($em->find(User::class, $verifiedUser->id));
}
#[Test]
#[TestDox('Dry run keeps expired unverified users')]
public function dryRunDoesNotDeleteExpiredUsers(): void
{
$expiredUser = UserFactory::createOne([
'isVerified' => false,
'verificationToken' => 'expired-token',
'verificationTokenExpiresAt' => new DateTime('-1 minute'),
]);
$command = static::getContainer()->get(PurgeExpiredPendingUsersCommand::class);
$tester = new CommandTester($command);
$tester->execute(['--dry-run' => true]);
$em = static::getContainer()->get(EntityManagerInterface::class);
self::assertNotNull($em->find(User::class, $expiredUser->id));
}
}
+3 -5
View File
@@ -89,14 +89,12 @@ class GameControllerTest extends WebTestCase
}
#[Test]
#[TestDox('Contact page loads successfully with form')]
public function contactPageLoadsSuccessfully(): void
#[TestDox('Contact page is not publicly accessible')]
public function contactPageIsNotPubliclyAccessible(): void
{
$crawler = $this->client->request('GET', '/contact');
self::assertResponseStatusCodeSame(Response::HTTP_OK);
self::assertSelectorExists('form');
self::assertSelectorExists('button[type="submit"]');
self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
}
#[Test]
+23 -2
View File
@@ -10,6 +10,8 @@
namespace App\Tests\Controller;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use App\Tests\Factory\UserFactory;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
@@ -103,6 +105,25 @@ class SecurityControllerTest extends WebTestCase
self::assertResponseRedirects('/login');
}
#[Test]
#[TestDox('Expired account activation token does not activate the account')]
public function expiredActivationTokenDoesNotActivateAccount(): void
{
$user = UserFactory::createOne([
'isVerified' => false,
'verificationToken' => 'expired-activation-token',
'verificationTokenExpiresAt' => new DateTime('-1 minute'),
]);
$this->client->request('GET', '/activate/expired-activation-token');
self::assertResponseRedirects('/login');
static::getContainer()->get(EntityManagerInterface::class)->refresh($user);
self::assertFalse($user->isVerified);
self::assertSame('expired-activation-token', $user->verificationToken);
}
#[Test]
#[TestDox('Password reset with invalid token redirects to forgot password')]
public function resetPasswordWithInvalidTokenShowsError(): void
@@ -117,7 +138,7 @@ class SecurityControllerTest extends WebTestCase
public function authenticatedUserRedirectsFromLogin(): void
{
$user = UserFactory::createOne();
$this->client->loginUser($user->_real());
$this->client->loginUser($user);
$this->client->request('GET', '/login');
@@ -129,7 +150,7 @@ class SecurityControllerTest extends WebTestCase
public function authenticatedUserRedirectsFromRegister(): void
{
$user = UserFactory::createOne();
$this->client->loginUser($user->_real());
$this->client->loginUser($user);
$this->client->request('GET', '/register');
+3 -3
View File
@@ -11,7 +11,7 @@
namespace App\Tests\Factory;
use App\Entity\ContactMessage;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
/**
* Class ContactMessageFactory
@@ -23,9 +23,9 @@ use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
* @link www.splendidbear.org
* @since 2026. 04. 21.
*
* @extends PersistentProxyObjectFactory<ContactMessage>
* @extends PersistentObjectFactory<ContactMessage>
*/
class ContactMessageFactory extends PersistentProxyObjectFactory
class ContactMessageFactory extends PersistentObjectFactory
{
protected function defaults(): array
{
+3 -3
View File
@@ -12,7 +12,7 @@ namespace App\Tests\Factory;
use App\Entity\Gamer;
use DateTime;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
/**
* Class GamerFactory
@@ -24,9 +24,9 @@ use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
* @link www.splendidbear.org
* @since 2026. 04. 21.
*
* @extends PersistentProxyObjectFactory<Gamer>
* @extends PersistentObjectFactory<Gamer>
*/
class GamerFactory extends PersistentProxyObjectFactory
class GamerFactory extends PersistentObjectFactory
{
protected function defaults(): array
{
+4 -4
View File
@@ -12,7 +12,7 @@ namespace App\Tests\Factory;
use App\Entity\Grid;
use App\Entity\GridRow;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
/**
* Class GridFactory
@@ -24,9 +24,9 @@ use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
* @link www.splendidbear.org
* @since 2026. 04. 21.
*
* @extends PersistentProxyObjectFactory<Grid>
* @extends PersistentObjectFactory<Grid>
*/
class GridFactory extends PersistentProxyObjectFactory
class GridFactory extends PersistentObjectFactory
{
protected function defaults(): array
{
@@ -45,7 +45,7 @@ class GridFactory extends PersistentProxyObjectFactory
return $this->afterInstantiate(function (Grid $grid): void {
for ($i = 0; $i < 16; $i++) {
/** @var GridRow $factory */
$factory = GridRowFactory::new()->create()->_real();
$factory = GridRowFactory::new()->create();
$grid->addGridRow($factory);
}
});
+3 -3
View File
@@ -11,7 +11,7 @@
namespace App\Tests\Factory;
use App\Entity\GridRow;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
/**
* Class GridRowFactory
@@ -23,9 +23,9 @@ use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
* @link www.splendidbear.org
* @since 2026. 04. 21.
*
* @extends PersistentProxyObjectFactory<GridRow>
* @extends PersistentObjectFactory<GridRow>
*/
class GridRowFactory extends PersistentProxyObjectFactory
class GridRowFactory extends PersistentObjectFactory
{
protected function defaults(): array
{
+3 -3
View File
@@ -13,7 +13,7 @@ namespace App\Tests\Factory;
use App\Entity\PlayedGame;
use DateTime;
use Symfony\Component\Uid\Uuid;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
/**
* Class PlayedGameFactory
@@ -25,9 +25,9 @@ use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
* @link www.splendidbear.org
* @since 2026. 04. 21.
*
* @extends PersistentProxyObjectFactory<PlayedGame>
* @extends PersistentObjectFactory<PlayedGame>
*/
class PlayedGameFactory extends PersistentProxyObjectFactory
class PlayedGameFactory extends PersistentObjectFactory
{
protected function defaults(): array
{
+3 -3
View File
@@ -12,7 +12,7 @@ namespace App\Tests\Factory;
use App\Entity\Step;
use DateTime;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
/**
* Class StepFactory
@@ -24,9 +24,9 @@ use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
* @link www.splendidbear.org
* @since 2026. 04. 21.
*
* @extends PersistentProxyObjectFactory<Step>
* @extends PersistentObjectFactory<Step>
*/
class StepFactory extends PersistentProxyObjectFactory
class StepFactory extends PersistentObjectFactory
{
protected function defaults(): array
{
+3 -3
View File
@@ -11,7 +11,7 @@
namespace App\Tests\Factory;
use App\Entity\User;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
/**
* Class UserFactory
@@ -23,9 +23,9 @@ use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
* @link www.splendidbear.org
* @since 2026. 04. 21.
*
* @extends PersistentProxyObjectFactory<User>
* @extends PersistentObjectFactory<User>
*/
class UserFactory extends PersistentProxyObjectFactory
class UserFactory extends PersistentObjectFactory
{
protected function defaults(): array
{
+3 -3
View File
@@ -12,7 +12,7 @@ namespace App\Tests\Factory;
use App\Entity\WebAuthnCredential;
use DateTime;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
/**
* Class WebAuthnCredentialFactory
@@ -24,9 +24,9 @@ use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
* @link www.splendidbear.org
* @since 2026. 04. 21.
*
* @extends PersistentProxyObjectFactory<WebAuthnCredential>
* @extends PersistentObjectFactory<WebAuthnCredential>
*/
class WebAuthnCredentialFactory extends PersistentProxyObjectFactory
class WebAuthnCredentialFactory extends PersistentObjectFactory
{
protected function defaults(): array
{
+3 -3
View File
@@ -44,7 +44,7 @@ class FactoryExampleTest extends WebTestCase
{
$user = UserFactory::createOne();
self::assertInstanceOf(User::class, $user->_real());
self::assertInstanceOf(User::class, $user);
self::assertNotNull($user->username);
self::assertNotNull($user->email);
self::assertTrue($user->isVerified);
@@ -87,7 +87,7 @@ class FactoryExampleTest extends WebTestCase
->withRegisteredPlayers()
->create();
self::assertInstanceOf(PlayedGame::class, $game->_real());
self::assertInstanceOf(PlayedGame::class, $game);
self::assertNotNull($game->red);
self::assertNotNull($game->blue);
self::assertNull($game->redAnon);
@@ -181,7 +181,7 @@ class FactoryExampleTest extends WebTestCase
->recentlyUsed()
->create(['user' => $user]);
self::assertEquals($user->_real(), $credential->user);
self::assertEquals($user, $credential->user);
self::assertEquals('YubiKey 5C', $credential->credentialName);
self::assertNotNull($credential->lastUsedAt);
}
+25 -88
View File
@@ -34,16 +34,17 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
#[TestDox('Recaptcha Service')]
class RecaptchaServiceTest extends TestCase
{
private const SECRET_KEY = 'test-secret-key';
private const API_ENDPOINT = 'http://localhost:3000/test-site-key/';
private const SECRET_KEY = 'test-secret-key';
#[Test]
#[TestDox('Verify returns false for empty token')]
public function verifyReturnsFalseForEmptyToken(): void
{
$httpClient = $this->createMock(HttpClientInterface::class);
$logger = $this->createMock(LoggerInterface::class);
$logger = $this->createMock(LoggerInterface::class);
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$service = new RecaptchaService($httpClient, $logger, self::API_ENDPOINT, self::SECRET_KEY);
$result = $service->verify('');
@@ -55,15 +56,14 @@ class RecaptchaServiceTest extends TestCase
public function verifyReturnsFalseWhenApiReturnsFailure(): void
{
$mockResponse = new MockResponse(json_encode([
'success' => false,
'error-codes' => ['invalid-input-secret'],
'success' => false,
], JSON_THROW_ON_ERROR));
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('info');
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$service = new RecaptchaService($httpClient, $logger, self::API_ENDPOINT, self::SECRET_KEY);
$result = $service->verify('invalid-token');
@@ -71,47 +71,24 @@ class RecaptchaServiceTest extends TestCase
}
#[Test]
#[TestDox('Verify returns true when api returns success and high score')]
public function verifyReturnsTrueWhenApiReturnsSuccessAndHighScore(): void
#[TestDox('Verify returns true when api returns success')]
public function verifyReturnsTrueWhenApiReturnsSuccess(): void
{
$mockResponse = new MockResponse(json_encode([
'success' => true,
'score' => 0.8,
'hostname' => 'test.com',
'success' => true,
], JSON_THROW_ON_ERROR));
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('info');
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$service = new RecaptchaService($httpClient, $logger, self::API_ENDPOINT, self::SECRET_KEY);
$result = $service->verify('valid-token');
$this->assertTrue($result);
}
#[Test]
#[TestDox('Verify returns false when score below threshold')]
public function verifyReturnsFalseWhenScoreBelowThreshold(): void
{
$mockResponse = new MockResponse(json_encode([
'success' => true,
'score' => 0.3,
'hostname' => 'test.com',
], JSON_THROW_ON_ERROR));
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('info');
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$result = $service->verify('low-score-token');
$this->assertFalse($result);
}
#[Test]
#[TestDox('Verify returns false when api throws exception')]
public function verifyReturnsFalseWhenApiThrowsException(): void
@@ -119,10 +96,10 @@ class RecaptchaServiceTest extends TestCase
$mockResponse = new MockResponse('', ['http_code' => 500]);
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('error');
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$service = new RecaptchaService($httpClient, $logger, self::API_ENDPOINT, self::SECRET_KEY);
$result = $service->verify('test-token');
@@ -130,59 +107,19 @@ class RecaptchaServiceTest extends TestCase
}
#[Test]
#[TestDox('Verify includes remote ip when provided')]
public function verifyIncludesRemoteIpWhenProvided(): void
#[TestDox('Siteverify URL is derived from api endpoint')]
public function siteverifyUrlIsDerivedFromApiEndpoint(): void
{
$mockResponse = new MockResponse(json_encode([
'success' => true,
'score' => 0.9,
], JSON_THROW_ON_ERROR));
$mockResponse = new MockResponse(json_encode(['success' => true], JSON_THROW_ON_ERROR));
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects($this->once())->method('info');
$httpClient = new MockHttpClient(function (string $method, string $url) use ($mockResponse): MockResponse {
$this->assertSame('http://localhost:3000/test-site-key/siteverify', $url);
return $mockResponse;
});
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$logger = $this->createMock(LoggerInterface::class);
$service = new RecaptchaService($httpClient, $logger, self::API_ENDPOINT, self::SECRET_KEY);
$result = $service->verify('test-token', '192.168.1.1');
$this->assertTrue($result);
$service->verify('test-token');
}
#[Test]
#[TestDox('Verify with score at threshold')]
public function verifyWithScoreAtThreshold(): void
{
$mockResponse = new MockResponse(json_encode([
'success' => true,
'score' => 0.5,
], JSON_THROW_ON_ERROR));
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$result = $service->verify('threshold-token');
$this->assertTrue($result);
}
#[Test]
#[TestDox('Verify with no score fails')]
public function verifyWithNoScoreFails(): void
{
$mockResponse = new MockResponse(json_encode([
'success' => true,
], JSON_THROW_ON_ERROR));
$httpClient = new MockHttpClient($mockResponse);
$logger = $this->createMock(LoggerInterface::class);
$service = new RecaptchaService($httpClient, $logger, self::SECRET_KEY);
$result = $service->verify('no-score-token');
$this->assertFalse($result);
}
}
}
+2 -2
View File
@@ -27,7 +27,7 @@ export default defineConfig({
mineseeker: './assets/js/app.jsx',
passkey: './assets/js/passkey.jsx',
profile: './assets/js/profile.jsx',
contact: './assets/js/contact.jsx',
cap: './assets/js/cap.js',
mineseekerStyle: './assets/css/style.mineseeker.scss',
homeStyle: './assets/css/style.layout.scss',
passkeyStyle: './assets/css/passkey.scss',
@@ -36,7 +36,7 @@ export default defineConfig({
},
server: {
cors: {
origin: ['https://mine.local', 'http://mine.local'],
origin: ['https://mine.home.arpa', 'http://mine.home.arpa'],
},
},
css: {